Skip to content
Open
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
2 changes: 2 additions & 0 deletions contributors/emails/alex@thealexferrari.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
alexferrari88
# PR #79405 (kanban: expose per-task reasoning effort)
12 changes: 12 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -380,6 +381,15 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Provider the --model belongs to (passed as "
"--provider <name> 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 "
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/hermes_cli/test_kanban_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
99 changes: 99 additions & 0 deletions tests/tools/test_kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions tools/kanban_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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"],
Expand Down
27 changes: 27 additions & 0 deletions website/docs/user-guide/features/kanban.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id>` 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.
Expand Down
Loading