feat(secretary): wire task name→id search as a Secretary tool (#304)

The GET /secretary/tasks?q= route (name→id resolution) shipped in wave 1
but no tool called it, so the Secretary could read a task only by UUID —
yet the CEO always refers to tasks by name. This adds search_tasks to
both runtimes (Claude SDK build_secretary_options + the grok
roboco-secretary MCP server) over a shared _do_search_tasks helper, so
'the task about X' resolves to concrete ids the CEO can then act on via
read_task or a control_task directive.

_call_backend gains query-param support and now returns the decoded JSON
(object or list) so the search route's list response flows through;
_do_search_tasks wraps matches under 'tasks' and passes error envelopes
straight through. Persona + RAG role doc updated (the RAG doc had
explicitly flagged this gap). Tests cover the helper and both wrappers.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-03 20:07:28 +02:00
committed by GitHub
co-authored by Renn F
parent 3ccc723cd4
commit 607e13f3dd
7 changed files with 122 additions and 17 deletions
+3 -2
View File
@@ -35,10 +35,11 @@ When you carry out a directive, you act with the CEO's authority — but that au
## Your tools
You have read-only file tools to inspect the repos, plus three action tools:
You have read-only file tools to inspect the repos, plus three read tools and one action tool:
- **`read_company_state`** — a compact snapshot of the company: charter (goals), task counts by status, pending pitches, and any directives awaiting the CEO's confirmation. Reading is always free; ground every claim about state in what you actually read.
- **`read_task`** — one task's detail by its id.
- **`search_tasks`** — resolve a task NAME to concrete ids. The CEO names tasks, not ids: search a title/description substring (min 2 chars) to find the match, then feed its id to `read_task` or to a `control_task` directive. When a command targets "the task about X", search first.
- **`read_task`** — one task's detail by its id (get the id from `search_tasks`).
- **`submit_directive`** — act on the CEO's command. `kind` is one of `relay_message`, `update_charter`, `control_task`, `approve_pitch`, `announce`; `payload` carries that kind's fields. The high-impact kinds (`update_charter`, `control_task`, `approve_pitch`, `announce`) are gated server-side and queued for the CEO's explicit confirmation — so restate the action and wait for a clear "yes" before you call `submit_directive` for any of them. `relay_message` runs directly.
You have no `say`/`dm`/`notify` and no lifecycle verbs — you never talk to other agents or run the delivery lifecycle. You inform the CEO by writing in this chat, and you act only through `submit_directive`.
+3 -2
View File
@@ -23,6 +23,7 @@ It runs in its own `agent-secretary` container, reusing the Intake chat machiner
- Read the codebase: `Read`, `Grep`, `Glob`
- Read a compact company snapshot via **`read_company_state`** — the charter (goals), task counts by status, pending pitches, and any directives already awaiting the CEO's confirmation
- Resolve a task **name** to ids via **`search_tasks(q)`** — title/description/id-prefix search; the name→id step before a `read_task` or a `control_task` directive
- Read one task's **full detail** via **`read_task(task_id)`** — Secretary FULL task access: beyond identity/status/description this also carries acceptance criteria, plan, bounded recent `progress_updates`, dev/qa/auditor/pr-reviewer/doc notes, and the branch/PR reference
- Act on the CEO's command via **`submit_directive`** (see below)
@@ -68,14 +69,14 @@ submit_directive(
### Resolving a task by name
The CEO refers to tasks by **name**, not UUID. The backend exposes `GET /secretary/tasks?q=` (Secretary/CEO-gated; title/description/id-prefix search, capped at 50 rows) precisely for that name→id resolution — but as of this writing it isn't wired to a Secretary tool in either runtime (only `read_company_state` / `read_task` / `submit_directive` are). Until it is, resolve a name the CEO mentions from `read_company_state`'s task counts / your own conversation context, or ask the CEO to confirm the short id before you `control_task` or `read_task` it.
The CEO refers to tasks by **name**, not UUID. Use **`search_tasks(q, limit=20)`** — the Secretary tool over the `GET /secretary/tasks?q=` route (Secretary/CEO-gated; title/description/id-prefix search) — to resolve a name to concrete ids, then feed the right id into `read_task` or a `control_task` directive. Restate the match to the CEO before you act on a high-impact kind.
## Tool Surface (locked-down SDK session)
| Source | Tools |
|--------|-------|
| Base (read-only) | `Read`, `Grep`, `Glob` |
| Secretary MCP | `read_company_state`, `read_task`, `submit_directive` |
| Secretary MCP | `read_company_state`, `search_tasks`, `read_task`, `submit_directive` |
| `roboco-do` (gateway) | `note`, `evidence` |
Same isolation as Intake: a hard tool allowlist, no host settings, no outward agent comms. Everything else is denied.
+2 -1
View File
@@ -5,7 +5,8 @@ receiver and the same relay sink to ``/api/secretary/live/{id}/events``, but the
held-open session is a :class:`GrokCliSession` (per-turn headless ``grok -p``,
resuming one session id) rather than a ``ClaudeSDKClient``. ``~/.grok/config.toml``
is rendered first to wire the Secretary's CEO-authority tools (read_company_state
/ read_task / submit_directive) as the ``roboco-secretary`` MCP server, which
/ read_task / search_tasks / submit_directive) as the ``roboco-secretary`` MCP
server, which
calls ``/api/secretary/*`` with the container's HMAC agent token — the same auth
the one-shot Grok path uses.
"""
+54 -11
View File
@@ -6,7 +6,9 @@ and differs only in its tools. Where Intake has a single intercepted
``propose_draft``, the Secretary has three tools that actually call the backend
``/api/secretary/*`` routes on the CEO's behalf:
* ``read_company_state`` / ``read_task`` — reads (always allowed)
* ``read_company_state`` / ``read_task`` / ``search_tasks`` — reads (always
allowed); ``search_tasks`` resolves a task NAME to ids so a directive can
target one (the CEO refers to tasks by name).
* ``submit_directive`` — acts; the backend gate-list queues high-impact kinds
for the CEO's confirmation and runs low-risk ones directly.
@@ -49,9 +51,14 @@ async def _call_backend(
path: str,
*,
json_body: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""Call ``/api/secretary{path}`` with the agent's auth; never raises."""
) -> Any:
"""Call ``/api/secretary{path}`` with the agent's auth; never raises.
Returns the decoded JSON (an object for most routes, a list for the task
search) or an ``{"error": ...}`` envelope on any HTTP/transport failure.
"""
owns = client is None
http = client or httpx.AsyncClient(timeout=_TIMEOUT)
try:
@@ -60,6 +67,7 @@ async def _call_backend(
f"{_api_base()}/api/secretary{path}",
headers=_headers(),
json=json_body,
params=params,
timeout=_TIMEOUT,
)
except httpx.HTTPError as exc:
@@ -69,18 +77,40 @@ async def _call_backend(
await http.aclose()
if not resp.is_success:
return {"error": f"http_{resp.status_code}", "detail": resp.text[:300]}
parsed: dict[str, Any] = resp.json()
return parsed
return resp.json()
async def _do_read_state(*, client: httpx.AsyncClient | None = None) -> dict[str, Any]:
return await _call_backend("GET", "/state", client=client)
result: dict[str, Any] = await _call_backend("GET", "/state", client=client)
return result
async def _do_read_task(
task_id: str, *, client: httpx.AsyncClient | None = None
) -> dict[str, Any]:
return await _call_backend("GET", f"/tasks/{task_id}", client=client)
result: dict[str, Any] = await _call_backend(
"GET", f"/tasks/{task_id}", client=client
)
return result
async def _do_search_tasks(
q: str,
limit: int = 20,
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
"""Resolve a task NAME to concrete ids (title/description/id-prefix match).
Wraps the list of matches under ``tasks`` so the tool result is an object;
passes an ``{"error": ...}`` envelope straight through.
"""
result = await _call_backend(
"GET", "/tasks", params={"q": q, "limit": limit}, client=client
)
if isinstance(result, list):
return {"tasks": result}
return result if isinstance(result, dict) else {"error": "unexpected_response"}
async def _do_submit_directive(
@@ -89,12 +119,13 @@ async def _do_submit_directive(
*,
client: httpx.AsyncClient | None = None,
) -> dict[str, Any]:
return await _call_backend(
result: dict[str, Any] = await _call_backend(
"POST",
"/directives",
json_body={"kind": kind, "payload": payload},
client=client,
)
return result
def _text_result(data: dict[str, Any]) -> dict[str, Any]:
@@ -141,6 +172,17 @@ def build_secretary_options(
async def _t_read_task(args: dict[str, Any]) -> dict[str, Any]:
return _text_result(await _do_read_task(str(args["task_id"])))
@tool(
"search_tasks",
"Resolve a task NAME to concrete task ids. The CEO refers to tasks by "
"name; search a title/description substring (min 2 chars) to get "
"matching ids, then pass an id to read_task or to submit_directive's "
"control_task. Returns up to 20 matches.",
{"q": str},
)
async def _t_search(args: dict[str, Any]) -> dict[str, Any]:
return _text_result(await _do_search_tasks(str(args["q"])))
@tool(
"submit_directive",
"Act on the CEO's command. 'kind' is one of: relay_message "
@@ -163,7 +205,7 @@ def build_secretary_options(
server = create_sdk_mcp_server(
name="secretary",
version="1.0.0",
tools=[_t_read_state, _t_read_task, _t_submit],
tools=[_t_read_state, _t_read_task, _t_search, _t_submit],
)
async def _gate(tool_name: str, _input: dict[str, Any], _ctx: Any) -> Any:
@@ -183,8 +225,8 @@ def build_secretary_options(
return PermissionResultDeny(
message=(
f"{tool_name} is not available to the Secretary. Your tools are "
"Read, Grep, Glob, read_company_state, read_task, and "
"submit_directive."
"Read, Grep, Glob, read_company_state, read_task, search_tasks, "
"and submit_directive."
)
)
@@ -196,6 +238,7 @@ def build_secretary_options(
*_SECRETARY_BASE_TOOLS,
"mcp__secretary__read_company_state",
"mcp__secretary__read_task",
"mcp__secretary__search_tasks",
"mcp__secretary__submit_directive",
],
model=model,
+15 -1
View File
@@ -2,7 +2,8 @@
Parity with the Claude Secretary's SDK tools
(:func:`roboco.agent_sdk.secretary_driver.build_secretary_options`):
``read_company_state`` / ``read_task`` (reads) and ``submit_directive`` (acts).
``read_company_state`` / ``read_task`` / ``search_tasks`` (reads) and
``submit_directive`` (acts).
Each calls the backend ``/api/secretary/*`` routes with the container's HMAC
agent token; the backend gate-list queues high-impact directive kinds for the
CEO's confirmation and runs low-risk ones directly. The backend-calling logic is
@@ -24,6 +25,7 @@ from mcp.server.fastmcp import FastMCP
from roboco.agent_sdk.secretary_driver import (
_do_read_state,
_do_read_task,
_do_search_tasks,
_do_submit_directive,
)
@@ -47,6 +49,18 @@ async def read_task(task_id: str) -> str:
return json.dumps(await _do_read_task(task_id))
@mcp.tool()
async def search_tasks(q: str, limit: int = 20) -> str:
"""Resolve a task NAME to concrete task ids.
The CEO refers to tasks by name; search a title/description substring
(min 2 chars) to get matching ids, then pass an id to read_task or to
submit_directive's control_task. Returns up to 'limit' (default 20)
matches under the 'tasks' key.
"""
return json.dumps(await _do_search_tasks(q, limit))
@mcp.tool()
async def submit_directive(kind: str, payload: dict[str, Any]) -> str:
"""Act on the CEO's command.
@@ -51,6 +51,34 @@ async def test_read_task_calls_backend(monkeypatch: pytest.MonkeyPatch) -> None:
assert out["id"] == "abc"
@pytest.mark.asyncio
async def test_search_tasks_sends_query_and_wraps_list(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_env(monkeypatch)
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/secretary/tasks"
assert request.url.params["q"] == "x account"
assert request.url.params["limit"] == "20"
return httpx.Response(200, json=[{"id": "abc", "title": "X account"}])
out = await sd._do_search_tasks("x account", client=_client(handler))
assert out == {"tasks": [{"id": "abc", "title": "X account"}]}
@pytest.mark.asyncio
async def test_search_tasks_passes_error_through(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_env(monkeypatch)
out = await sd._do_search_tasks(
"q", client=_client(lambda _r: httpx.Response(422, text="too short"))
)
assert "tasks" not in out
assert out["error"] == "http_422"
@pytest.mark.asyncio
async def test_submit_directive_posts_kind_and_payload(
monkeypatch: pytest.MonkeyPatch,
@@ -40,6 +40,23 @@ async def test_read_task_forwards_the_id(monkeypatch: pytest.MonkeyPatch) -> Non
assert json.loads(out)["title"] == "T"
@pytest.mark.asyncio
async def test_search_tasks_forwards_query_and_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, Any] = {}
async def _search(q: str, limit: int = 20) -> dict[str, Any]:
seen["q"] = q
seen["limit"] = limit
return {"tasks": [{"id": "t1"}]}
monkeypatch.setattr(secretary_server, "_do_search_tasks", _search)
out = await secretary_server.search_tasks("x account", 5)
assert seen == {"q": "x account", "limit": 5}
assert json.loads(out) == {"tasks": [{"id": "t1"}]}
@pytest.mark.asyncio
async def test_submit_directive_forwards_kind_and_payload(
monkeypatch: pytest.MonkeyPatch,