Agents can hand work to agents: dispatch_agent

A schedule is a time trigger, and only the first step of a pipeline is really
waiting on time — every later step waits on the previous step's result. Modeling
"watch a source -> write a spec -> implement it" as three schedules made each fire
blind: on a quiet day the coding agent still spawned, paid a full model run to find
there was nothing to do, and left an empty run in Activity.

So an agent can start the next step itself. `dispatch_agent` (a tool on the bundled
MCP server, and on the pi bridge through the same --call seam) enqueues an ordinary
spawn command in the agent's own project, tagged requested_by=agent:<id> — so a
handoff is visible in Activity with no new surface to build.

- Project-scoped by construction: project_id is read from the spawn environment and
  never from the tool arguments.
- Bounded rather than gated: MAX_DISPATCH_PER_RUN counts the command rows the agent
  already wrote; MAX_DISPATCH_DEPTH rides in the spawn payload and is recovered by
  spawn._dispatch_depth, so a chain keeps its place across a resume and a cycle
  terminates instead of fanning out.
- New scout and planner roles, with built-in skills (handler-scout, handler-planner,
  handler-dispatch) carrying the judgment code can't: dedupe against a memory-note
  watermark, treat "nothing new" as a complete run, and write a task the receiving
  cold-start agent can act on.
- A scout ending on a clean tree skips the test gate and records the new
  tests_status='skipped' (migration 0017, additive CHECK widening). The gate promises
  `done` means tests passed for the work that shipped; nothing shipped.

Rejected a `condition` field on schedules: "is this paper new and does it matter
here?" is a semantic judgment, so it belongs to a model, not a scheduler column. The
scout is the condition; dispatch is how it reports true — one mechanism that covers
future pipelines too.

