feat(agent-loop): give each send its own turn

This commit is contained in:
pku-xht
2026-07-17 17:14:52 +08:00
parent 97c5ca940d
commit aa4e629874
26 changed files with 372 additions and 209 deletions

View File

@@ -39,7 +39,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.send(content, options?)` — queue one independent FIFO item. Unless broad cancellation or disposal clears it before turn start, that item becomes the sole ordinary message in its turn; the next item waits for the preceding turn's durability checkpoint. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.

View File

@@ -59,7 +59,7 @@ export interface HookContext {
/**
* Prompt interception result. `allow.content` replaces the prompt and
* `additionalContext` becomes a separate context message. `block` records a
* durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn.
* durable `prompt/blocked` and ends that prompt's zero-step turn as rejected.
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
@@ -90,7 +90,7 @@ export interface Agent {
readonly ctx: Context
/**
* Queue detached, frozen lossless-JSON input; starts a turn when idle.
* Queue one detached, frozen lossless-JSON item; if claimed, it is the sole ordinary message in a FIFO-ordered turn.
* Invalid input throws synchronously before notification or enqueue.
*/
send(content: ContentBlock[], options?: SendOptions): void
@@ -111,8 +111,8 @@ export interface Agent {
inject(content: ContentBlock[], options?: SendOptions): void
/**
* Clear queued and steering work, including work waiting to start, and abort
* the active step. The supplied reason is preserved across pre-step and active
* Clear all queued and steering work, including items waiting to start, and
* abort the active step. The supplied reason is preserved across pre-step and active
* cancellation windows, and `whenIdle()` resolves after cancellation reaches
* quiescence. Idle cancellation is a no-op and does not arm a later cancel.
*/