mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-31 08:46:23 +00:00
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:
@@ -77,6 +77,12 @@ PROJECTS_ROOT=/var/lib/handler/projects
|
||||
# Closes the "merge locally, push to main" path around the forge-merge approval gate.
|
||||
# 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
|
||||
# headless, API-only deployment. Applied at process start (restart to change).
|
||||
# UI_ENABLED=true
|
||||
|
||||
@@ -6,6 +6,43 @@ the image workflows publish (plus `latest` from every push to `main`).
|
||||
|
||||
## [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
|
||||
|
||||
- **The project-root checkout is now a ref store, never a working tree Handler moves.**
|
||||
|
||||
@@ -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` |
|
||||
| `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` |
|
||||
| `MAX_DISPATCH_PER_RUN` / `MAX_DISPATCH_DEPTH` | Caps on agent-initiated dispatch: handoffs per run, and how far a chain reaches | `3` / `3` |
|
||||
|
||||
## 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
|
||||
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
|
||||
|
||||
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
|
||||
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`,
|
||||
`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`.
|
||||
@@ -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`,
|
||||
`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
|
||||
|
||||
```bash
|
||||
|
||||
@@ -22,6 +22,8 @@ import type { Schedule } from "../api/client";
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: "", label: "Role — none" },
|
||||
{ value: "scout", label: "scout" },
|
||||
{ value: "planner", label: "planner" },
|
||||
{ value: "junior", label: "junior" },
|
||||
{ value: "senior", label: "senior" },
|
||||
{ value: "deploy", label: "deploy" },
|
||||
|
||||
@@ -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).
|
||||
|
||||
### 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)
|
||||
- [ ] 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
|
||||
|
||||
@@ -9,6 +9,8 @@ import { fmtFull, timeAgo } from "@/lib/format";
|
||||
|
||||
const ROLE_OPTS = [
|
||||
{ value: "", label: "Role — none" },
|
||||
{ value: "scout", label: "scout" },
|
||||
{ value: "planner", label: "planner" },
|
||||
{ value: "junior", label: "junior" },
|
||||
{ value: "senior", label: "senior" },
|
||||
{ value: "deploy", label: "deploy" },
|
||||
|
||||
@@ -10,6 +10,8 @@ import { fmtFull } from "@/lib/format";
|
||||
|
||||
const ROLE_OPTS = [
|
||||
{ value: "", label: "Role — none" },
|
||||
{ value: "scout", label: "scout" },
|
||||
{ value: "planner", label: "planner" },
|
||||
{ value: "junior", label: "junior" },
|
||||
{ value: "senior", label: "senior" },
|
||||
{ value: "deploy", label: "deploy" },
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Literal
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
# 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"]
|
||||
|
||||
# Agent harness a model backend launches (claude_models.harness).
|
||||
|
||||
@@ -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
|
||||
keeps it). Stop, leave an open question naming the credential so the operator can
|
||||
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.
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -107,6 +107,16 @@ class Settings(BaseSettings):
|
||||
# Comma-separated permission allow rules added to generated settings for headless runs.
|
||||
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,
|
||||
# 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
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* Stop-hook re-invoke; stop_hook_active guards the loop)
|
||||
* - session_shutdown → hooks session_end
|
||||
*
|
||||
* It also registers the memory tools (memory_search/get/save/link) that claude agents
|
||||
* reach over MCP, by shelling to `python -m handler.mcpserver --call <tool>` — pi has
|
||||
* It also registers the memory tools (memory_search/get/save/link) and dispatch_agent
|
||||
* 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
|
||||
* 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.
|
||||
@@ -71,7 +72,9 @@ function callPython(moduleArgs: string[], toolName: string, args: Record<string,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -258,12 +261,41 @@ export default function (pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
...tool,
|
||||
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: {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 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)
|
||||
const webTools: Array<{ name: string; label: string; description: string; parameters: any }> = [
|
||||
{
|
||||
|
||||
@@ -249,6 +249,12 @@ def _agent_env(
|
||||
role = role or agent.get("role")
|
||||
if 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:
|
||||
# Read by the Stop / git-push hooks to enforce the bootstrap contract.
|
||||
env["HANDLER_MISE_INIT"] = "1"
|
||||
@@ -282,6 +288,21 @@ def _agent_env(
|
||||
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:
|
||||
pin = get_settings().forge_version
|
||||
if not pin:
|
||||
|
||||
@@ -522,6 +522,46 @@ def get_command(conn: Connection, command_id: int) -> dict | None:
|
||||
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(
|
||||
conn: Connection,
|
||||
project_id: str | None = None,
|
||||
|
||||
@@ -31,7 +31,9 @@ metadata = MetaData()
|
||||
# ``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.
|
||||
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")
|
||||
VISIBILITIES = ("project", "global")
|
||||
APPROVAL_STATUSES = ("approved", "rejected")
|
||||
@@ -142,8 +144,10 @@ agents = Table(
|
||||
Column("name", String, nullable=False), # unique within a project, not globally
|
||||
Column("working_dir", String, nullable=False),
|
||||
Column("status", String, nullable=False),
|
||||
# Optional workflow role (junior | senior | deploy) — informational, drives which
|
||||
# forge skill an agent follows; the approval gate keys on identity, not role.
|
||||
# Optional workflow role (scout | planner | junior | senior | deploy) —
|
||||
# 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),
|
||||
# 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 —
|
||||
|
||||
@@ -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.
|
||||
``SessionEnd`` cannot be blocked, so it just records a final checkpoint with the end
|
||||
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
|
||||
@@ -112,6 +118,19 @@ def _completion_blockers(working_dir: str) -> list[str]:
|
||||
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:
|
||||
"""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:
|
||||
return handle_mise_init_stop(conn, ident, hook_input)
|
||||
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 += _completion_blockers(working_dir)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
status = "done" if not blockers else "blocked"
|
||||
tests_status = "pass" if tests_ok else "fail"
|
||||
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
|
||||
else "checkpoint blocked: " + "; ".join(blockers)
|
||||
)
|
||||
|
||||
@@ -77,6 +77,9 @@ class Identity:
|
||||
# git-push hooks enforce the "write .mise.toml, commit, push" contract rather than the
|
||||
# normal test gate.
|
||||
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)
|
||||
|
||||
|
||||
@@ -93,10 +96,18 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
|
||||
agent_name = os.environ.get("HANDLER_AGENT_NAME")
|
||||
|
||||
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:
|
||||
row = conn.execute(select(agents).where(agents.c.id == int(agent_id))).first()
|
||||
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.
|
||||
if hook_input.cwd:
|
||||
@@ -105,7 +116,13 @@ def resolve_identity(conn: Connection, hook_input: HookInput) -> Identity | None
|
||||
).first()
|
||||
if row is not None:
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
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
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
PROTOCOL_VERSION = "2025-06-18"
|
||||
@@ -24,6 +26,10 @@ SERVER_INFO = {"name": "handler-memory", "version": "0.1.0"}
|
||||
|
||||
_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] = [
|
||||
{
|
||||
"name": "memory_search",
|
||||
@@ -94,6 +100,49 @@ TOOLS: list[dict] = [
|
||||
"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 —
|
||||
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.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):
|
||||
from ..db.engine import connection
|
||||
@@ -219,12 +273,105 @@ class MemoryServer:
|
||||
)
|
||||
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:
|
||||
handlers = {
|
||||
"memory_search": self.memory_search,
|
||||
"memory_get": self.memory_get,
|
||||
"memory_save": self.memory_save,
|
||||
"memory_link": self.memory_link,
|
||||
"dispatch_agent": self.dispatch_agent,
|
||||
}
|
||||
if name not in handlers:
|
||||
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}}
|
||||
|
||||
|
||||
def serve(stdin=None, stdout=None) -> int:
|
||||
"""The stdio loop: one JSON-RPC message per line, responses flushed immediately."""
|
||||
def server_from_env() -> MemoryServer:
|
||||
"""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
|
||||
|
||||
stdin = stdin or sys.stdin
|
||||
stdout = stdout or sys.stdout
|
||||
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,
|
||||
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:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""``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
|
||||
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
|
||||
@@ -10,10 +10,9 @@ server dispatches to.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import MemoryServer, serve
|
||||
from . import serve, server_from_env
|
||||
|
||||
|
||||
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:
|
||||
print("invalid JSON arguments on stdin", file=sys.stderr)
|
||||
return 2
|
||||
agent_id_raw = os.environ.get("HANDLER_AGENT_ID")
|
||||
server = MemoryServer(
|
||||
agent_id=int(agent_id_raw) if agent_id_raw else None,
|
||||
project_id=os.environ.get("HANDLER_PROJECT_ID") or None,
|
||||
)
|
||||
server = server_from_env()
|
||||
try:
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from handler.control import gitops, mise
|
||||
from handler.db import repository as repo
|
||||
from handler.hooks import checkpoint, verify
|
||||
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")
|
||||
a = repo.create_agent(conn, "p", "a", "/tmp/p/a")
|
||||
return Identity(a["id"], "p", "a", "/tmp/p/a", mise_init=mise_init)
|
||||
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, role=role)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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):
|
||||
ident = _seed(conn)
|
||||
monkeypatch.setattr(verify, "run_test", lambda cwd: (True, "ok"))
|
||||
|
||||
@@ -181,8 +181,10 @@ def test_mcp_protocol_basics(seeded):
|
||||
assert handle_message(server, {"jsonrpc": "2.0", "method": "notifications/initialized"}) is None
|
||||
assert _rpc(server, "resources/list")["error"]["code"] == -32601
|
||||
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} == {
|
||||
"memory_search", "memory_get", "memory_save", "memory_link",
|
||||
"memory_search", "memory_get", "memory_save", "memory_link", "dispatch_agent",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ def test_write_config_renders_provider_and_bridge(pi_env, tmp_path):
|
||||
assert "setActiveTools" in text
|
||||
assert "handler.webtool" 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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user