426 tests (14 new for dispatch, 3 for the gate exemption).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcbDevyMcJWE6qPA56C7mZ
This commit is contained in:
Claude
2026-08-19 22:46:06 +00:00
parent 52167db085
commit 8541b4b7c0
23 changed files with 945 additions and 33 deletions
+6
View File
@@ -77,6 +77,12 @@ PROJECTS_ROOT=/var/lib/handler/projects
# Closes the "merge locally, push to main" path around the forge-merge approval gate. # Closes the "merge locally, push to main" path around the forge-merge approval gate.
# PROTECTED_BRANCHES=main,master # PROTECTED_BRANCHES=main,master
# Agent-initiated dispatch (the `dispatch_agent` tool): an agent handing work to a fresh
# agent in its own project, so a pipeline advances on a result instead of on a timer.
# Both caps bound a confused or looping agent, not a healthy one — a handoff is one call.
# MAX_DISPATCH_PER_RUN=3
# MAX_DISPATCH_DEPTH=3
# Phase 3 (web UI). Serve the bundled UI from "/" and "/static". Set false for a # Phase 3 (web UI). Serve the bundled UI from "/" and "/static". Set false for a
# headless, API-only deployment. Applied at process start (restart to change). # headless, API-only deployment. Applied at process start (restart to change).
# UI_ENABLED=true # UI_ENABLED=true
+37
View File
@@ -6,6 +6,43 @@ the image workflows publish (plus `latest` from every push to `main`).
## [Unreleased] ## [Unreleased]
### Added — dynamic workflows: agents hand work to agents
Until now the only recurring primitive was a schedule, which fires an unconditional
spawn on a timer. A pipeline (watch a source → write a spec → implement it) had to be
N independent schedules, each firing blind: on a quiet day the coding agent still
spawned, paid a full model run to discover there was no work, and left an empty run in
Activity. Time is the wrong trigger for the later steps — the previous step's *result*
is.
- **`dispatch_agent`**, a new tool on the bundled `handler-memory` MCP server (and on
the pi bridge, via the same `--call` seam). An agent hands work to a fresh agent in
**its own project**`project_id` comes from the spawn environment, never from the
arguments — by enqueuing an ordinary `spawn` command tagged
`requested_by = agent:<id>`. It shows up in Activity like any other command, so
nothing new had to be built to observe it.
- **Guardrails, not an approval queue.** `MAX_DISPATCH_PER_RUN` (default 3) bounds one
run's handoffs, counted off the command rows it already wrote; `MAX_DISPATCH_DEPTH`
(default 3) bounds how far a chain reaches, so a cycle terminates instead of fanning
out. Depth is recovered from the spawn command that created an agent, so it survives
a resume. A refused dispatch tells the agent what to do instead.
- **Two roles — `scout` and `planner`** — with built-in skills (`handler-scout`,
`handler-planner`, `handler-dispatch`) carrying the judgment the code can't: dedupe
against a memory watermark, treat "nothing new" as a complete run, write a task the
receiving agent can act on cold.
- **A quiet scout run is cheap.** A `scout` ending on a clean tree skips `mise run test`
and records the new `tests_status = 'skipped'` (migration `0017_gate_skipped`) — the
gate's promise is that `done` means tests passed *for the work that shipped*, and
nothing shipped. A dirty tree, or any other role, keeps the full gate.
The net effect: one cheap schedule fires the scout; on a quiet day the pipeline costs
one small-model call and stops. Only a scout that actually found something spends a
coding-model run, and the spec it produced travels with the dispatch.
**Rollout:** run migrations (`0017_gate_skipped`, additive — it only widens a CHECK).
The two new settings have working defaults. The three new skills seed themselves on the
next API start, idempotently by name.
### Fixed ### Fixed
- **The project-root checkout is now a ref store, never a working tree Handler moves.** - **The project-root checkout is now a ref store, never a working tree Handler moves.**
+58
View File
@@ -139,6 +139,7 @@ Configuration is entirely environment-driven (see [`.env.example`](.env.example)
| `CLAUDE_BIN` / `PI_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `pi` / `mise` / `tmux` / `forge` / `git` | | `CLAUDE_BIN` / `PI_BIN` / `MISE_BIN` / `TMUX_BIN` / `FORGE_BIN` / `GIT_BIN` | Binary overrides | `claude` / `pi` / `mise` / `tmux` / `forge` / `git` |
| `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check | | `FORGE_VERSION` | Pinned forge version verified at spawn (Phase 2) | unset → skip check |
| `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` | | `PROTECTED_BRANCHES` | Branches a direct push needs an approval to reach (Phase 2) | `main,master` |
| `MAX_DISPATCH_PER_RUN` / `MAX_DISPATCH_DEPTH` | Caps on agent-initiated dispatch: handoffs per run, and how far a chain reaches | `3` / `3` |
## Run ## Run
@@ -452,6 +453,11 @@ recent notes in scope, so knowledge from earlier runs arrives without being aske
Notes live only in the database — like everything else, they survive disposable Notes live only in the database — like everything else, they survive disposable
workers by construction — and deleting an agent never deletes what it learned. workers by construction — and deleting an agent never deletes what it learned.
The same MCP server carries **`dispatch_agent`** (see
[Dynamic workflows](#dynamic-workflows-agents-handing-work-to-agents)), so an agent's
two ways of reaching past its own run — what it *knows* and what it *starts* — arrive
over one transport with one identity contract.
## Hooks ## Hooks
Wired into each agent as `python -m handler.hooks <event>`: Wired into each agent as `python -m handler.hooks <event>`:
@@ -475,6 +481,12 @@ Wired into each agent as `python -m handler.hooks <event>`:
agent's scope (its project + global) as additional context, plus a pointer at the agent's scope (its project + global) as additional context, plus a pointer at the
handler-memory MCP tools. Best-effort; never blocks the session. handler-memory MCP tools. Best-effort; never blocks the session.
One narrow exemption to the completion gate: a **`scout`** ending with a clean tree
skips the test run and records `tests_status = 'skipped'`. Scouts look and hand
findings on, so "`done` means a test run passed" is vacuous when nothing shipped — and
a scheduled watch is mostly quiet runs. A dirty tree, or any other role, keeps the
full gate.
Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`, Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`,
`HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `HANDLER_AGENT_ROLE`, `DATABASE_URL`), since `HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `HANDLER_AGENT_ROLE`, `DATABASE_URL`), since
hook stdin doesn't carry it; the wiring itself lives in the generated `settings.json`. hook stdin doesn't carry it; the wiring itself lives in the generated `settings.json`.
@@ -547,6 +559,52 @@ Missed intervals (worker down) collapse into a single catch-up run. Manage sched
the dashboard's **Schedules** pane or via `GET/POST /projects/:p/schedules`, the dashboard's **Schedules** pane or via `GET/POST /projects/:p/schedules`,
`PATCH`/`DELETE /schedules/:id`. `PATCH`/`DELETE /schedules/:id`.
### Dynamic workflows: agents handing work to agents
A schedule is a *time* trigger, which is the wrong trigger for every step after the
first. Scheduling "write a spec" and "implement the spec" on their own timers means
they fire blind: on a quiet day the coding agent still spawns, pays a full model run to
discover there is nothing to do, and leaves an empty run behind. What the later steps
actually wait on is the **result** of the earlier one.
So an agent can start one: **`dispatch_agent`** (a tool on the bundled MCP server, and
on the pi bridge) enqueues an ordinary `spawn` command in the agent's own project,
tagged `requested_by = agent:<id>`. It appears in Activity like any other command, and
the worker claims it the same way.
```
schedule (cheap model, every 6h)
└─ scout ── nothing new? update the watermark, end. ← one small call, done
something new? dispatch_agent(role="planner") ─┐
planner ── writes + commits specs/<date>-<slug>.md
dispatch_agent(role="junior") ─┐
junior → senior → deploy
```
Only the scout is on a timer. Nothing downstream costs a token until the scout says
there is work — which is the point: the condition ("is this paper new, and does it
matter here?") is a judgment, so it belongs to a model rather than to a scheduler
field.
- **Project-scoped by construction.** The target project comes from the spawn
environment, never from the tool arguments, so a dispatch can't cross a project
boundary any more than an agent can.
- **Bounded, not gated.** `MAX_DISPATCH_PER_RUN` (default 3) caps one run's handoffs;
`MAX_DISPATCH_DEPTH` (default 3) caps how far a chain reaches, so a cycle terminates
instead of fanning out. Depth is recovered from the command that created an agent, so
a resume keeps its place in the chain. Dispatches run immediately — the caps and the
Activity trail are the cost control, not an approval queue.
- **Roles carry the judgment.** `scout` and `planner` join the forge roles, with
built-in skills (`handler-scout`, `handler-planner`, `handler-dispatch`) that teach
the parts code can't enforce: dedupe new findings against a memory-note watermark,
treat "nothing new" as a *complete* run, and write a task the receiving agent — which
starts cold, with no memory of the session that dispatched it — can actually act on.
- **Cheap where it should be.** Point the scout's schedule at a small backend with
`model_id` (a `claude_models` row, `harness='pi'` for a local model); the expensive
Claude run only happens on the day there is something to build.
## Development ## Development
```bash ```bash
+2
View File
@@ -22,6 +22,8 @@ import type { Schedule } from "../api/client";
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
{ value: "", label: "Role — none" }, { value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" }, { value: "junior", label: "junior" },
{ value: "senior", label: "senior" }, { value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" }, { value: "deploy", label: "deploy" },
+38
View File
@@ -244,6 +244,44 @@ The static shell is served **unauthenticated** (it holds no data); the browser p
**Out of scope (additive follow-ups):** an aggregate `GET /projects/{project}/overview` (agents + latest checkmark in one call) to show every agent's checkmark at once; spawning agents / registering projects from the UI (still CLI-driven); shared-context **writes** from the UI (would need the shared-write token — MVP is read-only). **Out of scope (additive follow-ups):** an aggregate `GET /projects/{project}/overview` (agents + latest checkmark in one call) to show every agent's checkmark at once; spawning agents / registering projects from the UI (still CLI-driven); shared-context **writes** from the UI (would need the shared-write token — MVP is read-only).
### Dynamic workflows — agent-initiated dispatch
Design decision (operator, 2026-08-19): a recurring **pipeline** is not N schedules. A
schedule is a time trigger, and only the first step of a pipeline is genuinely waiting
on time — every later step is waiting on the previous step's *result*. Scheduling them
independently means each fires blind, and the expensive steps pay a full model run on
quiet days just to discover there is nothing to do.
Rejected: a `condition` field on `schedules`. The conditions that matter here ("is this
paper new, and does it bear on this project?") are semantic judgments, so they belong
to a model, not to a scheduler column. The scout **is** the condition; `dispatch_agent`
is how it reports true. One mechanism then covers every future pipeline rather than
this one.
- [x] `dispatch_agent` on the bundled MCP server (+ pi bridge via the same `--call`
seam): enqueues an ordinary `spawn` command tagged `requested_by = agent:<id>`,
so a handoff is visible in Activity with no new surface. `project_id` is read
from the spawn environment and never from the arguments — project isolation holds
by construction rather than by validation.
- [x] Bounded rather than gated (operator decision: dispatches run immediately, no
approval queue): `max_dispatch_per_run` counted off the command rows the agent
already wrote, and `max_dispatch_depth` carried in the spawn payload and
recovered by `spawn._dispatch_depth`, so a chain keeps its place across a resume
and a cycle terminates. Any agent may dispatch — one general primitive, so a
later chain (a junior splitting off a follow-up) needs no further change.
- [x] `scout` + `planner` roles with built-in skills carrying the judgment code can't:
dedupe against a memory-note watermark (better than a repo file — no checkout, no
commit, and `SessionStart` recall delivers it for free), treat "nothing new" as a
complete run, and write a task the receiving cold-start agent can act on.
- [x] A `scout` on a clean tree skips the test gate (`tests_status = 'skipped'`,
migration `0017_gate_skipped`). The gate promises `done` means tests passed *for
the work that shipped*; nothing shipped. The clean-tree condition is what keeps
that honest.
**Definition of done:** one cheap schedule fires a scout; a quiet run costs one
small-model call and enqueues nothing; a run that finds something dispatches a planner,
which commits a spec and dispatches a junior into the existing forge workflow. 426 tests.
### Phase 4 — Observability (moved back, now optional) ### Phase 4 — Observability (moved back, now optional)
- [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate) - [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate)
- [ ] Grafana/Loki wiring documented as an optional add-on for self-hosters who already run that stack — not a dependency for anyone else - [ ] Grafana/Loki wiring documented as an optional add-on for self-hosters who already run that stack — not a dependency for anyone else
@@ -9,6 +9,8 @@ import { fmtFull, timeAgo } from "@/lib/format";
const ROLE_OPTS = [ const ROLE_OPTS = [
{ value: "", label: "Role — none" }, { value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" }, { value: "junior", label: "junior" },
{ value: "senior", label: "senior" }, { value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" }, { value: "deploy", label: "deploy" },
@@ -10,6 +10,8 @@ import { fmtFull } from "@/lib/format";
const ROLE_OPTS = [ const ROLE_OPTS = [
{ value: "", label: "Role — none" }, { value: "", label: "Role — none" },
{ value: "scout", label: "scout" },
{ value: "planner", label: "planner" },
{ value: "junior", label: "junior" }, { value: "junior", label: "junior" },
{ value: "senior", label: "senior" }, { value: "senior", label: "senior" },
{ value: "deploy", label: "deploy" }, { value: "deploy", label: "deploy" },
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Roles + forge families mirrored from db.tables; Literal gives clean 422s on bad input. # Roles + forge families mirrored from db.tables; Literal gives clean 422s on bad input.
Role = Literal["junior", "senior", "deploy"] Role = Literal["scout", "planner", "junior", "senior", "deploy"]
ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"] ForgeType = Literal["github", "gitlab", "gitea", "forgejo", "bitbucket"]
# Agent harness a model backend launches (claude_models.harness). # Agent harness a model backend launches (claude_models.harness).
+123
View File
@@ -240,6 +240,129 @@ whatever the operator configured). They are for tools to use, not for output.
- Committed a secret anyway? Do not just delete it in a follow-up commit (history - Committed a secret anyway? Do not just delete it in a follow-up commit (history
keeps it). Stop, leave an open question naming the credential so the operator can keeps it). Stop, leave an open question naming the credential so the operator can
rotate it, and say exactly which commit is affected. rotate it, and say exactly which commit is affected.
""",
),
(
"handler-dispatch",
"How to hand work to a new agent with dispatch_agent: when a handoff is "
"warranted, and how to write a task the receiving agent can act on cold. "
"Use whenever you are considering dispatching.",
"""# Handing work to another agent
`dispatch_agent` queues a **new agent** in your project, starting as soon as a worker
is free. It exists so a pipeline advances on a *result* instead of on a timer: the
step that knows whether there is work is the step that starts the next one.
## Dispatch when — and only when — there is real work
- One dispatch per thing you actually found. A handoff, not a fan-out.
- **Finding nothing is a complete, successful run.** Say so in your final message and
end your turn. Do not dispatch "just to check", do not dispatch a placeholder, and
do not dispatch work you could finish yourself in this run.
- Don't dispatch to dodge a blocked gate. A failing test is yours to fix.
## Write the task for someone with no context
The new agent starts cold: it never saw your session, your search results, or your
reasoning. Its whole world is the `task` string you write. So:
- State the outcome, not the backstory: "Implement @specs/2026-08-19-foo.md" beats
"continue what I was looking at".
- Name every file, path, URL, or identifier it needs. If a fact only exists in your
transcript, it is lost put it in the task, or save it to memory and say which note.
- Say what *done* looks like, and name any constraint you already know about.
- `reason` is for the operator reading Activity, not for the new agent: one line on
why this handoff was warranted.
## Limits, and what they mean
Dispatch is capped per run and per chain depth; a refusal is not a bug to route
around. Hitting the per-run cap means you are fanning out fold the rest into one
handoff. Hitting the depth cap means the chain has gone far enough without a human:
finish what you can and leave the rest in your checkpoint for the operator.
Your dispatch shows up in Activity as a normal queued spawn attributed to you, so the
operator can always see which agent started what.
""",
),
(
"handler-scout",
"Role: scout — watch a source for genuinely new material, dedupe against a "
"memory watermark, and hand findings to a planner. Use when your role is "
"scout or your task is a recurring watch.",
"""# Role: scout
You watch a source and decide whether anything new is worth acting on. You do **not**
write code, write specs, or change the repo a scout run should leave a clean tree.
Most of your runs will find nothing. That is the job working correctly, and it is what
makes the whole pipeline cheap: nothing downstream runs until you say there is work.
## Every run, in order
1. **Recall the watermark.** `memory_search` for your watch note (the one your task
names, e.g. `watch:<topic>`). It lists the identifiers you have already handled
DOIs, arXiv ids, URLs, release tags, whatever your source uses. A `SessionStart`
recall usually puts it in front of you before you ask.
2. **Query the source** your task names, over a window comfortably wider than your
schedule's interval — overlap is free, a gap loses an item forever.
3. **Filter to genuinely new AND relevant.** Drop anything whose id is already in the
watermark. Then drop anything that doesn't actually bear on the project's subject:
a keyword match is not relevance, and passing junk downstream costs a full coding
run. When you are unsure, prefer to skip and note why.
4. **Update the watermark** with `memory_save(note_id=...)` on the *same* note every
id you examined this run, whether or not you passed it on, so the next run doesn't
re-examine it. Keep it a compact list, newest first; trim ids far older than could
ever resurface.
5. **Report.**
- *Nothing new:* end your turn with a one-line final message saying what you
searched and that nothing qualified. No dispatch. Don't pad the run.
- *Something new:* one `dispatch_agent` call with `role="planner"`, carrying the
full citations (title, id/DOI, link, authors, date) and in your own words why
it matters to this project and what it might change. Then end.
## Keep the run cheap
You are deliberately run on a small, cheap model on a short leash: read abstracts and
metadata first, and only fetch a full text when relevance genuinely turns on it. Don't
clone, don't build, don't run tests. Your entire output is a memory note and, on the
rare interesting day, one dispatch.
""",
),
(
"handler-planner",
"Role: planner — turn source material into a committed spec, then dispatch "
"an implementer. Use when your role is planner.",
"""# Role: planner
You turn raw material (a paper, a report, an operator brief) into a spec someone else
can implement without reading the source. You do **not** implement it yourself.
## The run
1. **Read the sources named in your task** properly, not just the abstract. If a
source is unreachable, say so in your checkpoint rather than guessing at it.
2. **Check what already exists.** `memory_search` for prior decisions on this topic,
and look at the repo: the change may already be present, already rejected, or
already specced. Saying "no change needed, here's why" is a valid outcome and a
cheap one.
3. **Write `specs/<YYYY-MM-DD>-<slug>.md`** (create `specs/` if missing):
- *Source* citation and link, so the provenance survives you.
- *Why* what this changes about how the project should work.
- *What to build* concrete, in this codebase's terms: the files and functions to
touch, the behavior to add, the interfaces involved.
- *How to verify* what test proves it, and what the expected result is.
- *Out of scope* what an implementer should explicitly not do here.
Write for someone who never read the source. If the source doesn't support a
concrete change, write that conclusion in the spec instead of inventing scope.
4. **Commit and push the spec** it must exist in the repo before anyone can act on
it, and the completion gate will hold you until it's pushed anyway.
5. **Dispatch the implementer:** one `dispatch_agent` with `role="junior"` and a task
naming the spec path (`Implement @specs/<file>.md`) plus a one-line summary of the
goal. From there the normal junior -> senior -> deploy workflow takes over.
If step 3 concluded no change is warranted, commit that spec anyway as the record
and do **not** dispatch. A written "we looked and decided not to" is worth keeping.
""", """,
), ),
] ]
+10
View File
@@ -107,6 +107,16 @@ class Settings(BaseSettings):
# Comma-separated permission allow rules added to generated settings for headless runs. # Comma-separated permission allow rules added to generated settings for headless runs.
headless_allowed_tools: str = "Bash(git *),Bash(mise *)" headless_allowed_tools: str = "Bash(git *),Bash(mise *)"
# ---- Agent-initiated dispatch (the ``dispatch_agent`` MCP tool): an agent handing
# work to a fresh agent in its own project, so a pipeline advances on a result rather
# than on a timer. Both caps bound a confused (or looping) agent, not a healthy one:
# a handoff is one call, and chains are two or three links deep.
# Dispatches one agent may enqueue within a single run.
max_dispatch_per_run: int = 3
# How far a dispatch chain may reach from the agent that started it. A dispatch at
# this depth is refused, so A -> B -> C -> A terminates instead of fanning out.
max_dispatch_depth: int = 3
# Wall-clock budget for the install-from-prompt one-off claude run (Claude page, # Wall-clock budget for the install-from-prompt one-off claude run (Claude page,
# Skills tab). Kept under worker_stale_after by default: the run blocks the worker's # Skills tab). Kept under worker_stale_after by default: the run blocks the worker's
# drain loop synchronously, and outliving the heartbeat window would get its live # drain loop synchronously, and outliving the heartbeat window would get its live
+36 -4
View File
@@ -16,8 +16,9 @@
* Stop-hook re-invoke; stop_hook_active guards the loop) * Stop-hook re-invoke; stop_hook_active guards the loop)
* - session_shutdown hooks session_end * - session_shutdown hooks session_end
* *
* It also registers the memory tools (memory_search/get/save/link) that claude agents * It also registers the memory tools (memory_search/get/save/link) and dispatch_agent
* reach over MCP, by shelling to `python -m handler.mcpserver --call <tool>` pi has * that claude agents reach over MCP, by shelling to
* `python -m handler.mcpserver --call <tool>` pi has
* no MCP by design, and a subprocess inheriting the agent env is the same trust model * no MCP by design, and a subprocess inheriting the agent env is the same trust model
* the MCP server used anyway plus web_search/web_fetch (`python -m handler.webtool`), * the MCP server used anyway plus web_search/web_fetch (`python -m handler.webtool`),
* because pi ships no web tools and claude's live server-side at Anthropic. * because pi ships no web tools and claude's live server-side at Anthropic.
@@ -71,7 +72,9 @@ function callPython(moduleArgs: string[], toolName: string, args: Record<string,
return (res.stdout || "").trim() || "{}"; return (res.stdout || "").trim() || "{}";
} }
function callMemory(tool: string, args: Record<string, unknown>): string { // The bundled MCP server's one-shot seam: same tool implementations claude reaches
// over MCP (memory + dispatch), same identity-from-environment contract.
function callMcpTool(tool: string, args: Record<string, unknown>): string {
return callPython(["-m", "handler.mcpserver", "--call", tool], tool, args); return callPython(["-m", "handler.mcpserver", "--call", tool], tool, args);
} }
@@ -258,12 +261,41 @@ export default function (pi: ExtensionAPI) {
pi.registerTool({ pi.registerTool({
...tool, ...tool,
async execute(_toolCallId: string, params: Record<string, unknown>) { async execute(_toolCallId: string, params: Record<string, unknown>) {
const text = callMemory(tool.name, params ?? {}); const text = callMcpTool(tool.name, params ?? {});
return { content: [{ type: "text", text }], details: {} }; return { content: [{ type: "text", text }], details: {} };
}, },
}); });
} }
// ---- dispatch (same MCP seam): hand work to a new agent in this project ------------
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch agent",
description:
"Hand work to a NEW agent in this project, which starts as soon as a worker is " +
"free. Use when your run produced something concrete for a different role to act " +
"on. This is a handoff, not a fan-out: dispatch once per thing you found, and only " +
"when there is real work — finding nothing is a complete run, so say so and end " +
"your turn instead. The new agent has NO memory of this session, so 'task' must " +
"stand on its own: what to do, which files or sources, and anything it would " +
"otherwise have to rediscover.",
parameters: Type.Object({
name_prefix: Type.String({ description: "Short slug; a timestamp is appended" }),
task: Type.String({ description: "The new agent's whole prompt — self-contained" }),
reason: Type.String({ description: "Why this handoff is warranted (for the operator)" }),
role: Type.Optional(
Type.String({ description: "scout | planner | junior | senior | deploy" }),
),
model_id: Type.Optional(Type.Integer({ description: "Model backend for the new agent" })),
worktree: Type.Optional(Type.String({ description: "Branch for a per-run worktree" })),
subdir: Type.Optional(Type.String({ description: "Subdir under the project root" })),
}),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const text = callMcpTool("dispatch_agent", params ?? {});
return { content: [{ type: "text", text }], details: {} };
},
});
// ---- web tools (handler.webtool — pi ships none, claude's are Anthropic-server-side) // ---- web tools (handler.webtool — pi ships none, claude's are Anthropic-server-side)
const webTools: Array<{ name: string; label: string; description: string; parameters: any }> = [ const webTools: Array<{ name: string; label: string; description: string; parameters: any }> = [
{ {
+21
View File
@@ -249,6 +249,12 @@ def _agent_env(
role = role or agent.get("role") role = role or agent.get("role")
if role: if role:
env["HANDLER_AGENT_ROLE"] = role env["HANDLER_AGENT_ROLE"] = role
depth = _dispatch_depth(project["id"], agent["name"])
if depth:
# Read by the dispatch tool: how many handoffs deep this agent already is, so a
# chain (and any cycle in it) runs into ``max_dispatch_depth``. Recovered from
# the spawn command rather than the agent row, so a resume keeps the same depth.
env["HANDLER_DISPATCH_DEPTH"] = str(depth)
if mise_init: if mise_init:
# Read by the Stop / git-push hooks to enforce the bootstrap contract. # Read by the Stop / git-push hooks to enforce the bootstrap contract.
env["HANDLER_MISE_INIT"] = "1" env["HANDLER_MISE_INIT"] = "1"
@@ -282,6 +288,21 @@ def _agent_env(
return env, harness return env, harness
def _dispatch_depth(project_id: str, agent_name: str) -> int:
"""How many dispatch handoffs led to this agent (0 for operator/schedule spawns).
Best-effort: a missing command row or a hand-rolled payload just means depth 0,
which is the same position an operator-started agent is in.
"""
with connection() as conn:
command = repo.get_spawn_command(conn, project_id, agent_name)
payload = (command or {}).get("payload") or {}
try:
return max(0, int(payload.get("dispatch_depth") or 0))
except (TypeError, ValueError):
return 0
def _check_forge_version(working_dir: str) -> str | None: def _check_forge_version(working_dir: str) -> str | None:
pin = get_settings().forge_version pin = get_settings().forge_version
if not pin: if not pin:
+40
View File
@@ -522,6 +522,46 @@ def get_command(conn: Connection, command_id: int) -> dict | None:
return _row_to_dict(row) return _row_to_dict(row)
def get_spawn_command(conn: Connection, project_id: str, agent_name: str) -> dict | None:
"""The most recent ``spawn`` command that created this agent, if any.
Lets a launch recover what it was spawned *with* (currently the dispatch depth)
without copying those fields onto the agent row and works identically on the
resume path, which has no payload of its own.
"""
row = conn.execute(
select(commands)
.where(
commands.c.type == "spawn",
commands.c.project_id == project_id,
commands.c.agent_name == agent_name,
)
.order_by(commands.c.id.desc())
.limit(1)
).first()
return _row_to_dict(row)
def count_agent_dispatches(conn: Connection, agent_id: int, since: datetime | None) -> int:
"""How many commands this agent has enqueued itself (optionally since a timestamp).
Dispatches are ordinary ``commands`` rows tagged ``requested_by = 'agent:<id>'``, so
the per-run budget needs no extra table it counts the audit trail it already wrote.
``since`` is the current run's ``started_at``; ``None`` counts the agent's whole
history, which is the conservative reading when no run row exists yet.
"""
from sqlalchemy import func as sqlfunc
stmt = (
select(sqlfunc.count())
.select_from(commands)
.where(commands.c.requested_by == f"agent:{agent_id}")
)
if since is not None:
stmt = stmt.where(commands.c.created_at >= since)
return int(conn.execute(stmt).scalar_one())
def list_commands( def list_commands(
conn: Connection, conn: Connection,
project_id: str | None = None, project_id: str | None = None,
+7 -3
View File
@@ -31,7 +31,9 @@ metadata = MetaData()
# ``crashed`` is reserved for the reaper: it marks an agent whose owning worker went # ``crashed`` is reserved for the reaper: it marks an agent whose owning worker went
# silent mid-run — never a normal exit, which reconciles to done/blocked instead. # silent mid-run — never a normal exit, which reconciles to done/blocked instead.
AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done", "crashed") AGENT_STATUSES = ("working", "paused_for_input", "blocked", "done", "crashed")
GATE_STATUSES = ("pass", "fail", "unknown") # "skipped" = the gate deliberately did not run because there was nothing to verify
# (a scout ending on a clean tree), which is neither "unknown" nor a "pass".
GATE_STATUSES = ("pass", "fail", "unknown", "skipped")
CI_STATUSES = ("not_applicable", "pending", "pass", "fail") CI_STATUSES = ("not_applicable", "pending", "pass", "fail")
VISIBILITIES = ("project", "global") VISIBILITIES = ("project", "global")
APPROVAL_STATUSES = ("approved", "rejected") APPROVAL_STATUSES = ("approved", "rejected")
@@ -142,8 +144,10 @@ agents = Table(
Column("name", String, nullable=False), # unique within a project, not globally Column("name", String, nullable=False), # unique within a project, not globally
Column("working_dir", String, nullable=False), Column("working_dir", String, nullable=False),
Column("status", String, nullable=False), Column("status", String, nullable=False),
# Optional workflow role (junior | senior | deploy) — informational, drives which # Optional workflow role (scout | planner | junior | senior | deploy) —
# forge skill an agent follows; the approval gate keys on identity, not role. # informational, drives which role skill an agent follows; the approval gate keys on
# identity, not role. ``scout`` additionally relaxes the Stop test gate on a clean
# tree (a run that shipped nothing has nothing to verify).
Column("role", String), Column("role", String),
# Which model backend (claude_models row) this agent runs on; null = the worker's # Which model backend (claude_models row) this agent runs on; null = the worker's
# logged-in Claude subscription. Recorded at spawn so resumes — a brand-new process — # logged-in Claude subscription. Recorded at spawn so resumes — a brand-new process —
+33 -3
View File
@@ -9,6 +9,12 @@ captured from the session transcript onto the checkmark, so the dashboard always
real checkpoint to show regardless of whether the agent thought to leave one. real checkpoint to show regardless of whether the agent thought to leave one.
``SessionEnd`` cannot be blocked, so it just records a final checkpoint with the end ``SessionEnd`` cannot be blocked, so it just records a final checkpoint with the end
reason. reason.
One narrow exemption: a ``scout`` that ends with a clean tree skips the test run and
records ``tests_status = 'skipped'``. Scouts exist to look and hand findings on, so the
gate's promise — ``done`` means a test run passed for the work that shipped — is vacuous
when nothing shipped, and a scheduled watch is mostly quiet runs. A dirty tree, or any
other role, keeps the full gate.
""" """
from __future__ import annotations from __future__ import annotations
@@ -112,6 +118,19 @@ def _completion_blockers(working_dir: str) -> list[str]:
return blockers return blockers
def _tests_are_moot(ident: Identity, working_dir: str) -> bool:
"""True when this stop needs no test run: a ``scout`` leaving a clean tree.
Scouts read the world and hand findings on; the expensive half of the gate exists to
stop unverified *changes* reaching ``done``. Running the suite on every quiet watch
run is pure overhead, and on a scheduled watch there are a lot of quiet runs. Any
other role, or any uncommitted change, keeps the full gate.
"""
if ident.role != "scout":
return False
return gitops.head_sha(working_dir) is None or gitops.is_clean(working_dir)
def _final_assistant_text(transcript_path: str | None) -> str | None: def _final_assistant_text(transcript_path: str | None) -> str | None:
"""The agent's last assistant message from the session transcript. """The agent's last assistant message from the session transcript.
@@ -155,15 +174,26 @@ def handle_stop(conn: Connection, ident: Identity, hook_input: HookInput) -> dic
if ident.mise_init: if ident.mise_init:
return handle_mise_init_stop(conn, ident, hook_input) return handle_mise_init_stop(conn, ident, hook_input)
working_dir = ident.working_dir or hook_input.cwd or "." working_dir = ident.working_dir or hook_input.cwd or "."
tests_ok, output = verify.run_test(working_dir) if _tests_are_moot(ident, working_dir):
# A scout that touched nothing has nothing to verify: the gate's promise is
# "``done`` means a test run passed for the work that shipped", and no work
# shipped. The clean-tree condition is what keeps that honest — a scout that
# *did* edit files falls through to the normal gate like anyone else.
tests_ok, output, tests_status = True, "", "skipped"
else:
tests_ok, output = verify.run_test(working_dir)
tests_status = "pass" if tests_ok else "fail"
blockers = [] if tests_ok else ["the test suite is failing (`mise run test`)"] blockers = [] if tests_ok else ["the test suite is failing (`mise run test`)"]
blockers += _completion_blockers(working_dir) blockers += _completion_blockers(working_dir)
now = datetime.now(UTC) now = datetime.now(UTC)
status = "done" if not blockers else "blocked" status = "done" if not blockers else "blocked"
tests_status = "pass" if tests_ok else "fail"
summary = ( summary = (
"checkpoint: tests passed, work committed and pushed" (
"checkpoint: nothing to ship, tests not applicable"
if tests_status == "skipped"
else "checkpoint: tests passed, work committed and pushed"
)
if not blockers if not blockers
else "checkpoint blocked: " + "; ".join(blockers) else "checkpoint blocked: " + "; ".join(blockers)
) )
+19 -2
View File
@@ -77,6 +77,9 @@ class Identity:
# git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the # git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the
# normal test gate. # normal test gate.
mise_init: bool = False mise_init: bool = False
# Workflow role (env ``HANDLER_AGENT_ROLE``, falling back to the agent row). The
# Stop gate reads it: a ``scout`` that changed nothing has no work to verify.
role: str | None = None
extra: dict = field(default_factory=dict) extra: dict = field(default_factory=dict)
@@ -93,10 +96,18 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
agent_name = os.environ.get("HANDLER_AGENT_NAME") agent_name = os.environ.get("HANDLER_AGENT_NAME")
mise_init = bool(os.environ.get("HANDLER_MISE_INIT")) mise_init = bool(os.environ.get("HANDLER_MISE_INIT"))
role = os.environ.get("HANDLER_AGENT_ROLE") or None
if agent_id and project_id and agent_name: if agent_id and project_id and agent_name:
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first() row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
working_dir = row._mapping["working_dir"] if row else None working_dir = row._mapping["working_dir"] if row else None
return Identity(int(agent_id), project_id, agent_name, working_dir, mise_init=mise_init) return Identity(
int(agent_id),
project_id,
agent_name,
working_dir,
mise_init=mise_init,
role=role or (row._mapping["role"] if row else None),
)
# Fallback: match by working_dir == cwd. # Fallback: match by working_dir == cwd.
if hook_input.cwd: if hook_input.cwd:
@@ -105,7 +116,13 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
).first() ).first()
if row is not None: if row is not None:
m = row._mapping m = row._mapping
return Identity(m["id"], m["project_id"], m["name"], m["working_dir"]) return Identity(
m["id"],
m["project_id"],
m["name"],
m["working_dir"],
role=role or m["role"],
)
return None return None
+170 -8
View File
@@ -9,14 +9,16 @@ It talks straight to the database — memory is rows, workers stay stateless.
Tools: ``memory_search`` (substring search over the agent's project + global notes; Tools: ``memory_search`` (substring search over the agent's project + global notes;
empty query = most recent), ``memory_get`` (one note with its links), ``memory_save`` empty query = most recent), ``memory_get`` (one note with its links), ``memory_save``
(create, or update with ``note_id``), ``memory_link`` (connect two notes, idempotent). (create, or update with ``note_id``), ``memory_link`` (connect two notes, idempotent),
``dispatch_agent`` (hand work to a fresh agent in this project the seam that lets a
pipeline advance on a *result* instead of on a timer).
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import sys import sys
from datetime import datetime from datetime import UTC, datetime
from typing import Any from typing import Any
PROTOCOL_VERSION = "2025-06-18" PROTOCOL_VERSION = "2025-06-18"
@@ -24,6 +26,10 @@ SERVER_INFO = {"name": "handler-memory", "version": "0.1.0"}
_NOTE_KINDS = ["fact", "decision", "gotcha", "runbook"] _NOTE_KINDS = ["fact", "decision", "gotcha", "runbook"]
# Roles a dispatch may target. Mirrors ``api.schemas.Role``; duplicated as plain data
# because this module is deliberately import-light (it runs as its own subprocess).
_ROLES = ["scout", "planner", "junior", "senior", "deploy"]
TOOLS: list[dict] = [ TOOLS: list[dict] = [
{ {
"name": "memory_search", "name": "memory_search",
@@ -94,6 +100,49 @@ TOOLS: list[dict] = [
"required": ["src_note_id", "dst_note_id"], "required": ["src_note_id", "dst_note_id"],
}, },
}, },
{
"name": "dispatch_agent",
"description": (
"Hand work to a NEW agent in this project, which starts as soon as a worker "
"is free. Use this when your run produced something concrete for a different "
"role to act on — findings that deserve a spec, a spec ready to implement. "
"This is a handoff, not a fan-out: dispatch once per thing you found, and "
"only when there is real work. Finding nothing is a complete, successful "
"run — say so and end your turn instead of dispatching. The new agent starts "
"with NO memory of this session, so 'task' must stand on its own: state what "
"to do, name the files or sources, and include anything it would otherwise "
"have to rediscover."
),
"inputSchema": {
"type": "object",
"properties": {
"name_prefix": {
"type": "string",
"description": "Short slug for the new agent; a timestamp is appended",
},
"task": {
"type": "string",
"description": "The new agent's whole prompt — self-contained, no context",
},
"reason": {
"type": "string",
"description": "Why this handoff is warranted; recorded for the operator",
},
"role": {
"type": "string",
"enum": _ROLES,
"description": "Role skill the new agent runs under",
},
"model_id": {
"type": "integer",
"description": "Model backend to pin the new agent to",
},
"worktree": {"type": "string", "description": "Branch for a per-run worktree"},
"subdir": {"type": "string", "description": "Subdir under the project root"},
},
"required": ["name_prefix", "task", "reason"],
},
},
] ]
@@ -112,9 +161,14 @@ class MemoryServer:
"""Tool dispatch against the handler database. One short connection per call — """Tool dispatch against the handler database. One short connection per call —
the process lives as long as the agent's session, but holds nothing in memory.""" the process lives as long as the agent's session, but holds nothing in memory."""
def __init__(self, agent_id: int | None, project_id: str | None): def __init__(
self, agent_id: int | None, project_id: str | None, dispatch_depth: int = 0
):
self.agent_id = agent_id self.agent_id = agent_id
self.project_id = project_id self.project_id = project_id
# How many dispatches deep this agent already is (0 = started by the operator or
# a schedule). Set from ``HANDLER_DISPATCH_DEPTH``, which spawn injects.
self.dispatch_depth = dispatch_depth
def _connection(self): def _connection(self):
from ..db.engine import connection from ..db.engine import connection
@@ -219,12 +273,105 @@ class MemoryServer:
) )
return {"link": {k: _iso(v) for k, v in link.items()}} return {"link": {k: _iso(v) for k, v in link.items()}}
# ---- dispatch ----
def _name_taken(self, conn, name: str) -> bool:
"""True if an agent (or an unfinished spawn) already claims this name.
Agent names are unique per project and the timestamp suffix has one-second
resolution, so two dispatches in the same second would otherwise collide the
second one only failing later, asynchronously, as a failed command.
"""
from ..db import repository as repo
if repo.get_agent_by_name(conn, self.project_id, name) is not None:
return True
recent = repo.list_commands(conn, project_id=self.project_id, limit=200)
return any(
c.get("type") == "spawn"
and c.get("agent_name") == name
and c.get("status") in ("queued", "running")
for c in recent
)
def dispatch_agent(self, args: dict) -> dict:
from ..config import get_settings
from ..db import repository as repo
if self.agent_id is None or not self.project_id:
raise ValueError(
"dispatch_agent needs an agent identity, and this process has none"
)
prefix = (args.get("name_prefix") or "").strip().strip("-")
task = (args.get("task") or "").strip()
reason = (args.get("reason") or "").strip()
if not prefix or not task or not reason:
raise ValueError("name_prefix, task and reason are all required")
role = args.get("role")
if role is not None and role not in _ROLES:
raise ValueError(f"role must be one of {_ROLES}")
settings = get_settings()
# Depth is counted from the agent that started the chain, so a cycle
# (A dispatches B dispatches C dispatches A) runs out of budget instead of
# fanning out forever.
depth = self.dispatch_depth + 1
if depth > settings.max_dispatch_depth:
raise ValueError(
f"dispatch refused: this chain is already {self.dispatch_depth} handoffs "
f"deep (limit {settings.max_dispatch_depth}). Finish the work in this "
"run, or leave it for the operator."
)
with self._connection() as conn:
run = repo.get_latest_run(conn, self.agent_id)
spent = repo.count_agent_dispatches(
conn, self.agent_id, run["started_at"] if run else None
)
if spent >= settings.max_dispatch_per_run:
raise ValueError(
f"dispatch refused: {spent} already dispatched in this run (limit "
f"{settings.max_dispatch_per_run}). Fold the rest into one handoff."
)
stamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
candidates = [f"{prefix}-{stamp}"] + [f"{prefix}-{stamp}-{n}" for n in range(2, 12)]
name = next((c for c in candidates if not self._name_taken(conn, c)), None)
if name is None:
raise ValueError(f"no free agent name for prefix '{prefix}' — try another")
payload: dict = {
"task": task,
"reason": reason,
"dispatch_depth": depth,
"parent_agent_id": self.agent_id,
}
for key in ("role", "model_id", "worktree", "subdir"):
if args.get(key):
payload[key] = args[key]
# project_id comes from the environment, never from the arguments: an agent
# can only ever dispatch inside its own project.
command = repo.enqueue_command(
conn,
"spawn",
project_id=self.project_id,
agent_name=name,
payload=payload,
requested_by=f"agent:{self.agent_id}",
)
return {
"dispatched": True,
"command_id": command["id"],
"agent_name": name,
"project_id": self.project_id,
"dispatch_depth": depth,
}
def call_tool(self, name: str, args: dict) -> dict: def call_tool(self, name: str, args: dict) -> dict:
handlers = { handlers = {
"memory_search": self.memory_search, "memory_search": self.memory_search,
"memory_get": self.memory_get, "memory_get": self.memory_get,
"memory_save": self.memory_save, "memory_save": self.memory_save,
"memory_link": self.memory_link, "memory_link": self.memory_link,
"dispatch_agent": self.dispatch_agent,
} }
if name not in handlers: if name not in handlers:
raise ValueError(f"unknown tool '{name}'") raise ValueError(f"unknown tool '{name}'")
@@ -277,17 +424,32 @@ def _error(msg_id: Any, code: int, message: str) -> dict:
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}} return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
def serve(stdin=None, stdout=None) -> int: def server_from_env() -> MemoryServer:
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately.""" """Build the server from the spawn environment — the one identity contract.
Shared by the stdio loop and the ``--call`` seam (the pi bridge) so both agree on
who the agent is, which project it may touch, and how deep its dispatch chain runs.
"""
import os import os
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
agent_id_raw = os.environ.get("HANDLER_AGENT_ID") agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
server = MemoryServer( depth_raw = os.environ.get("HANDLER_DISPATCH_DEPTH") or "0"
try:
depth = max(0, int(depth_raw))
except ValueError:
depth = 0
return MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None, agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None, project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
dispatch_depth=depth,
) )
def serve(stdin=None, stdout=None) -> int:
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately."""
stdin = stdin or sys.stdin
stdout = stdout or sys.stdout
server = server_from_env()
for line in stdin: for line in stdin:
line = line.strip() line = line.strip()
if not line: if not line:
+3 -8
View File
@@ -1,6 +1,6 @@
"""``python -m handler.mcpserver`` — run the bundled handler-memory MCP server. """``python -m handler.mcpserver`` — run the bundled handler-memory MCP server.
``--call <tool>`` runs one memory tool directly instead: JSON arguments on stdin, JSON ``--call <tool>`` runs one tool directly instead: JSON arguments on stdin, JSON
result on stdout, exit 1 with the error on stderr for a failed call. This is the seam result on stdout, exit 1 with the error on stderr for a failed call. This is the seam
the pi harness's bridge extension uses (pi has no MCP by design), sharing the exact the pi harness's bridge extension uses (pi has no MCP by design), sharing the exact
tool implementations and the same identity-from-environment contract the MCP tool implementations and the same identity-from-environment contract the MCP
@@ -10,10 +10,9 @@ server dispatches to.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import sys import sys
from . import MemoryServer, serve from . import serve, server_from_env
def call_tool(name: str, stdin=None, stdout=None) -> int: def call_tool(name: str, stdin=None, stdout=None) -> int:
@@ -25,11 +24,7 @@ def call_tool(name: str, stdin=None, stdout=None) -> int:
except ValueError: except ValueError:
print("invalid JSON arguments on stdin", file=sys.stderr) print("invalid JSON arguments on stdin", file=sys.stderr)
return 2 return 2
agent_id_raw = os.environ.get("HANDLER_AGENT_ID") server = server_from_env()
server = MemoryServer(
agent_id=int(agent_id_raw) if agent_id_raw else None,
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
)
try: try:
payload = server.call_tool(name, args if isinstance(args, dict) else {}) payload = server.call_tool(name, args if isinstance(args, dict) else {})
except Exception as exc: # noqa: BLE001 - the caller renders this as a tool error except Exception as exc: # noqa: BLE001 - the caller renders this as a tool error
@@ -0,0 +1,52 @@
"""checkmark gates gain a 'skipped' verdict
Revision ID: 0017_gate_skipped
Revises: 0016_user_accounts
Create Date: 2026-08-19
The Stop gate can now decline to run the suite when there is provably nothing to
verify a ``scout`` role ending on a clean tree, whose whole job is to look and hand
findings on. That verdict is not ``unknown`` (we didn't look) and certainly not
``pass`` (nothing ran), so the checkmark needs a third word for it. Widening the CHECK
is additive: every existing row already holds one of the three old values.
Both gate columns move together they share one vocabulary, and a future gate that
can be moot for the same reason should not need another migration.
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0017_gate_skipped"
down_revision: str | None = "0016_user_accounts"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
OLD_GATE_STATUSES = "'pass', 'fail', 'unknown'"
NEW_GATE_STATUSES = OLD_GATE_STATUSES + ", 'skipped'"
# (constraint name, column) — batch_alter_table so SQLite recreates the table while
# Postgres alters in place, the same shape migration 0004 used to widen command types.
_GATES = (("ck_checkmarks_tests", "tests_status"), ("ck_checkmarks_build", "build_status"))
def _rewrite(values: str) -> None:
with op.batch_alter_table("checkmarks", schema=None) as batch_op:
for name, column in _GATES:
batch_op.drop_constraint(name, type_="check")
batch_op.create_check_constraint(name, f"{column} IN ({values})")
def upgrade() -> None:
_rewrite(NEW_GATE_STATUSES)
def downgrade() -> None:
# Any row parked on the new verdict has to land somewhere the old CHECK accepts;
# 'unknown' is the honest reading of "no verdict was recorded".
for _, column in _GATES:
op.execute(f"UPDATE checkmarks SET {column} = 'unknown' WHERE {column} = 'skipped'")
_rewrite(OLD_GATE_STATUSES)
+235
View File
@@ -0,0 +1,235 @@
"""Agent-initiated dispatch: the ``dispatch_agent`` MCP tool, its guardrails, and the
depth that spawn recovers for the agents it launches.
A dispatch is an ordinary queued ``spawn`` command attributed to the agent that asked
for it, so these tests assert on the command queue the same rows Activity renders.
"""
from __future__ import annotations
import json
import pytest
from handler.db import repository as repo
from handler.db.engine import get_engine
@pytest.fixture
def dispatcher(env):
"""A project with one agent that has a live run (the per-run budget's boundary)."""
with get_engine().begin() as conn:
repo.create_project(conn, "proj", "/tmp/proj")
repo.create_project(conn, "other", "/tmp/other")
agent = repo.create_agent(conn, "proj", "scout-1", "/tmp/proj", "working")
run = repo.create_run(conn, agent["id"], "s1", "w1", "spawn")
return {"agent": agent, "run": run}
_DEFAULT = object()
def _server(dispatcher, *, depth=0, project_id="proj", agent_id=_DEFAULT):
from handler.mcpserver import MemoryServer
return MemoryServer(
agent_id=dispatcher["agent"]["id"] if agent_id is _DEFAULT else agent_id,
project_id=project_id,
dispatch_depth=depth,
)
def _call(server, name, args):
from handler.mcpserver import handle_message
resp = handle_message(
server,
{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": name, "arguments": args}},
)
result = resp["result"]
text = result["content"][0]["text"]
return result["isError"], (text if result["isError"] else json.loads(text))
_TASK = {"name_prefix": "planner", "task": "Read @specs/x.md and build it", "reason": "new paper"}
# --- the happy path ----------------------------------------------------------------------
def test_dispatch_enqueues_an_attributed_spawn(dispatcher):
err, out = _call(_server(dispatcher), "dispatch_agent", {**_TASK, "role": "planner"})
assert not err, out
assert out["dispatched"] is True
assert out["agent_name"].startswith("planner-")
with get_engine().begin() as conn:
command = repo.get_command(conn, out["command_id"])
assert command["type"] == "spawn"
assert command["status"] == "queued"
assert command["project_id"] == "proj"
assert command["agent_name"] == out["agent_name"]
# Attribution is what makes a dispatch legible in Activity — and what the per-run
# budget counts.
assert command["requested_by"] == f"agent:{dispatcher['agent']['id']}"
payload = command["payload"]
assert payload["task"] == _TASK["task"]
assert payload["reason"] == _TASK["reason"]
assert payload["role"] == "planner"
assert payload["dispatch_depth"] == 1
assert payload["parent_agent_id"] == dispatcher["agent"]["id"]
def test_dispatch_is_listed_and_optional_fields_pass_through(dispatcher):
from handler.mcpserver import TOOLS
assert "dispatch_agent" in {t["name"] for t in TOOLS}
err, out = _call(
_server(dispatcher),
"dispatch_agent",
{**_TASK, "model_id": 7, "worktree": "feature/x", "subdir": "svc"},
)
assert not err, out
with get_engine().begin() as conn:
payload = repo.get_command(conn, out["command_id"])["payload"]
assert payload["model_id"] == 7
assert payload["worktree"] == "feature/x"
assert payload["subdir"] == "svc"
# Untouched optional fields stay absent rather than arriving as nulls the worker
# would have to special-case.
assert "role" not in payload
def test_repeat_dispatches_get_distinct_names(dispatcher):
"""Names are timestamped to the second; a burst must not collide."""
server = _server(dispatcher)
names = set()
for _ in range(3):
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
names.add(out["agent_name"])
assert len(names) == 3
# --- guardrails --------------------------------------------------------------------------
def test_project_comes_from_the_environment_not_the_arguments(dispatcher):
"""Project isolation holds by construction: a foreign project_id is simply ignored."""
err, out = _call(_server(dispatcher), "dispatch_agent", {**_TASK, "project_id": "other"})
assert not err, out
with get_engine().begin() as conn:
assert repo.get_command(conn, out["command_id"])["project_id"] == "proj"
def test_per_run_budget_refuses_the_next_dispatch(dispatcher, monkeypatch):
monkeypatch.setenv("MAX_DISPATCH_PER_RUN", "2")
from handler import config
config.get_settings.cache_clear()
server = _server(dispatcher)
for _ in range(2):
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
err, msg = _call(server, "dispatch_agent", _TASK)
assert err
assert "dispatch refused" in msg and "limit 2" in msg
with get_engine().begin() as conn:
queued = [c for c in repo.list_commands(conn, project_id="proj") if c["type"] == "spawn"]
assert len(queued) == 2
config.get_settings.cache_clear()
def test_budget_counts_only_the_current_run(dispatcher, monkeypatch):
"""A dispatch from an earlier run doesn't spend this run's allowance."""
monkeypatch.setenv("MAX_DISPATCH_PER_RUN", "1")
from handler import config
config.get_settings.cache_clear()
server = _server(dispatcher)
err, _ = _call(server, "dispatch_agent", _TASK)
assert not err
err, _ = _call(server, "dispatch_agent", _TASK)
assert err # budget spent for this run
# A new run resets the window.
with get_engine().begin() as conn:
repo.finish_run(conn, dispatcher["run"]["id"], "completed", exit_code=0)
repo.create_run(conn, dispatcher["agent"]["id"], "s2", "w1", "resume")
err, out = _call(server, "dispatch_agent", _TASK)
assert not err, out
config.get_settings.cache_clear()
def test_depth_cap_refuses_a_long_chain(dispatcher, monkeypatch):
monkeypatch.setenv("MAX_DISPATCH_DEPTH", "3")
from handler import config
config.get_settings.cache_clear()
# Depth 2 may still hand off (the child lands at 3, the cap itself).
err, out = _call(_server(dispatcher, depth=2), "dispatch_agent", _TASK)
assert not err, out
assert out["dispatch_depth"] == 3
# Depth 3 is the end of the chain: a cycle terminates here instead of fanning out.
err, msg = _call(_server(dispatcher, depth=3), "dispatch_agent", _TASK)
assert err
assert "dispatch refused" in msg and "deep" in msg
config.get_settings.cache_clear()
@pytest.mark.parametrize(
"args",
[
{**_TASK, "name_prefix": " "},
{**_TASK, "task": ""},
{**_TASK, "reason": ""},
{**_TASK, "role": "architect"},
],
)
def test_bad_arguments_are_refused(dispatcher, args):
err, msg = _call(_server(dispatcher), "dispatch_agent", args)
assert err
assert "error:" in msg
def test_dispatch_needs_an_identity(dispatcher):
"""A process with no agent identity (a stray CLI call) cannot enqueue work."""
err, msg = _call(_server(dispatcher, agent_id=None), "dispatch_agent", _TASK)
assert err
assert "identity" in msg
# --- what the dispatched agent inherits --------------------------------------------------
def test_spawn_recovers_dispatch_depth_for_the_child(dispatcher):
"""The child's depth is read back off the command that created it — so it survives
a resume, which carries no payload of its own."""
from handler.control import spawn
err, out = _call(_server(dispatcher, depth=1), "dispatch_agent", _TASK)
assert not err, out
assert spawn._dispatch_depth("proj", out["agent_name"]) == 2
# An operator- or schedule-started agent is at the root of its own chain.
assert spawn._dispatch_depth("proj", "scout-1") == 0
def test_dispatch_reaches_the_call_seam(dispatcher, monkeypatch, capsys):
"""``--call`` is how pi-harness agents dispatch; it shares the tool implementation."""
import io
from handler.mcpserver.__main__ import call_tool
monkeypatch.setenv("HANDLER_AGENT_ID", str(dispatcher["agent"]["id"]))
monkeypatch.setenv("HANDLER_PROJECT_ID", "proj")
monkeypatch.setenv("HANDLER_DISPATCH_DEPTH", "1")
out = io.StringIO()
rc = call_tool("dispatch_agent", stdin=io.StringIO(json.dumps(_TASK)), stdout=out)
assert rc == 0
payload = json.loads(out.getvalue())
assert payload["dispatched"] is True
# The env-carried depth is what the seam uses, same as the MCP path.
assert payload["dispatch_depth"] == 2
+45 -3
View File
@@ -2,16 +2,18 @@
from __future__ import annotations from __future__ import annotations
import pytest
from handler.control import gitops, mise from handler.control import gitops, mise
from handler.db import repository as repo from handler.db import repository as repo
from handler.hooks import checkpoint, verify from handler.hooks import checkpoint, verify
from handler.hooks.context import HookInput, Identity from handler.hooks.context import HookInput, Identity
def _seed(conn, mise_init=False): def _seed(conn, mise_init=False, role=None):
repo.create_project(conn, "p", "/tmp/p") repo.create_project(conn, "p", "/tmp/p")
a = repo.create_agent(conn, "p", "a", "/tmp/p/a") a = repo.create_agent(conn, "p", "a", "/tmp/p/a", role=role)
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init) return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init, role=role)
def _fake_mise_state(monkeypatch, *, has_test, clean, ahead): def _fake_mise_state(monkeypatch, *, has_test, clean, ahead):
@@ -118,6 +120,46 @@ def test_stop_allows_done_when_working_dir_is_not_a_repo(conn, monkeypatch):
assert repo.get_checkmark(conn, ident.agent_id)["status"] == "done" assert repo.get_checkmark(conn, ident.agent_id)["status"] == "done"
def test_stop_skips_tests_for_a_scout_that_shipped_nothing(conn, monkeypatch):
"""A watch run that found nothing has no work to verify — and most runs are that."""
ident = _seed(conn, role="scout")
monkeypatch.setattr(
verify, "run_test", lambda cwd: pytest.fail("the suite must not run here")
)
_fake_git_state(monkeypatch, clean=True)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result == {}
cm = repo.get_checkmark(conn, ident.agent_id)
assert cm["tests_status"] == "skipped"
assert cm["status"] == "done"
def test_stop_still_gates_a_scout_that_changed_files(conn, monkeypatch):
"""The exemption is clean-tree-only: a scout that edited something is gated."""
ident = _seed(conn, role="scout")
monkeypatch.setattr(verify, "run_test", lambda cwd: (False, "1 failed"))
_fake_git_state(monkeypatch, clean=False)
result = checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop"))
assert result["decision"] == "block"
assert "test suite is failing" in result["reason"]
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "fail"
def test_stop_gates_other_roles_on_a_clean_tree(conn, monkeypatch):
"""Only scouts are exempt — a junior with nothing to commit still runs the suite."""
ident = _seed(conn, role="junior")
ran = []
monkeypatch.setattr(verify, "run_test", lambda cwd: (ran.append(cwd), (True, "ok"))[1])
_fake_git_state(monkeypatch, clean=True)
assert checkpoint.handle_stop(conn, ident, HookInput({"session_id": "s1"}, "stop")) == {}
assert ran, "the suite must run for a non-scout role"
assert repo.get_checkmark(conn, ident.agent_id)["tests_status"] == "pass"
def test_stop_captures_final_message_as_checkpoint(conn, monkeypatch, tmp_path): def test_stop_captures_final_message_as_checkpoint(conn, monkeypatch, tmp_path):
ident = _seed(conn) ident = _seed(conn)
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok")) monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
+3 -1
View File
@@ -181,8 +181,10 @@ def test_mcp_protocol_basics(seeded):
assert handle_message(server, {"jsonrpc": "2.0", "method": "notifications/initialized"}) is None assert handle_message(server, {"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
assert _rpc(server, "resources/list")["error"]["code"] == -32601 assert _rpc(server, "resources/list")["error"]["code"] == -32601
tools = _rpc(server, "tools/list")["result"]["tools"] tools = _rpc(server, "tools/list")["result"]["tools"]
# The bundled server's whole surface: the memory tools plus dispatch (tested in
# tests/test_dispatch.py), which shares this transport and identity contract.
assert {t["name"] for t in tools} == { assert {t["name"] for t in tools} == {
"memory_search", "memory_get", "memory_save", "memory_link", "memory_search", "memory_get", "memory_save", "memory_link", "dispatch_agent",
} }
+2
View File
@@ -111,6 +111,8 @@ def test_write_config_renders_provider_and_bridge(pi_env, tmp_path):
assert "setActiveTools" in text assert "setActiveTools" in text
assert "handler.webtool" in text assert "handler.webtool" in text
assert "web_search" in text and "web_fetch" in text assert "web_search" in text and "web_fetch" in text
# Dispatch parity: a pi agent can hand work on through the same MCP seam.
assert "dispatch_agent" in text
assert (base / "APPEND_SYSTEM.md").read_text().strip() assert (base / "APPEND_SYSTEM.md").read_text().strip()