diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 68bba09aa8195..fdc49da34f135 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -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) diff --git a/tests/plugins/test_kanban_model_override.py b/tests/plugins/test_kanban_model_override.py index 9823e93c397c1..22221b5ac0ea4 100644 --- a/tests/plugins/test_kanban_model_override.py +++ b/tests/plugins/test_kanban_model_override.py @@ -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"