From 82b8b7c6b9ec0acf671909bcb76873ab9233805b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:03:30 -0700 Subject: [PATCH] =?UTF-8?q?feat(kanban):=20per-task=20model=20dropdown=20?= =?UTF-8?q?=E2=80=94=20set/override=20worker=20model+provider=20from=20the?= =?UTF-8?q?=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing write path for the per-task model_override column (which was previously only settable via manual SQL) and pairs it with a provider_override so cross-provider switches resolve correctly: - kanban_db: provider_override column (+migration), set_model_override() with model_override_set event, create_task(model_override=, provider_override=), dispatcher spawns worker with -m [--provider ] - dashboard: Model row in the task drawer — dropdown fed by a new /model-options endpoint (build_models_payload substrate, provider-grouped, free-text fallback), PATCH + bulk model override support - CLI: kanban create --model/--provider, new kanban set-model subcommand, show prints the provider - agent tools: kanban_create accepts model/provider; show/list expose provider_override Rate-limit recovery flow: override is settable on running tasks and takes effect on the next dispatch, without touching the worker profile's config. --- hermes_cli/kanban.py | 59 +++- hermes_cli/kanban_db.py | 95 ++++++- plugins/kanban/dashboard/dist/index.js | 168 +++++++++++ plugins/kanban/dashboard/plugin_api.py | 88 ++++++ tests/plugins/test_kanban_model_override.py | 292 ++++++++++++++++++++ tools/kanban_tools.py | 28 ++ 6 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 tests/plugins/test_kanban_model_override.py diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 0937fc40f2e7..400a462ef7d3 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -77,6 +77,8 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]: "result": t.result, "skills": list(t.skills) if t.skills else [], "max_retries": t.max_retries, + "model_override": t.model_override, + "provider_override": t.provider_override, "session_id": t.session_id, "workflow_template_id": t.workflow_template_id, "current_step_key": t.current_step_key, @@ -347,6 +349,16 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "two retries. Omit to use the dispatcher's " "kanban.failure_limit config " f"(default {kb.DEFAULT_FAILURE_LIMIT}).") + p_create.add_argument("--model", default=None, dest="model_override", + help="Pin the worker to this model (passed as " + "-m ) without changing the profile's " + "configured model. Combine with --provider " + "when the model belongs to a different " + "backend than the profile's default.") + p_create.add_argument("--provider", default=None, dest="provider_override", + help="Provider the --model belongs to (passed as " + "--provider to the worker). Requires " + "--model.") p_create.add_argument("--goal", action="store_true", dest="goal_mode", help="Run the worker in a goal loop: after each " "turn a judge checks the response against the " @@ -445,6 +457,23 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu p_assign.add_argument("task_id") p_assign.add_argument("profile", help="Profile name (or 'none' to unassign)") + # --- set-model (per-task model/provider override) --- + p_set_model = sub.add_parser( + "set-model", + help="Set or clear a task's model/provider override " + "(takes effect on the next dispatch)", + ) + p_set_model.add_argument("task_id") + p_set_model.add_argument( + "model", nargs="?", default=None, + help="Model to pin the worker to (or 'none' to clear the override)", + ) + p_set_model.add_argument( + "--provider", default=None, + help="Provider the model belongs to (worker is spawned with " + "--provider ). Cleared together with the model.", + ) + # --- reclaim / reassign (recovery) --- p_reclaim = sub.add_parser( "reclaim", @@ -989,6 +1018,7 @@ def kanban_command(args: argparse.Namespace) -> int: "ls": _cmd_list, "show": _cmd_show, "assign": _cmd_assign, + "set-model": _cmd_set_model, "reclaim": _cmd_reclaim, "reassign": _cmd_reassign, "diagnostics": _cmd_diagnostics, @@ -1393,6 +1423,8 @@ def _cmd_create(args: argparse.Namespace) -> int: max_runtime_seconds=max_runtime, skills=getattr(args, "skills", None) or None, max_retries=max_retries, + model_override=getattr(args, "model_override", None), + provider_override=getattr(args, "provider_override", None), goal_mode=bool(getattr(args, "goal_mode", False)), goal_max_turns=getattr(args, "goal_max_turns", None), initial_status=getattr(args, "initial_status", "running"), @@ -1567,7 +1599,8 @@ def _cmd_show(args: argparse.Namespace) -> int: if task.skills: print(f" skills: {', '.join(task.skills)}") if task.model_override: - print(f" model: {task.model_override}") + _prov = f" (provider: {task.provider_override})" if task.provider_override else "" + print(f" model: {task.model_override}{_prov}") # Effective retry threshold. Show the per-task override if set, # otherwise the dispatcher's resolved value from config (or the # default if config doesn't set it either). Helps operators see @@ -1675,6 +1708,30 @@ def _cmd_assign(args: argparse.Namespace) -> int: return 0 +def _cmd_set_model(args: argparse.Namespace) -> int: + model = args.model + if model is not None and model.lower() in {"none", "-", "null", ""}: + model = None + provider = getattr(args, "provider", None) + try: + with kb.connect_closing() as conn: + ok = kb.set_model_override(conn, args.task_id, model, provider=provider) + except (ValueError, RuntimeError) as exc: + print(f"kanban: {exc}", file=sys.stderr) + return 2 + if not ok: + print(f"no such task: {args.task_id}", file=sys.stderr) + return 1 + if model: + label = f"{provider}:{model}" if provider else model + print(f"Set model override on {args.task_id}: {label} " + "(applies on next dispatch)") + else: + print(f"Cleared model override on {args.task_id} " + "(worker uses its profile default)") + return 0 + + def _cmd_reclaim(args: argparse.Namespace) -> int: with kb.connect_closing() as conn: ok = kb.reclaim_task( diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a99cda572862..c415b7ec93af 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -881,6 +881,13 @@ class Task: # the defaults; empty list = explicitly no extra skills. skills: Optional[list] = None model_override: Optional[str] = None + # Provider that ``model_override`` belongs to. When set, the dispatcher + # passes ``--provider `` alongside ``-m `` so the worker + # resolves the model against the right backend instead of the profile's + # configured provider. NULL = worker profile's provider resolves the + # model (pre-existing behaviour). Solves the "model from provider A, + # profile configured for provider B" mismatch class. + provider_override: Optional[str] = None # Per-task override for the consecutive-failure circuit breaker. # The value is the failure count at which the breaker trips — e.g. # ``max_retries=1`` blocks on the first failure (zero retries), @@ -979,6 +986,11 @@ def from_row(cls, row: sqlite3.Row) -> "Task": ), skills=skills_value, model_override=row["model_override"] if "model_override" in keys and row["model_override"] else None, + provider_override=( + row["provider_override"] + if "provider_override" in keys and row["provider_override"] + else None + ), max_retries=( row["max_retries"] if "max_retries" in keys else None ), @@ -1142,6 +1154,11 @@ class Event: -- to the worker, overriding the profile's default model. NULL = use -- the profile default. model_override TEXT, + -- Provider the model override belongs to. When set (alongside + -- model_override), the dispatcher passes --provider so the + -- worker resolves the model against the right backend instead of the + -- profile's configured provider. NULL = profile provider. + provider_override TEXT, -- Per-task override for the consecutive-failure circuit breaker. -- The value is the failure count at which the breaker trips — e.g. -- ``max_retries=1`` blocks on the first failure. NULL (the common @@ -2282,6 +2299,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: if "model_override" not in cols: conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT") + if "provider_override" not in cols: + # Provider the model_override belongs to. NULL = worker profile's + # provider resolves the model (the behaviour existing rows had). + _add_column_if_missing( + conn, "tasks", "provider_override", "provider_override TEXT" + ) + if "goal_mode" not in cols: # Ralph-style goal loop toggle for the dispatched worker. 0 (the # default) = classic single-shot worker, preserving the behaviour @@ -2736,6 +2760,8 @@ def create_task( max_runtime_seconds: Optional[int] = None, skills: Optional[Iterable[str]] = None, max_retries: Optional[int] = None, + model_override: Optional[str] = None, + provider_override: Optional[str] = None, goal_mode: bool = False, goal_max_turns: Optional[int] = None, initial_status: str = "running", @@ -2765,7 +2791,16 @@ def create_task( each name to ``hermes --skills ...``. Use this to pin a task to a specialist skill (e.g. ``skills=["translation"]`` so the worker loads the translation skill regardless of the profile's default config). + + ``model_override`` / ``provider_override`` pin the worker to a specific + model (and optionally its provider) without touching the profile's + config — passed to the worker as ``-m [--provider ]``. + ``provider_override`` requires ``model_override``. """ + model_override = (model_override or "").strip() or None + provider_override = (provider_override or "").strip() or None + if provider_override and not model_override: + raise ValueError("provider_override requires a model_override") assignee = _canonical_assignee(assignee) if not title or not title.strip(): raise ValueError("title is required") @@ -2970,8 +3005,9 @@ def create_task( created_by, created_at, workspace_kind, workspace_path, branch_name, project_id, tenant, idempotency_key, max_runtime_seconds, - skills, max_retries, goal_mode, goal_max_turns, session_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + skills, max_retries, model_override, provider_override, + goal_mode, goal_max_turns, session_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -2991,6 +3027,8 @@ def create_task( int(max_runtime_seconds) if max_runtime_seconds is not None else None, json.dumps(skills_list) if skills_list is not None else None, int(max_retries) if max_retries is not None else None, + model_override, + provider_override, 1 if goal_mode else 0, int(goal_max_turns) if goal_max_turns is not None else None, session_id, @@ -3013,6 +3051,8 @@ def create_task( "branch_name": branch_name, "skills": list(skills_list) if skills_list else None, "goal_mode": bool(goal_mode) or None, + "model_override": model_override, + "provider_override": provider_override, }, ) return task_id @@ -3141,6 +3181,51 @@ def assign_task(conn: sqlite3.Connection, task_id: str, profile: Optional[str]) return True +def set_model_override( + conn: sqlite3.Connection, + task_id: str, + model: Optional[str], + provider: Optional[str] = None, +) -> bool: + """Set (or clear) the per-task model/provider override. + + ``model=None`` (or empty) clears BOTH overrides — the worker falls back + to its profile's configured model. ``provider`` without ``model`` is + rejected: a bare provider switch has no defined meaning for the worker + spawn (``--provider`` alone would re-resolve the profile's model name + against a different backend, which is exactly the mismatch class this + feature exists to kill). + + Allowed on any non-archived task, including ``running`` ones — the + override only takes effect on the NEXT dispatch, so setting it on a + running task that's about to be reclaimed/retried is the primary + rate-limit-recovery flow. Returns True on success. + """ + model = (model or "").strip() or None + provider = (provider or "").strip() or None + if provider and not model: + raise ValueError("provider_override requires a model_override") + if not model: + provider = None + with write_txn(conn): + row = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + if not row: + return False + if row["status"] == "archived": + raise RuntimeError(f"cannot set model override on archived task {task_id}") + conn.execute( + "UPDATE tasks SET model_override = ?, provider_override = ? WHERE id = ?", + (model, provider, task_id), + ) + _append_event( + conn, task_id, "model_override_set", + {"model": model, "provider": provider}, + ) + return True + + # --------------------------------------------------------------------------- # Links # --------------------------------------------------------------------------- @@ -8642,6 +8727,12 @@ def _default_spawn( cmd.extend(["--skills", sk]) if task.model_override: cmd.extend(["-m", task.model_override]) + # Pin the provider too when the override names one, so the worker + # resolves the model against the intended backend instead of the + # profile's configured provider (mixing model X with provider Y is + # the classic mis-set that stalls a board). + if task.provider_override: + cmd.extend(["--provider", task.provider_override]) worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME")) if worker_toolsets: cmd.extend(["--toolsets", ",".join(worker_toolsets)]) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 1ae794831447..959e4e6d6da2 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -3579,6 +3579,7 @@ h(MetaRow, { label: tx(i18n, "status", "Status"), value: t.status }), h(AssigneeEditor, { task: t, onPatch: props.onPatch }), h(PriorityEditor, { task: t, onPatch: props.onPatch }), + h(ModelEditor, { task: t, onPatch: props.onPatch }), t.tenant ? h(MetaRow, { label: tx(i18n, "tenant", "Tenant"), value: t.tenant }) : null, h(MetaRow, { label: tx(i18n, "workspace", "Workspace"), @@ -3985,6 +3986,173 @@ ); } + // Module-level cache for the model-options catalog so opening several + // task drawers doesn't refetch. { providers: [{slug,label,models}] } + let _modelCatalogCache = null; + let _modelCatalogPromise = null; + function fetchModelCatalog() { + if (_modelCatalogCache) return Promise.resolve(_modelCatalogCache); + if (_modelCatalogPromise) return _modelCatalogPromise; + _modelCatalogPromise = SDK.fetchJSON(`${API}/model-options`) + .then(function (data) { + _modelCatalogCache = data && Array.isArray(data.providers) ? data : { providers: [] }; + return _modelCatalogCache; + }) + .catch(function () { + _modelCatalogPromise = null; // allow retry on next open + return { providers: [] }; + }); + return _modelCatalogPromise; + } + + // Per-task model override dropdown. Value encoding: "" = profile + // default; "\u0000" = provider+model pair (the separator + // can't appear in either half). A catalog fetch failure degrades to a + // free-text input so the override is still settable. + function ModelEditor(props) { + const { t } = useI18n(); + const task = props.task; + const [editing, setEditing] = useState(false); + const [catalog, setCatalog] = useState(_modelCatalogCache); + const [busy, setBusy] = useState(false); + const [freeText, setFreeText] = useState(""); + + useEffect(function () { + if (!editing || catalog) return; + let alive = true; + fetchModelCatalog().then(function (data) { + if (alive) setCatalog(data); + }); + return function () { alive = false; }; + }, [editing, catalog]); + + const current = task.model_override + ? (task.provider_override + ? `${task.provider_override}: ${task.model_override}` + : task.model_override) + : tx(t, "modelProfileDefault", "profile default"); + + if (!editing) { + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")), + h("span", { + className: cn( + "hermes-kanban-meta-value hermes-kanban-editable", + !task.model_override ? "text-muted-foreground" : "", + ), + onClick: function () { setEditing(true); }, + title: tx(t, "clickToEditModel", + "Click to override the model for this task's next run"), + }, current), + ); + } + + const apply = function (patch) { + setBusy(true); + props.onPatch(patch).then(function () { + setEditing(false); + }).catch(function () { + // onPatch surfaces its own toast; just re-enable the control. + }).then(function () { setBusy(false); }); + }; + + const onPick = function (value) { + if (value === "") { + apply({ clear_model_override: true }); + return; + } + const sep = value.indexOf("\u0000"); + if (sep === -1) { + apply({ model_override: value }); + return; + } + apply({ + provider_override: value.slice(0, sep), + model_override: value.slice(sep + 1), + }); + }; + + const providers = (catalog && catalog.providers) || []; + const loading = editing && !catalog; + const currentValue = task.model_override + ? (task.provider_override + ? `${task.provider_override}\u0000${task.model_override}` + : task.model_override) + : ""; + + // Free-text fallback when the catalog is empty (inventory unavailable + // or zero authenticated providers). + if (!loading && providers.length === 0) { + const saveFree = function () { + const v = freeText.trim(); + if (!v) { apply({ clear_model_override: true }); return; } + apply({ model_override: v }); + }; + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")), + h(Input, { + value: freeText, autoFocus: true, disabled: busy, + placeholder: tx(t, "modelFreeTextPlaceholder", "model name (empty = profile default)"), + onChange: function (e) { setFreeText(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter") { e.preventDefault(); saveFree(); } + if (e.key === "Escape") setEditing(false); + }, + className: "h-7 text-xs flex-1", + style: { textTransform: "none" }, + autoCapitalize: "none", autoCorrect: "off", spellCheck: false, + }), + ); + } + + // Ensure the current override is selectable even when it's not in the + // catalog (e.g. set from the CLI with a model the catalog doesn't list). + let currentInCatalog = currentValue === ""; + for (let i = 0; i < providers.length && !currentInCatalog; i++) { + const p = providers[i]; + for (let j = 0; j < p.models.length; j++) { + const enc = `${p.slug}\u0000${p.models[j]}`; + if (enc === currentValue || p.models[j] === currentValue) { + currentInCatalog = true; + break; + } + } + } + + return h("div", { className: "hermes-kanban-meta-row" }, + h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")), + loading + ? h("span", { className: "hermes-kanban-meta-value text-muted-foreground" }, + tx(t, "modelLoading", "loading models…")) + : h("select", { + className: "hermes-kanban-recovery-select", + value: currentValue, + disabled: busy, + autoFocus: true, + onChange: function (e) { onPick(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Escape") setEditing(false); + }, + }, + h("option", { value: "" }, + tx(t, "modelProfileDefaultOption", "(profile default)")), + !currentInCatalog + ? h("option", { value: currentValue }, current) + : null, + providers.map(function (p) { + return h("optgroup", { key: p.slug, label: p.label || p.slug }, + p.models.map(function (m) { + return h("option", { + key: `${p.slug}\u0000${m}`, + value: `${p.slug}\u0000${m}`, + }, m); + }), + ); + }), + ), + ); + } + function BodyEditor(props) { const { t } = useI18n(); const [editing, setEditing] = useState(false); diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index aaf3a981b089..fa3edd135d72 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -608,6 +608,8 @@ class CreateTaskBody(BaseModel): skills: Optional[list[str]] = None goal_mode: bool = False goal_max_turns: Optional[int] = None + model_override: Optional[str] = None + provider_override: Optional[str] = None @router.post("/tasks") @@ -632,6 +634,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)): skills=payload.skills, goal_mode=payload.goal_mode, goal_max_turns=payload.goal_max_turns, + model_override=payload.model_override, + provider_override=payload.provider_override, ) task = kanban_db.get_task(conn, task_id) body: dict[str, Any] = {"task": _task_dict(task) if task else None} @@ -815,6 +819,13 @@ class UpdateTaskBody(BaseModel): # complete --summary ... --metadata ...``. summary: Optional[str] = None metadata: Optional[dict] = None + # Per-task model/provider override (the board's model dropdown). + # ``model_override=""`` clears both. ``clear_model_override=True`` is + # the explicit clear signal — needed because Optional[str]=None means + # "field not sent" in a PATCH, not "set to NULL". + model_override: Optional[str] = None + provider_override: Optional[str] = None + clear_model_override: bool = False @router.patch("/tasks/{task_id}") @@ -894,6 +905,22 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu detail=f"status transition to {s!r} not valid from current state", ) + # --- model/provider override --------------------------------------- + if payload.clear_model_override or payload.model_override is not None: + new_model = ( + None if payload.clear_model_override + else (payload.model_override or "").strip() or None + ) + try: + ok = kanban_db.set_model_override( + conn, task_id, new_model, + provider=payload.provider_override, + ) + except (ValueError, RuntimeError) as e: + raise HTTPException(status_code=400, detail=str(e)) + if not ok: + raise HTTPException(status_code=404, detail="task not found") + # --- priority ----------------------------------------------------- if payload.priority is not None: with kanban_db.write_txn(conn): @@ -1155,6 +1182,10 @@ class BulkTaskBody(BaseModel): summary: Optional[str] = None metadata: Optional[dict] = None reclaim_first: bool = False + # Bulk model/provider override — same semantics as UpdateTaskBody. + model_override: Optional[str] = None + provider_override: Optional[str] = None + clear_model_override: bool = False @router.post("/tasks/bulk") @@ -1246,6 +1277,20 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): (tid, json.dumps({"priority": int(payload.priority)}), int(time.time())), ) + if payload.clear_model_override or payload.model_override is not None: + new_model = ( + None if payload.clear_model_override + else (payload.model_override or "").strip() or None + ) + try: + ok = kanban_db.set_model_override( + conn, tid, new_model, + provider=payload.provider_override, + ) + if not ok: + entry.update(ok=False, error="model override refused") + except (ValueError, RuntimeError) as e: + entry.update(ok=False, error=str(e)) except Exception as e: # defensive — one bad id shouldn't kill the batch entry.update(ok=False, error=str(e)) results.append(entry) @@ -1971,6 +2016,49 @@ def dispatch( conn.close() +# --------------------------------------------------------------------------- +# Model options (the board's per-task model-override dropdown) +# --------------------------------------------------------------------------- + +@router.get("/model-options") +def model_options(): + """Authenticated providers + curated model lists for the task drawer's + model-override dropdown. + + Thin wrapper around ``hermes_cli.inventory.build_models_payload`` — the + same substrate the dashboard Models page and the TUI picker use, so the + dropdown can never offer a model/provider pair the rest of Hermes + wouldn't accept. Deliberately skips pricing/capability enrichment and + custom-provider probes: the dropdown needs names fast, not $/Mtok + columns (a slow/offline local endpoint must not hang the drawer). + """ + try: + from hermes_cli.inventory import build_models_payload, load_picker_context + + payload = build_models_payload( + load_picker_context(), + explicit_only=True, + canonical_order=True, + probe_custom_providers=False, + ) + return { + "providers": [ + { + "slug": row.get("slug", ""), + "label": row.get("label") or row.get("slug", ""), + "models": list(row.get("models") or []), + } + for row in payload.get("providers", []) + if row.get("models") + ], + } + except Exception: + log.exception("kanban model-options failed") + # Degrade to an empty catalog — the UI falls back to a free-text + # input so the feature still works without the inventory module. + return {"providers": []} + + # --------------------------------------------------------------------------- # Boards CRUD (multi-project support) # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_model_override.py b/tests/plugins/test_kanban_model_override.py new file mode 100644 index 000000000000..be74710273a7 --- /dev/null +++ b/tests/plugins/test_kanban_model_override.py @@ -0,0 +1,292 @@ +"""Per-task model/provider override — DB layer, worker spawn, dashboard API. + +Covers the model-dropdown feature: kanban_db.set_model_override(), +create_task(model_override=..., provider_override=...), the dispatcher +passing ``-m --provider `` to the worker, and the dashboard +PATCH/bulk/model-options surfaces. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from hermes_cli import kanban_db as kb + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@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 conn(kanban_home): + c = kb.connect() + yield c + c.close() + + +def _load_plugin_router(): + repo_root = Path(__file__).resolve().parents[2] + plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py" + assert plugin_file.exists(), f"plugin file missing: {plugin_file}" + spec = importlib.util.spec_from_file_location( + "hermes_dashboard_plugin_kanban_model_override_test", plugin_file, + ) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod.router + + +@pytest.fixture +def client(kanban_home): + app = FastAPI() + app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban") + return TestClient(app) + + +# --------------------------------------------------------------------------- +# DB layer — set_model_override +# --------------------------------------------------------------------------- + + +def test_set_and_clear_model_override(conn): + tid = kb.create_task(conn, title="t", assignee="worker") + assert kb.set_model_override(conn, tid, "gpt-5.6-sol", provider="openai") + t = kb.get_task(conn, tid) + assert t.model_override == "gpt-5.6-sol" + assert t.provider_override == "openai" + + # Clearing the model clears the provider too. + assert kb.set_model_override(conn, tid, None) + t = kb.get_task(conn, tid) + assert t.model_override is None + assert t.provider_override is None + + +def test_set_model_override_events(conn): + tid = kb.create_task(conn, title="t", assignee="worker") + kb.set_model_override(conn, tid, "sonnet-x", provider="anthropic") + events = kb.list_events(conn, tid) + kinds = [e.kind for e in events] + assert "model_override_set" in kinds + ev = next(e for e in events if e.kind == "model_override_set") + assert ev.payload["model"] == "sonnet-x" + assert ev.payload["provider"] == "anthropic" + + +def test_provider_without_model_rejected(conn): + tid = kb.create_task(conn, title="t", assignee="worker") + with pytest.raises(ValueError): + kb.set_model_override(conn, tid, None, provider="openrouter") + with pytest.raises(ValueError): + kb.create_task( + conn, title="t2", assignee="worker", provider_override="openrouter", + ) + + +def test_set_model_override_unknown_task(conn): + assert kb.set_model_override(conn, "t_nope", "some-model") is False + + +def test_set_model_override_archived_task_rejected(conn): + tid = kb.create_task(conn, title="t", assignee="worker") + assert kb.archive_task(conn, tid) + with pytest.raises(RuntimeError): + kb.set_model_override(conn, tid, "some-model") + + +def test_set_model_override_allowed_on_running(conn): + """The rate-limit recovery flow: override a running task so the NEXT + dispatch (after reclaim/retry) picks up the new model.""" + tid = kb.create_task(conn, title="t", assignee="worker") + claimed = kb.claim_task(conn, tid, claimer="worker") + assert claimed is not None + assert kb.set_model_override(conn, tid, "fallback-model", provider="nous") + t = kb.get_task(conn, tid) + assert t.status == "running" + assert t.model_override == "fallback-model" + assert t.provider_override == "nous" + + +def test_create_task_with_model_and_provider(conn): + tid = kb.create_task( + conn, title="t", assignee="worker", + model_override="qwen-max", provider_override="openrouter", + ) + t = kb.get_task(conn, tid) + assert t.model_override == "qwen-max" + assert t.provider_override == "openrouter" + # Creation event carries the override for auditability. + ev = next(e for e in kb.list_events(conn, tid) if e.kind == "created") + assert ev.payload["model_override"] == "qwen-max" + assert ev.payload["provider_override"] == "openrouter" + + +def test_migration_adds_provider_override_column(conn): + cols = {row["name"] for row in conn.execute("PRAGMA table_info(tasks)")} + assert "model_override" in cols + assert "provider_override" in cols + + +# --------------------------------------------------------------------------- +# Worker spawn — argv carries -m and --provider +# --------------------------------------------------------------------------- + + +def _spawn_and_capture(monkeypatch, tmp_path, task): + monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"]) + captured = {} + + class FakeProc: + pid = 4245 + + def fake_popen(cmd, *args, **kwargs): + captured["cmd"] = list(cmd) + return FakeProc() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + workspace = tmp_path / "ws" + workspace.mkdir(exist_ok=True) + kb._default_spawn(task, str(workspace)) + return captured["cmd"] + + +def test_spawn_passes_model_and_provider(monkeypatch, tmp_path, conn): + tid = kb.create_task( + conn, title="t", assignee="elias", + model_override="glm-5", provider_override="openrouter", + ) + task = kb.get_task(conn, tid) + cmd = _spawn_and_capture(monkeypatch, tmp_path, task) + i = cmd.index("-m") + assert cmd[i + 1] == "glm-5" + j = cmd.index("--provider") + assert j == i + 2 + assert cmd[j + 1] == "openrouter" + + +def test_spawn_model_only_omits_provider_flag(monkeypatch, tmp_path, conn): + tid = kb.create_task( + conn, title="t", assignee="elias", model_override="glm-5", + ) + task = kb.get_task(conn, tid) + cmd = _spawn_and_capture(monkeypatch, tmp_path, task) + assert "-m" in cmd + assert "--provider" not in cmd + + +def test_spawn_no_override_omits_both_flags(monkeypatch, tmp_path, conn): + tid = kb.create_task(conn, title="t", assignee="elias") + task = kb.get_task(conn, tid) + cmd = _spawn_and_capture(monkeypatch, tmp_path, task) + assert "-m" not in cmd + assert "--provider" not in cmd + + +# --------------------------------------------------------------------------- +# Dashboard API — PATCH / bulk / create / model-options +# --------------------------------------------------------------------------- + + +def _create(client, **kwargs): + body = {"title": "task", "assignee": "worker"} + body.update(kwargs) + r = client.post("/api/plugins/kanban/tasks", json=body) + assert r.status_code == 200, r.text + return r.json()["task"] + + +def test_patch_sets_model_override(client): + task = _create(client) + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"model_override": "gpt-5.6-sol", "provider_override": "openai"}, + ) + assert r.status_code == 200, r.text + updated = r.json()["task"] + assert updated["model_override"] == "gpt-5.6-sol" + assert updated["provider_override"] == "openai" + + +def test_patch_clears_model_override(client): + task = _create( + client, model_override="gpt-5.6-sol", provider_override="openai", + ) + assert task["model_override"] == "gpt-5.6-sol" + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"clear_model_override": True}, + ) + assert r.status_code == 200, r.text + updated = r.json()["task"] + assert updated["model_override"] is None + assert updated["provider_override"] is None + + +def test_patch_provider_without_model_is_400(client): + task = _create(client) + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"model_override": "", "provider_override": "openai"}, + ) + assert r.status_code == 400 + + +def test_create_task_with_override_via_api(client): + task = _create( + client, model_override="qwen-max", provider_override="openrouter", + ) + assert task["model_override"] == "qwen-max" + assert task["provider_override"] == "openrouter" + + +def test_bulk_model_override(client): + t1 = _create(client) + t2 = _create(client) + r = client.post( + "/api/plugins/kanban/tasks/bulk", + json={ + "ids": [t1["id"], t2["id"]], + "model_override": "fallback-model", + "provider_override": "nous", + }, + ) + assert r.status_code == 200, r.text + assert all(entry["ok"] for entry in r.json()["results"]) + for tid in (t1["id"], t2["id"]): + got = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"] + assert got["model_override"] == "fallback-model" + assert got["provider_override"] == "nous" + + +def test_model_options_endpoint_shape(client, monkeypatch): + """The endpoint returns {providers: [{slug,label,models}]} and degrades + to an empty catalog when the inventory substrate raises.""" + r = client.get("/api/plugins/kanban/model-options") + assert r.status_code == 200 + data = r.json() + assert "providers" in data + assert isinstance(data["providers"], list) + for row in data["providers"]: + assert "slug" in row and "label" in row and "models" in row + assert isinstance(row["models"], list) + assert len(row["models"]) >= 1 # empty-model rows are filtered out diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 5535c01e2e68..7e6c9ef7a359 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -353,6 +353,7 @@ def _task_summary_dict(kb, conn, task) -> dict[str, Any]: "completed_at": task.completed_at, "current_run_id": task.current_run_id, "model_override": task.model_override, + "provider_override": task.provider_override, "parents": parents, "children": children, "parent_count": len(parents), @@ -398,6 +399,7 @@ def _task_dict(t): "result": t.result, "current_run_id": t.current_run_id, "model_override": t.model_override, + "provider_override": t.provider_override, } def _run_dict(r): @@ -1116,6 +1118,10 @@ def _handle_create(args: dict, **kw) -> str: if goal_bool_error: return tool_error(goal_bool_error) goal_max_turns = args.get("goal_max_turns") + model_override = args.get("model") + provider_override = args.get("provider") + if provider_override and not model_override: + return tool_error("'provider' requires 'model' to be set as well") if isinstance(parents, str): parents = [parents] if not isinstance(parents, (list, tuple)): @@ -1157,6 +1163,8 @@ def _handle_create(args: dict, **kw) -> str: if max_runtime_seconds is not None else None ), skills=skills, + model_override=model_override, + provider_override=provider_override, goal_mode=goal_mode, goal_max_turns=( int(goal_max_turns) if goal_max_turns is not None else None @@ -1871,6 +1879,26 @@ def _board_schema_prop() -> dict[str, str]: "true. Defaults to the goal-engine default (20)." ), }, + "model": { + "type": "string", + "description": ( + "Pin the dispatched worker to this model instead of " + "the assignee profile's configured model. Use the " + "exact model name the target provider expects. Omit " + "to use the profile default." + ), + }, + "provider": { + "type": "string", + "description": ( + "Provider the 'model' belongs to (e.g. 'openrouter', " + "'anthropic', 'nous'). Set this whenever the model " + "is not from the assignee profile's configured " + "provider — a model name alone is resolved against " + "the profile's provider and will fail if it belongs " + "to a different one. Requires 'model'." + ), + }, "board": _board_schema_prop(), }, "required": ["title", "assignee"],