diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 47843187f7e3..c1bb320c3d79 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -76,6 +76,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]: "completed_at": t.completed_at, "result": t.result, "skills": list(t.skills) if t.skills else [], + "model_override": t.model_override, "max_retries": t.max_retries, "session_id": t.session_id, "workflow_template_id": t.workflow_template_id, @@ -345,6 +346,16 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu "(repeatable). The kanban lifecycle is already " "injected automatically. Example: " "--skill translation --skill github-code-review") + p_create.add_argument("--model", default=None, dest="model_override", + metavar="MODEL", + help="Per-task model override. When set, the " + "dispatcher runs this task's worker with " + "-m , overriding the assignee profile's " + "configured model for this run only " + "(e.g. --model claude-sonnet-5-0 to run a " + "simple card on a cheaper model while the " + "lane defaults to opus). Omit to use the " + "profile's model.default.") p_create.add_argument("--max-retries", type=int, default=None, metavar="N", help="Per-task override for the consecutive-failure " @@ -1363,6 +1374,7 @@ def _cmd_create(args: argparse.Namespace) -> int: goal_mode=bool(getattr(args, "goal_mode", False)), goal_max_turns=getattr(args, "goal_max_turns", None), initial_status=getattr(args, "initial_status", "running"), + model_override=getattr(args, "model_override", None), ) task = kb.get_task(conn, task_id) if getattr(args, "json", False): diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index a15b09e3dee1..3930eee8a91c 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2782,6 +2782,7 @@ def create_task( session_id: Optional[str] = None, board: Optional[str] = None, project_id: Optional[str] = None, + model_override: Optional[str] = None, ) -> str: """Create a new task and optionally link it under parent tasks. @@ -3149,8 +3150,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, goal_mode, goal_max_turns, session_id, + model_override + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, @@ -3173,6 +3175,7 @@ def create_task( 1 if goal_mode else 0, int(goal_max_turns) if goal_max_turns is not None else None, session_id, + (model_override.strip() or None) if model_override else None, ), ) for pid in parents: diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index efa14458d308..26fb36868e30 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -250,6 +250,32 @@ def test_kanban_list_json_includes_session_id(kanban_home): ) +def test_kanban_json_includes_model_override(kanban_home): + """The per-task model override must be visible on every JSON surface + (`create`/`show`/`list --json`), not just plain-text `show`, so a + scripted consumer that sets `--model` can read back the value it wrote. + Omitting `--model` leaves the field null.""" + created = json.loads( + kc.run_slash("create 'with model' --assignee alice --model m-x --json") + ) + assert created["model_override"] == "m-x" + tid = created["id"] + + shown = json.loads(kc.run_slash(f"show {tid} --json")) + assert shown["task"]["model_override"] == "m-x" + + listed = json.loads(kc.run_slash("list --json")) + assert any( + row.get("id") == tid and row.get("model_override") == "m-x" + for row in listed + ) + + omitted = json.loads( + kc.run_slash("create 'no model' --assignee alice --json") + ) + assert omitted["model_override"] is None + + def test_run_slash_usage_error_returns_message(kanban_home): # Missing required argument for create out = kc.run_slash("create") diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index b1aa1a1c5eef..561d603740fb 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -244,6 +244,30 @@ def test_create_task_no_parents_is_ready(kanban_home): assert t.workspace_kind == "scratch" +def test_create_task_persists_model_override(kanban_home): + """A per-task model override round-trips through create_task -> row. + + The dispatcher spawns the worker with ``-m `` when set, + so an override must survive the INSERT. Omitting it (or passing blank) + must leave the column NULL so the worker falls back to the profile model. + """ + with kb.connect() as conn: + tid = kb.create_task( + conn, title="cheap card", assignee="alice", + model_override="claude-sonnet-5-0", + ) + default_tid = kb.create_task(conn, title="normal card", assignee="alice") + blank_tid = kb.create_task( + conn, title="blank override", assignee="alice", model_override=" ", + ) + overridden = kb.get_task(conn, tid) + defaulted = kb.get_task(conn, default_tid) + blanked = kb.get_task(conn, blank_tid) + assert overridden.model_override == "claude-sonnet-5-0" + assert defaulted.model_override is None + assert blanked.model_override is None + + def test_create_task_with_parent_is_todo_until_parent_done(kanban_home): with kb.connect() as conn: p = kb.create_task(conn, title="parent") diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 6804fcdaff26..d7176cb54b2b 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -985,6 +985,7 @@ def _handle_create(args: dict, **kw) -> str: created_by=os.environ.get("HERMES_PROFILE") or "worker", session_id=session_id, board=board, + model_override=args.get("model") or None, ) new_task = kb.get_task(conn, new_tid) subscribed = _maybe_auto_subscribe(conn, new_tid) @@ -1596,6 +1597,17 @@ def _board_schema_prop() -> dict[str, str]: "true. Defaults to the goal-engine default (20)." ), }, + "model": { + "type": "string", + "description": ( + "Per-task model override. When set, the dispatcher runs " + "this task's worker with this model, overriding the " + "assignee profile's configured model for this run only " + "(e.g. 'claude-sonnet-5-0' to run a simple card on a " + "cheaper model while the lane defaults to a stronger " + "one). Omit to use the profile's default model." + ), + }, "board": _board_schema_prop(), }, "required": ["title", "assignee"],