mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user