diff --git a/contributors/emails/alex@thealexferrari.com b/contributors/emails/alex@thealexferrari.com new file mode 100644 index 000000000000..0ae3f735a869 --- /dev/null +++ b/contributors/emails/alex@thealexferrari.com @@ -0,0 +1,2 @@ +alexferrari88 +# PR #79405 (kanban: expose per-task reasoning effort) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 79519831bce7..c7b7d703c70a 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -78,6 +78,7 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]: "max_retries": t.max_retries, "model_override": t.model_override, "provider_override": t.provider_override, + "reasoning_effort": t.reasoning_effort, "session_id": t.session_id, "workflow_template_id": t.workflow_template_id, "current_step_key": t.current_step_key, @@ -380,6 +381,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help="Provider the --model belongs to (passed as " "--provider to the worker). Requires " "--model.") + p_create.add_argument( + "--reasoning", + default=None, + dest="reasoning_effort", + metavar="LEVEL", + help="Pin the worker's reasoning effort for this task (none, minimal, " + "low, medium, high, xhigh, max, or ultra). Omit to inherit the " + "assignee profile default.", + ) 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 " @@ -1583,6 +1593,7 @@ def _cmd_create(args: argparse.Namespace) -> int: max_retries=max_retries, model_override=getattr(args, "model_override", None), provider_override=getattr(args, "provider_override", None), + reasoning_effort=getattr(args, "reasoning_effort", 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"), @@ -1762,6 +1773,7 @@ def _cmd_show(args: argparse.Namespace) -> int: if task.model_override: _prov = f" (provider: {task.provider_override})" if task.provider_override else "" print(f" model: {task.model_override}{_prov}") + print(f" reasoning: {task.reasoning_effort or 'inherit'}") # 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 diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 658bf2d8c8c1..f78feab96785 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -40,6 +40,61 @@ def kanban_home(tmp_path, monkeypatch): +def test_kanban_create_forwards_reasoning_effort(kanban_home): + created = json.loads( + kc.run_slash("create 'deep task' --assignee alice --reasoning high --json") + ) + + assert created["reasoning_effort"] == "high" + with kb.connect() as conn: + task = kb.get_task(conn, created["id"]) + assert task is not None + assert task.reasoning_effort == "high" + + +def test_kanban_create_without_reasoning_inherits(kanban_home): + created = json.loads( + kc.run_slash("create 'inheriting task' --assignee alice --json") + ) + + assert created["reasoning_effort"] is None + + +def test_kanban_create_invalid_reasoning_errors(kanban_home): + output = kc.run_slash( + "create 'invalid task' --assignee alice --reasoning extremely-hard" + ) + + assert "reasoning_effort must be one of" in output + + +def test_kanban_show_json_surfaces_reasoning_effort(kanban_home): + with kb.connect() as conn: + task_id = kb.create_task( + conn, + title="non-reasoning task", + assignee="alice", + reasoning_effort="none", + ) + + shown = json.loads(kc.run_slash(f"show {task_id} --json")) + + assert shown["task"]["reasoning_effort"] == "none" + + +def test_kanban_show_text_surfaces_inherited_reasoning(kanban_home): + with kb.connect() as conn: + task_id = kb.create_task( + conn, + title="inheriting task", + assignee="alice", + ) + + shown = kc.run_slash(f"show {task_id}") + + assert "reasoning: inherit" in shown + + def test_kanban_list_json_includes_session_id(kanban_home): """JSON output exposes `session_id` so external clients (Scarf, web dashboards) don't need a side query to filter by chat session.""" diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index b9d2a52e3538..d8bc8d0d62d2 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -416,6 +416,105 @@ def test_create_happy_path(worker_env): conn.close() +def test_create_forwards_reasoning_effort(worker_env): + from tools import kanban_tools as kt + + created = json.loads( + kt._handle_create({ + "title": "deep child task", + "assignee": "peer", + "reasoning_effort": "high", + }) + ) + assert created["reasoning_effort"] == "high" + + from hermes_cli import kanban_db as kb + + with kb.connect() as conn: + task = kb.get_task(conn, created["task_id"]) + + assert task is not None + assert task.reasoning_effort == "high" + + +def test_create_preserves_none_reasoning_effort(worker_env): + from tools import kanban_tools as kt + + created = json.loads( + kt._handle_create({ + "title": "non-reasoning child task", + "assignee": "peer", + "reasoning_effort": "none", + }) + ) + + from hermes_cli import kanban_db as kb + + with kb.connect() as conn: + task = kb.get_task(conn, created["task_id"]) + + assert task is not None + assert task.reasoning_effort == "none" + + +def test_create_omitted_reasoning_effort_inherits(worker_env): + from tools import kanban_tools as kt + + created = json.loads( + kt._handle_create({ + "title": "inheriting child task", + "assignee": "peer", + }) + ) + + assert created["reasoning_effort"] is None + + +def test_create_rejects_invalid_reasoning_effort(worker_env): + from tools import kanban_tools as kt + + result = json.loads( + kt._handle_create({ + "title": "invalid child task", + "assignee": "peer", + "reasoning_effort": "extremely-hard", + }) + ) + + assert "reasoning_effort must be one of" in result["error"] + + +def test_show_surfaces_reasoning_effort(worker_env): + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + with kb.connect() as conn: + task_id = kb.create_task( + conn, + title="non-reasoning task", + assignee="peer", + reasoning_effort="none", + ) + + shown = json.loads(kt._handle_show({"task_id": task_id})) + + assert shown["task"]["reasoning_effort"] == "none" + + +def test_create_schema_exposes_optional_reasoning_effort(): + from hermes_constants import VALID_REASONING_EFFORTS + from tools import kanban_tools as kt + + properties = kt.KANBAN_CREATE_SCHEMA["parameters"]["properties"] + + assert properties["reasoning_effort"]["enum"] == [ + "", + "none", + *VALID_REASONING_EFFORTS, + ] + assert "reasoning_effort" not in kt.KANBAN_CREATE_SCHEMA["parameters"]["required"] + + def test_link_happy_path(worker_env): from hermes_cli import kanban_db as kb conn = kb.connect() diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index d49b53a2212b..f628e36d3e34 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -34,6 +34,7 @@ from typing import Any, Optional from agent.redact import redact_sensitive_text +from hermes_constants import VALID_REASONING_EFFORTS from hermes_cli.goals import judge_goal from tools.registry import registry, tool_error from hermes_cli.config import cfg_get, load_config @@ -503,6 +504,7 @@ def _task_summary_dict(kb, conn, task) -> dict[str, Any]: "current_run_id": task.current_run_id, "model_override": task.model_override, "provider_override": task.provider_override, + "reasoning_effort": task.reasoning_effort, "parents": parents, "children": children, "parent_count": len(parents), @@ -549,6 +551,7 @@ def _task_dict(t): "current_run_id": t.current_run_id, "model_override": t.model_override, "provider_override": t.provider_override, + "reasoning_effort": t.reasoning_effort, } def _run_dict(r): @@ -1411,6 +1414,7 @@ def _handle_create(args: dict, **kw) -> str: goal_max_turns = args.get("goal_max_turns") model_override = args.get("model") provider_override = args.get("provider") + reasoning_effort = args.get("reasoning_effort") if provider_override and not model_override: return tool_error("'provider' requires 'model' to be set as well") if isinstance(parents, str): @@ -1454,6 +1458,7 @@ def _handle_create(args: dict, **kw) -> str: skills=skills, model_override=model_override, provider_override=provider_override, + reasoning_effort=reasoning_effort, goal_mode=goal_mode, goal_max_turns=( int(goal_max_turns) if goal_max_turns is not None else None @@ -1470,6 +1475,7 @@ def _handle_create(args: dict, **kw) -> str: workspace_kind=new_task.workspace_kind if new_task else None, workspace_path=new_task.workspace_path if new_task else None, project_id=new_task.project_id if new_task else None, + reasoning_effort=(new_task.reasoning_effort if new_task else None), subscribed=subscribed, ) finally: @@ -2303,6 +2309,15 @@ def _board_schema_prop() -> dict[str, str]: "to a different one. Requires 'model'." ), }, + "reasoning_effort": { + "type": "string", + "enum": ["", "none", *VALID_REASONING_EFFORTS], + "description": ( + "Pin the dispatched worker's reasoning effort for this " + "task. Use 'none' to disable reasoning. Omit or pass an " + "empty value to inherit the assignee profile default." + ), + }, "board": _board_schema_prop(), }, "required": ["title", "assignee"], diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index ebb1c26d6590..293023ddfe3d 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -499,6 +499,33 @@ model: For the occasional quality-sensitive card, pin just that task back to a stronger model with the [per-task model override](#per-task-model-override) (`--model`/`--provider` at create time, `hermes kanban set-model` later, or the dashboard's model dropdown) — no profile edits needed. +### Per-task reasoning effort + +Pin the worker's reasoning depth independently of its model. From an orchestrator agent, pass `reasoning_effort` to `kanban_create`: + +``` +kanban_create( + title="review migration safety", + assignee="reviewer", + reasoning_effort="high", +) +``` + +From the CLI, use `--reasoning`: + +```bash +hermes kanban create "review migration safety" \ + --assignee reviewer \ + --reasoning high + +# Disable reasoning for a mechanical task. +hermes kanban create "format generated files" \ + --assignee formatter \ + --reasoning none +``` + +Accepted levels are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. `none` is a stored override that disables reasoning; it does not mean “clear.” Omit the option or tool argument (or pass an empty tool value) to store `null` and inherit the assignee profile's default. Invalid values fail task creation instead of silently inheriting. `hermes kanban show ` and the JSON forms of `create`, `list`, and `show` expose the stored `reasoning_effort`; text output displays `inherit` for `null`. + ### Lifecycle plugin hooks Board transitions fire [plugin hooks](/user-guide/features/hooks#plugin-hooks): `kanban_task_claimed`, `kanban_task_completed`, and `kanban_task_blocked`, each carrying `task_id` and `profile_name`. Hooks fire **after** the board DB change commits, so callbacks always see durable state. Note the process split: `kanban_task_claimed` fires in the **dispatcher** process, while `kanban_task_completed`/`kanban_task_blocked` fire in the **worker** process — register the hook in the dispatcher profile to observe every transition centrally.