mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-05 23:36:25 +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.
|
# 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
|
||||||
|
|||||||
@@ -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.**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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.
|
||||||
""",
|
""",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }> = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
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"))
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user