Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
title: Interrupt an agent with the message that corrects it
type: feature
authors:
- mavam
created: 2026-10-21T15:38:54.055782Z
---

Pi and agents can now interrupt a working agent and send it the correcting message in one step. `agent_send` takes a `mode`: `steer` (the default) joins the agent's current work, `followUp` waits until that work ends, and `interrupt` stops the work first, like Esc in the attach view. An interrupt drops the messages queued for the agent and reports how many. It keeps the agent's job and the agents it spawned.

```json
{ "name": "fixer", "message": "Stop patching the parser; the bug is in the lexer.", "mode": "interrupt" }
```

Agents can interrupt any agent they can message, and stopped agents still refuse both. No other agent's message lands between an interrupt and its message. An agent's send that resumes after a crash never interrupts the work its message already started. The interrupt and its message aren't one atomic step: a crash in between, or a sender that is itself interrupted meanwhile, can leave the recipient interrupted without the message.

`mode` replaces the `followUp` flag of `agent_send`; calls that still set `followUp: true` keep queuing as before.
14 changes: 13 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ blocked wait, so a child asking its waiting spawner for clarification can't
make progress through that exchange until the wait ends. Asynchronous
results avoid this limitation; waits aren't a general coordination loop.

A message can interrupt its recipient first, so a correction doesn't wait
for work it makes pointless. The interrupt belongs to the send, not to a
separate authority: whoever may message an agent may interrupt it, and
stopped agents refuse both. The message is recorded together with the
interrupt, after the abort, so a send that resumes after a crash knows
whether it already interrupted and never aborts the work its own message
started. The abort waits for the recipient's work to end, so a sender
that is itself interrupted meanwhile gives up instead of waiting; two
agents interrupting each other can't deadlock, at the price that the
recipient may stay interrupted without the message.

Messages between agents are visible to the user but don't start parent
turns. Parent results and peer messages serve different audiences; see
[messaging](./messaging.md) for delivery guarantees and limitations.
Expand All @@ -161,7 +172,8 @@ reaches its agent one way, never both.

Interrupting an agent means correcting its work, not withdrawing the job.
The parent's request, or the agent's job in its graph, stays open for a
later answer. Stopping the agent withdraws it.
later answer. Stopping the agent withdraws it. Pi can interrupt with the
correcting message in one send, which keeps that answer open the same way.

Pi's session and agent storage are separate, so result delivery is
**at least once**: a crash can repeat a result, but doesn't lose it.
Expand Down
29 changes: 24 additions & 5 deletions docs/messaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ jobs, including finished agents but not stopped ones. It can use
`agent_send` to message one of them by name or ID without waiting. An ID
addresses that agent even if another agent has since taken its name, but
doesn't grant access outside the sender's reach. A message steers a working
recipient unless queued as a follow-up; the recipient's answer stays with it.
recipient by default. With `mode: "followUp"`, it waits until the current
work ends; with `mode: "interrupt"`, it first interrupts that work, like Esc
in the attach view. The recipient's answer stays with it.

Pi's agents reach each other. An agent that another agent spawned reaches
that agent and the others spawned with it, and every agent reaches the
Expand Down Expand Up @@ -65,19 +67,28 @@ appear in red. A session keeps its latest 1,000 messages.
a model makes in parallel within one step have no order among themselves.
- **Timing.** A message steers a working recipient: it joins the current work
at the recipient's next step, like your own messages. With `followUp`, it
waits until the current work ends. An idle recipient starts working.
waits until the current work ends. With `interrupt`, the recipient's
current work stops first and the message starts its next. An idle
recipient starts working.
- **Interrupts correct, not end.** An interrupt drops the messages queued
for the recipient and keeps its job and the agents it spawned, as your
interrupt does. No other agent's message lands between the interrupt and
its message, but input from you or Pi can. A send resumed after a crash
never interrupts the work its message started; one that crashed before
its message was recorded interrupts again, then sends.
- **No delivery of its own.** A message produces nothing that reaches the
sender or Pi by itself; agents that want an answer ask the recipient to
message them. A steer joins the recipient's current work, though, so it can
shape the answer to whatever the recipient is doing, including the answer
to its job that Pi receives. Use `followUp` to keep that answer as it is.
to its job that Pi receives, and so can an interrupt. Use `followUp` to
keep that answer as it is.
- **Stops are final.** An agent you or Pi stopped refuses messages from
agents until you or Pi message it again. A send and a stop of the same
agent never interleave: the send lands first and the stop interrupts it,
or the send is refused.
- **Visible outcome.** Each message is queued, delivered once it entered
the recipient's conversation, or dropped, shown in red, when you
interrupted or stopped the recipient while it was still queued. Delivered
the recipient's conversation, or dropped, shown in red, when an interrupt
or stop of the recipient came while it was still queued. Delivered
means it arrived, not that the recipient handled it: a turn that fails
after a message arrived is the recipient's state, not the message's.

