feat(kanban): expose the per-task reasoning effort over REST

Carries the new column through create, PATCH, and bulk. Clearing is an
explicit clear_reasoning_effort flag rather than a null, because a null in a
PATCH body means "field not sent", not "set to NULL" — the same shape the
model override already uses, and the reason "none" can stay a real value.

Tests cover normalization, the depth-survives-a-model-clear invariant, both
spawn-argv branches, and the REST round-trip. One asserts the worker CLI
actually accepts the --reasoning flag the dispatcher emits: a spawn arg no
parser accepts would fail every dispatch while every unit test stayed green.
This commit is contained in:
Brooklyn Nicholson 2026-08-01 16:10:03 -05:00
parent 0b69a6ac02
commit f0ed0aebbc
2 changed files with 152 additions and 0 deletions

View File

@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
# Per-task thinking depth (none|minimal|…|ultra). None = inherit the
# assigned profile's own agent.reasoning_effort.
reasoning_effort: Optional[str] = None
# Explicit project link; when omitted, create_task inherits the board's
# scoped project (if any) so a project-scoped board anchors every task.
project_id: Optional[str] = None
@ -639,6 +642,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
reasoning_effort=payload.reasoning_effort,
project_id=payload.project_id,
board=board,
)
@ -839,6 +843,12 @@ class UpdateTaskBody(BaseModel):
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
# Per-task thinking depth. ``"none"`` is a VALUE (thinking off), not a
# clear — use ``clear_reasoning_effort=True`` to fall back to the
# profile's own level. Separate from the model clear so dropping a model
# override doesn't silently reset the depth the operator chose.
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
@router.patch("/tasks/{task_id}")
@ -934,6 +944,19 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- reasoning effort ----------------------------------------------
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
new_effort = (
None if payload.clear_reasoning_effort
else payload.reasoning_effort
)
try:
ok = kanban_db.set_reasoning_effort(conn, task_id, new_effort)
except (ValueError, RuntimeError) as e:
raise HTTPException(status_code=400, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- priority -----------------------------------------------------
if payload.priority is not None:
with kanban_db.write_txn(conn):
@ -1199,6 +1222,9 @@ class BulkTaskBody(BaseModel):
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
# Bulk thinking-depth override — same semantics as UpdateTaskBody.
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
@router.post("/tasks/bulk")
@ -1304,6 +1330,17 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
entry.update(ok=False, error="model override refused")
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
new_effort = (
None if payload.clear_reasoning_effort
else payload.reasoning_effort
)
try:
ok = kanban_db.set_reasoning_effort(conn, tid, new_effort)
if not ok:
entry.update(ok=False, error="reasoning override refused")
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
except Exception as e: # defensive — one bad id shouldn't kill the batch
entry.update(ok=False, error=str(e))
results.append(entry)

View File

@ -205,3 +205,118 @@ def test_model_options_endpoint_shape(client, monkeypatch):
assert "slug" in row and "label" in row and "models" in row
assert isinstance(row["models"], list)
assert len(row["models"]) >= 1 # empty-model rows are filtered out
# ---------------------------------------------------------------------------
# Per-task reasoning effort — the depth half of the board's model picker
# ---------------------------------------------------------------------------
def test_reasoning_effort_normalizes_and_rejects(conn):
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort=" HIGH ")
assert kb.get_task(conn, tid).reasoning_effort == "high"
# "none" is a VALUE (thinking off), not a clear.
assert kb.set_reasoning_effort(conn, tid, "none")
assert kb.get_task(conn, tid).reasoning_effort == "none"
# Empty clears back to "inherit the profile".
assert kb.set_reasoning_effort(conn, tid, "")
assert kb.get_task(conn, tid).reasoning_effort is None
with pytest.raises(ValueError):
kb.set_reasoning_effort(conn, tid, "extremely-hard")
def test_reasoning_effort_survives_clearing_the_model(conn):
"""Depth and model are independent knobs: dropping a model override must
not silently reset the thinking depth the operator chose."""
tid = kb.create_task(
conn, title="t", assignee="worker",
model_override="glm-5", provider_override="openrouter",
reasoning_effort="ultra",
)
assert kb.set_model_override(conn, tid, None)
t = kb.get_task(conn, tid)
assert t.model_override is None
assert t.provider_override is None
assert t.reasoning_effort == "ultra"
def test_reasoning_effort_without_a_model_override(conn):
"""A task may run the profile's OWN model at a different depth."""
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort="low")
t = kb.get_task(conn, tid)
assert t.model_override is None
assert t.reasoning_effort == "low"
def test_spawn_passes_reasoning_without_a_model(monkeypatch, tmp_path, conn):
tid = kb.create_task(conn, title="t", assignee="elias", reasoning_effort="high")
task = kb.get_task(conn, tid)
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
assert "-m" not in cmd
i = cmd.index("--reasoning")
assert cmd[i + 1] == "high"
def test_spawn_omits_reasoning_when_unset(monkeypatch, tmp_path, conn):
tid = kb.create_task(conn, title="t", assignee="elias")
task = kb.get_task(conn, tid)
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
assert "--reasoning" not in cmd
def test_worker_cli_accepts_the_reasoning_flag():
"""The dispatcher's --reasoning must be a real flag on the worker's CLI —
a spawn arg no parser accepts fails every dispatch."""
from hermes_cli._parser import build_top_level_parser
parser = build_top_level_parser()[0]
args = parser.parse_args(["--cli", "chat", "-q", "hi", "--reasoning", "high"])
assert args.reasoning == "high"
def test_patch_sets_and_clears_reasoning_effort(client):
task = _create(client)
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"reasoning_effort": "xhigh"},
)
assert r.status_code == 200, r.text
assert r.json()["task"]["reasoning_effort"] == "xhigh"
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"clear_reasoning_effort": True},
)
assert r.status_code == 200, r.text
assert r.json()["task"]["reasoning_effort"] is None
def test_patch_rejects_an_unknown_level(client):
task = _create(client)
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"reasoning_effort": "bogus"},
)
assert r.status_code == 400
def test_create_accepts_reasoning_effort(client):
task = _create(client, reasoning_effort="minimal")
assert task["reasoning_effort"] == "minimal"
def test_bulk_reasoning_effort(client):
t1 = _create(client)
t2 = _create(client)
r = client.post(
"/api/plugins/kanban/tasks/bulk",
json={"ids": [t1["id"], t2["id"]], "reasoning_effort": "max"},
)
assert r.status_code == 200, r.text
assert all(entry["ok"] for entry in r.json()["results"])
for tid in (t1["id"], t2["id"]):
got = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
assert got["reasoning_effort"] == "max"