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
59 changes: 58 additions & 1 deletion hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <model>) 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 <name> 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 "
Expand Down Expand Up @@ -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 <name>). Cleared together with the model.",
)

# --- reclaim / reassign (recovery) ---
p_reclaim = sub.add_parser(
"reclaim",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
95 changes: 93 additions & 2 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`` alongside ``-m <model>`` 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),
Expand Down Expand Up @@ -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
),
Expand Down Expand Up @@ -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 <name> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 <model> [--provider <name>]``.
``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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)])
Expand Down
Loading
Loading