Expand All @@ -93,5 +104,13 @@ appear in red. A session keeps its latest 1,000 messages.
agents outside their reach.
- There are no send budgets or hop limits. Agents told to keep talking do
so until you stop one.
- An interrupting send returns once the recipient's work stopped. A tool
that ignores cancellation holds it up, and other sends to that recipient
with it.
- An interrupt and its message aren't one atomic step. If the sender's own
call ends in between, for example because its agent was interrupted or two
agents interrupted each other, the recipient stays interrupted without
the message. Like after your interrupt, results owed to it still wait
for its next answer.
- Exactly-once delivery doesn't guarantee exactly-once side effects; see
[recovery behavior](./architecture.md#durability).
12 changes: 10 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Ask Pi to start agents: [see the example prompts](../README.md#-usage).
| --- | --- |
| Agent | A separate Pi agent with its own name, model, working directory, and conversation. It keeps its conversation after it answers. |
| Job | The instructions an agent starts with. They must stand on their own, because the agent doesn't see your conversation. |
| Message | Any later input to an agent. A message to a working agent *steers* it; a *follow-up* waits until the current answer is done. |
| Message | Any later input to an agent. A message to a working agent *steers* it; a *follow-up* waits until the current answer is done; an *interrupt* stops the current work first. |
| Result | The agent's final message after its job or a message. |
| Group | One spawn's runtime-owned set of agents, with one result for whoever spawned it. A group of one looks like its agent, but has a separate group ID. |
| Graph | The `after` dependencies within a group: some agents run in parallel, others start after their inputs finish. |
Expand Down Expand Up @@ -159,6 +159,14 @@ Stop the agent instead if Pi should get nothing. The same holds for a
graph's agent, whose job in the graph stays open until it answers again,
and for an agent's own agents, which keep working after an interrupt.

Pi and agents can do the same in one step: `agent_send` with
`mode: "interrupt"` interrupts a working agent and sends the message that
corrects it. Its queued messages are dropped, and the result says how many.
If the message can't be sent, for example to an agent that still waits for
the agents its job needs, nothing is interrupted. A crash between the
interrupt and Pi's message can leave the agent interrupted without it;
message it again.

Open `/agents` to attach to finished or stopped agents, including agents
from completed graphs. Their conversations remain available after you
resume a session.
Expand All @@ -182,7 +190,7 @@ Pi uses these tools to work with agents:
| Tool | Purpose |
| --- | --- |
| `agent_spawn` | Start one group of 1 to 12 agents, optionally with `after` dependencies or permission to spawn. |
| `agent_send` | Message an agent: prompt, steer, or queue a follow-up. |
| `agent_send` | Message an agent: prompt, steer, queue a follow-up, or interrupt its work first. |
| `agent_wait` | Block until agents or groups answer and return their results. |
| `agent_status` | Show agent and group states. |
| `agent_stop` | Stop an agent or a group. |
Expand Down
41 changes: 35 additions & 6 deletions src/agents/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* its stops.
*/

import type { Context } from "@earendil-works/chord";
import { awaitWithContext } from "@earendil-works/chord/context";
import type { AgentState, TargetRef } from "./types.js";

export class MessagingHub {
Expand All @@ -20,6 +22,14 @@ export class MessagingHub {
throw new Error("Agents are closing");
};

/** Abort an agent's work and record the interrupt's watermark, whoever
* asked for it, under the agent's turn that the caller holds; the
* service sets it. Not bound to the caller's call, so the watermark
* follows every abort, also when that call is cancelled meanwhile. */
interrupt: (agentId: string) => Promise<void> = async () => {
throw new Error("Agents are closing");
};

/** Take an idle turn without waiting behind work that may need the
* caller to finish, such as result presentation during a refresh. */
async tryTurn(
Expand All @@ -36,21 +46,40 @@ export class MessagingHub {
return true;
}

/** Run `work` once the recipient's earlier turns ended. */
async turn<T>(agentId: string, work: () => Promise<T>): Promise<T> {
/**
* Run `work` once the recipient's earlier turns ended. Cancelling
* `context` while it waits gives up the turn without running `work`, so
* a caller whose own work is being aborted never holds up the abort;
* later turns still wait for the earlier ones. `work` can `hold` the
* turn for work it stops waiting for, which then ends the turn.
*/
async turn<T>(
agentId: string,
work: (hold: (pending: Promise<unknown>) => void) => Promise<T>,
context?: Context,
): Promise<T> {
// Stored turns never reject.
const previous = this.turns.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => {}).then(work);
const settled = run.catch(() => {});
const held: Promise<unknown>[] = [];
const ready = context ? awaitWithContext(previous, context) : previous;
const run = ready.then(() => work((pending) => held.push(pending)));
// A turn ends once its work, what it held, and the turns before it
// ended, also when its caller gave up waiting.
const ended = Promise.allSettled([previous, run]).then(() =>
Promise.allSettled(held),
);
const settled = ended.then(() => {});
this.turns.set(agentId, settled);
try {
return await run;
} finally {
if (this.turns.get(agentId) === settled) {
void ended.then(() => {
if (this.turns.get(agentId) !== settled) return;
this.turns.delete(agentId);
const retry = this.retries.get(agentId);
this.retries.delete(agentId);
retry?.();
}
});
}
}
}
Loading
Loading