diff --git a/apps/desktop/src/plugins/kanban/api.ts b/apps/desktop/src/plugins/kanban/api.ts index 0f1010b5674..9a1a9b294a7 100644 --- a/apps/desktop/src/plugins/kanban/api.ts +++ b/apps/desktop/src/plugins/kanban/api.ts @@ -20,6 +20,7 @@ import type { KanbanTask, KanbanTaskDetail, OrchestrationSettings, + TaskEstimate, WorkerLog } from './types' @@ -203,6 +204,15 @@ export const createBoard = (slug: string, name: string, projectId?: string) => body: { slug, name, ...(projectId ? { project_id: projectId } : {}) } }) +/** Rough auxiliary-model estimate for a task (tokens + complexity). Makes a + * model call — gate behind an explicit user action + disclaimer. */ +export const estimateTask = (id: string) => + call(withBoard(`/tasks/${id}/estimate`), { method: 'POST', body: {} }) + +/** Estimate from typed title/body before a task exists (create dialog). */ +export const estimateNew = (title: string, body: string) => + call('/estimate', { method: 'POST', body: { title, body: body || undefined } }) + /** Edit a board's display metadata + default project directory. Pass * `default_workdir: ''` to clear it. Slug is immutable. */ export const updateBoard = (slug: string, patch: Record) => diff --git a/apps/desktop/src/plugins/kanban/board.tsx b/apps/desktop/src/plugins/kanban/board.tsx index b3ba686a472..35091e47e09 100644 --- a/apps/desktop/src/plugins/kanban/board.tsx +++ b/apps/desktop/src/plugins/kanban/board.tsx @@ -12,6 +12,7 @@ import { Button, cn, Codicon, + compactNumber, ContextMenu, ContextMenuContent, ContextMenuItem, @@ -68,6 +69,7 @@ import { bulkTasks, createTask, deleteTask, + estimateNew, fetchBoard, fetchBoards, fetchProfiles, @@ -77,7 +79,7 @@ import { import { BoardSwitcher } from './board-switcher' import { TaskDrawer } from './drawer' import { OrchestrationPanel } from './orchestration' -import { columnMeta, type KanbanBoard, type KanbanTask } from './types' +import { columnMeta, COMPLEXITY_LABEL, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types' import { ago, ARC_TITLES, @@ -539,9 +541,10 @@ function NewTaskDialog({ // unassigned — parking a card is the explicit choice, not the default. const resolvedDefault = useOrchestration()?.resolved_default_assignee || 'default' - // Board-level workspace default: a task inherits the current board's project - // workspace (scratch when unscoped, worktree in a git repo, else dir) unless - // overridden below. Set the board's project in the switcher's "Board settings…". + // Board-level workspace default: a task inherits the current board's + // configured project dir (scratch when unset, worktree in a git repo, else + // dir) unless the operator overrides it below. Set the board default in the + // board switcher's "Board settings…". const selectedSlug = useValue($boardSlug) const { data: boards } = useQuery({ queryKey: BOARDS_KEY, queryFn: fetchBoards, staleTime: 30_000 }) const currentBoard = boards?.boards.find(b => b.slug === (selectedSlug || boards.current)) @@ -555,13 +558,28 @@ function NewTaskDialog({ const [priority, setPriority] = useState('0') const [skills, setSkills] = useState('') const [workspaceKind, setWorkspaceKind] = useState(boardDefaultKind) - // Empty = inherit the board's project dir (backend resolves it); a path here - // overrides just this task. Only meaningful for dir/worktree. + // Empty = inherit the board's default project dir (backend resolves it); + // a path here overrides just this task. Only meaningful for dir/worktree. const [workspacePath, setWorkspacePath] = useState('') const [parent, setParent] = useState('') const [goalMode, setGoalMode] = useState(false) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [estimate, setEstimate] = useState(null) + + // Rough effort estimate from the typed title/body (before the task exists), + // via the auto-routed auxiliary model. Makes a model call — explicit action. + const estMut = useMutation({ + mutationFn: () => estimateNew(title.trim(), bodyText.trim()), + onError: err => host.notify({ kind: 'error', message: errText(err) }), + onSuccess: r => { + if (r.ok) { + setEstimate(r) + } else { + host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' }) + } + } + }) // Reset per open — the dialog is externally controlled (open = target set), // so onOpenChange(true) never fires; key the reset off `target` (and the @@ -579,6 +597,7 @@ function NewTaskDialog({ setGoalMode(false) setError(null) setBusy(false) + setEstimate(null) } }, [target, boardDefaultKind]) @@ -610,7 +629,7 @@ function NewTaskDialog({ title: trimmed, triage: isTriage, workspace_kind: workspaceKind, - // Empty → backend inherits the board's project dir. + // Empty → backend inherits the board's default project dir. workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined }) @@ -743,6 +762,41 @@ function NewTaskDialog({ {error && {error}} +
+ {estimate?.ok ? ( + <> + + + ~{compactNumber(estimate.est_tokens)} tok + {estimate.complexity ? ` · ${COMPLEXITY_LABEL[estimate.complexity] ?? estimate.complexity}` : ''} + + + + + + + ) : ( + + + + )} +
diff --git a/apps/desktop/src/plugins/kanban/drawer.tsx b/apps/desktop/src/plugins/kanban/drawer.tsx index 594884bfcb3..a59a24105e6 100644 --- a/apps/desktop/src/plugins/kanban/drawer.tsx +++ b/apps/desktop/src/plugins/kanban/drawer.tsx @@ -10,6 +10,7 @@ import { Button, cn, Codicon, + compactNumber, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -20,6 +21,7 @@ import { Loader, LogView, Textarea, + Tip, useMutation, useQuery, useQueryClient, @@ -31,6 +33,7 @@ import { $boardSlug, addComment, deleteTask, + estimateTask, fetchLog, fetchProfiles, fetchTask, @@ -44,12 +47,14 @@ import { } from './api' import { columnMeta, + COMPLEXITY_LABEL, type Diagnostic, type DiagnosticAction, type KanbanAttachment, type KanbanEvent, type KanbanTaskDetail, - SEVERITY_TONE + SEVERITY_TONE, + type TaskEstimate } from './types' import { ago, @@ -426,6 +431,73 @@ function AttachmentsSection({ ) } +// Rough effort estimate via the auxiliary (auto-routed) model. Tokens + +// complexity, never dollars — providers don't report cost reliably. Gated +// behind an explicit click + disclaimer since it makes a model call. The +// control keeps a stable footprint (spinner swaps in place) so nothing jumps. +function EstimateSection({ id }: { id: string }) { + const [result, setResult] = useState(null) + + const est = useMutation({ + mutationFn: () => estimateTask(id), + onError: err => host.notify({ kind: 'error', message: errText(err) }), + onSuccess: r => { + if (r.ok) { + setResult(r) + } else { + host.notify({ kind: 'warning', message: r.reason || 'Could not estimate' }) + } + } + }) + + // A new task resets the cached estimate (the drawer reuses one instance). + useEffect(() => setResult(null), [id]) + + return ( +
+ {result?.ok ? ( +
+
+ + ~{compactNumber(result.est_tokens)} tok + + {result.complexity && ( + + · {COMPLEXITY_LABEL[result.complexity] ?? result.complexity} + + )} + + + +
+ {result.rationale && ( +

{result.rationale}

+ )} +
+ ) : ( +
+ + + makes a model call + +
+ )} +
+ ) +} + export function TaskDrawer({ columns, id, @@ -666,6 +738,8 @@ export function TaskDrawer({ void mutate(() => patchTask(task.id, { body }))()} /> + + {task.result && (

{task.result}

diff --git a/apps/desktop/src/plugins/kanban/plugin.tsx b/apps/desktop/src/plugins/kanban/plugin.tsx index ed7d73269f8..539cd404340 100644 --- a/apps/desktop/src/plugins/kanban/plugin.tsx +++ b/apps/desktop/src/plugins/kanban/plugin.tsx @@ -23,6 +23,7 @@ import { SIDEBAR_NAV_AREA, type SidebarNavContribution, STATUSBAR_AREAS, + Tip, useQuery, useValue } from '@hermes/plugin-sdk' @@ -55,18 +56,19 @@ function KanbanCount() { } return ( - + + + ) } diff --git a/apps/desktop/src/plugins/kanban/types.ts b/apps/desktop/src/plugins/kanban/types.ts index 838fa190935..dfad533bad7 100644 --- a/apps/desktop/src/plugins/kanban/types.ts +++ b/apps/desktop/src/plugins/kanban/types.ts @@ -144,6 +144,18 @@ export interface KanbanProject { color?: null | string } +/** POST /tasks/:id/estimate — rough auxiliary-model estimate (never dollars). */ +export interface TaskEstimate { + ok: boolean + reason?: null | string + est_tokens?: number + complexity?: 'L' | 'M' | 'S' | null + rationale?: null | string + model?: null | string +} + +/** Human-readable complexity band (the backend returns the compact letter). */ +export const COMPLEXITY_LABEL: Record = { L: 'Large', M: 'Medium', S: 'Small' } export interface BoardsResponse { boards: BoardMeta[] diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index ae7069e931f..fbaf0260e58 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -216,6 +216,9 @@ export { useI18n, usePluginI18n } from '@/i18n' +/** THE compact-number formatter — every user-facing count/token figure goes + * through here (1230 → "1.2k", 1_500_000 → "1.5M"). Don't hand-roll `/1000`. */ +export { compactNumber } from '@/lib/format' export { triggerHaptic as haptic } from '@/lib/haptics' /** The app's lucide icon set (RefreshCw, LayoutDashboard, Activity, …). */ export * as icons from '@/lib/icons' @@ -237,8 +240,6 @@ export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left' * setup.runtime_check, reconciled) — pass `host.request`. Don't hand-roll * readiness from raw RPC shapes. */ export { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness' -/** Canonical time formatting — every timestamp/age string in the app comes - * from these (localized `Intl` under the hood). Don't hand-roll "Xm ago". */ export { coarseElapsed, fmtDateTime, fmtDayTime, relativeTime } from '@/lib/time' export { cn } from '@/lib/utils' export { THEMES_AREA } from '@/themes/user-themes' diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 9403a8976b6..68bba09aa81 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -1740,6 +1740,134 @@ def reassign_task_endpoint( conn.close() +# --------------------------------------------------------------------------- +# Estimate — a rough token/complexity estimate for a task via the auxiliary +# (auto-routed) model. NOT a dollar cost: providers don't report cost +# reliably, so we estimate tokens + a complexity band with a one-line why. +# --------------------------------------------------------------------------- + +_ESTIMATE_SYSTEM_PROMPT = ( + "You estimate how much work an autonomous coding agent will spend on a " + "kanban task. Given the task title and description, respond with STRICT " + "JSON only (no prose, no code fence):\n" + '{"est_tokens": , ' + '"complexity": "S"|"M"|"L", ' + '"rationale": ""}\n' + "Base the token figure on a realistic multi-turn agent run (reading files, " + "tool calls, edits, retries) — not a single reply. S≈small/localized, " + "M≈multi-file, L≈broad or ambiguous. Be honest that this is a rough guess." +) + + +class EstimateBody(BaseModel): + title: str = "" + body: Optional[str] = None + + +@router.post("/estimate") +def estimate_text_endpoint(payload: EstimateBody): + """Estimate from raw title/body — used by the create dialog before a task + exists yet. Same outcome shape as the per-task endpoint below.""" + return _run_estimate(payload.title, payload.body) + + +@router.post("/tasks/{task_id}/estimate") +def estimate_task_endpoint(task_id: str, board: Optional[str] = Query(None)): + """Rough token + complexity estimate for an existing task via the auxiliary + model. Returns ``{ok, est_tokens, complexity, rationale, model}``; a non-OK + outcome is NOT an HTTP error. Runs in FastAPI's threadpool (sync ``def``) + because the LLM call can take several seconds. + """ + board = _resolve_board(board) + conn = _conn(board=board) + try: + task = kanban_db.get_task(conn, task_id) + finally: + conn.close() + if task is None: + raise HTTPException(status_code=404, detail=f"task {task_id} not found") + return _run_estimate(task.title, task.body) + + +def _run_estimate(title: str, body: Optional[str]) -> dict: + """Shared estimate core: ask the auto-routed auxiliary model for a rough + token + complexity read on a task described by ``title``/``body``. + + Never raises — a bad config / parse / API error becomes + ``{"ok": False, "reason": ...}`` so the UI can render it inline. + """ + if not (title or "").strip(): + return {"ok": False, "reason": "a title is required to estimate"} + + try: + from agent.auxiliary_client import call_llm + except Exception: + return {"ok": False, "reason": "auxiliary client unavailable"} + + def _cap(s: Optional[str], n: int) -> str: + s = (s or "").strip() + return s if len(s) <= n else s[:n] + "…" + + user_msg = ( + f"Title: {_cap(title, 400)}\n\n" + f"Description:\n{_cap(body, 4000) or '(none)'}" + ) + try: + resp = call_llm( + task="kanban_estimator", + messages=[ + {"role": "system", "content": _ESTIMATE_SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + temperature=0.0, + max_tokens=300, + timeout=60, + ) + except Exception as exc: + return {"ok": False, "reason": f"LLM error: {type(exc).__name__}"} + + try: + raw = (resp.choices[0].message.content or "").strip() + model = getattr(resp, "model", None) + except Exception: + raw, model = "", None + + # Reuse the same tolerant JSON-blob extraction the specifier uses. + parsed: Optional[dict] = None + try: + import json as _json + import re as _re + blob = raw + if not blob.lstrip().startswith("{"): + m = _re.search(r"\{.*\}", blob, _re.DOTALL) + blob = m.group(0) if m else blob + obj = _json.loads(blob) + if isinstance(obj, dict): + parsed = obj + except Exception: + parsed = None + + if not parsed: + return {"ok": False, "reason": "could not parse an estimate from the model"} + + try: + est_tokens = int(parsed.get("est_tokens") or 0) + except (TypeError, ValueError): + est_tokens = 0 + complexity = str(parsed.get("complexity") or "").strip().upper() + if complexity not in {"S", "M", "L"}: + complexity = None + rationale = str(parsed.get("rationale") or "").strip() or None + + return { + "ok": True, + "est_tokens": est_tokens, + "complexity": complexity, + "rationale": rationale, + "model": model, + } + + # --------------------------------------------------------------------------- # Plugin config (read dashboard.kanban.* defaults from config.yaml) # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_estimate.py b/tests/plugins/test_kanban_estimate.py new file mode 100644 index 00000000000..075d13cb0ba --- /dev/null +++ b/tests/plugins/test_kanban_estimate.py @@ -0,0 +1,102 @@ +"""Kanban dashboard plugin: task effort estimate. + +The estimate endpoints call the auto-routed auxiliary model and parse a +compact JSON reply (tokens + complexity + rationale). Tests monkeypatch +``call_llm`` so no network is touched. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hermes_cli import kanban_db as kb + + +def _load_plugin_router(): + repo_root = Path(__file__).resolve().parents[2] + plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + spec = importlib.util.spec_from_file_location("hermes_kanban_plugin_est_test", plugin_file) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.router + + +@pytest.fixture +def kanban_home(tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + return home + + +@pytest.fixture +def client(kanban_home): + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + return TestClient(app) + + +def _fake_resp(content: str, model: str = "aux-mini"): + msg = types.SimpleNamespace(content=content) + return types.SimpleNamespace(choices=[types.SimpleNamespace(message=msg)], model=model) + + +def test_estimate_parses_model_json(client, monkeypatch): + task_id = client.post("/api/plugins/kanban/tasks", json={"title": "big refactor"}).json()["task"]["id"] + + import agent.auxiliary_client as aux + + def fake_call_llm(**kwargs): + assert kwargs.get("task") == "kanban_estimator" + return _fake_resp('{"est_tokens": 42000, "complexity": "M", "rationale": "multi-file edit"}') + + monkeypatch.setattr(aux, "call_llm", fake_call_llm) + + body = client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json() + assert body["ok"] is True + assert body["est_tokens"] == 42000 + assert body["complexity"] == "M" + assert body["rationale"] == "multi-file edit" + assert body["model"] == "aux-mini" + + +def test_estimate_tolerates_unparseable_reply(client, monkeypatch): + task_id = client.post("/api/plugins/kanban/tasks", json={"title": "vague"}).json()["task"]["id"] + + import agent.auxiliary_client as aux + monkeypatch.setattr(aux, "call_llm", lambda **kw: _fake_resp("I cannot estimate this, sorry.")) + + assert client.post(f"/api/plugins/kanban/tasks/{task_id}/estimate").json()["ok"] is False + + +def test_estimate_unknown_task_404(client): + assert client.post("/api/plugins/kanban/tasks/t_missing/estimate").status_code == 404 + + +def test_estimate_from_text_no_task(client, monkeypatch): + """The create dialog estimates from typed title/body before a task exists.""" + import agent.auxiliary_client as aux + monkeypatch.setattr( + aux, "call_llm", + lambda **kw: _fake_resp('{"est_tokens": 8000, "complexity": "S", "rationale": "localized"}'), + ) + body = client.post( + "/api/plugins/kanban/estimate", json={"title": "tweak a label", "body": "in settings"} + ).json() + assert body["ok"] is True + assert body["est_tokens"] == 8000 + assert body["complexity"] == "S" + + +def test_estimate_from_text_requires_title(client): + assert client.post("/api/plugins/kanban/estimate", json={"title": " ", "body": "x"}).json()["ok"] is False