Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <MODEL>, 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 "
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions tests/hermes_cli/test_kanban_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
24 changes: 24 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <model_override>`` 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")
Expand Down
12 changes: 12 additions & 0 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"],
Expand Down
Loading