refactor(agent): unify agent-scoped event signatures as payload objects

All agent/* and agent-loop/config-start-failed events take one payload
object carrying the agent subject; waterfall/serial payloads require a
signal and keep next as the final argument. PreStepContext and
RequestFailureContext are unfolded into payloads and retired.
goal/changed follows the same shape so agentEvents keeps its listener
error containment. ReactLoopAgent builds its scope carrier once in the
constructor. Regenerates scope resolvers, tool-cordis api catalog, and
docs catalogs; updates all affected listeners, tests, and the
core-data-structures docs (en + zh).
This commit is contained in:
_Kerman
2026-08-06 12:13:14 +08:00
parent bb53e25ed0
commit ccebba2349
91 changed files with 574 additions and 618 deletions

View File

@@ -111,7 +111,7 @@ export async function runHeadless(task: string): Promise<void> {
const abort = new AbortController() const abort = new AbortController()
const frames = api.events.mux({}, abort.signal) const frames = api.events.mux({}, abort.signal)
const idle = new Promise<void>((resolve) => { const idle = new Promise<void>((resolve) => {
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve() if (agent.id === created.sessionId && status === 'idle') resolve()
}) })
}) })

View File

@@ -24,16 +24,16 @@ A fully configured agent and live session were published. Setup is composition-o
* Synchronous listener failure vetoes publication, while returned-promise * Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every * rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry. * creation listener has observed the stable entry.
* @param agent - the newly registered agent with its live session and completed setup. * @param payload.agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/created'(this: Scoped<Agent>, agent: Agent): void 'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:178`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit ### `agent/disposed` — emit
@@ -44,16 +44,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
* An agent left the registry; AgentLoop emits this after driver quiescence * An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom * and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract. * registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry. * @param payload.agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void 'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:187`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit ### `agent/error` — emit
@@ -63,19 +63,19 @@ A step or turn errored. The machine reports a failure here even when the error h
/** /**
* A step or turn errored. The machine reports a failure here even when * A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record. * the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored. * @param payload.agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced. * @param payload.turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced. * @param payload.step - the step at which the failure surfaced.
* @param error - the failure, verbatim. * @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void 'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts)
### `agent/inbox/claimed` — emit ### `agent/inbox/claimed` — emit
@@ -86,17 +86,18 @@ One message left the inbox inside its open turn. If the proposed step is rejecte
* One message left the inbox inside its open turn. If the proposed step * One message left the inbox inside its open turn. If the proposed step
* is rejected, the claimed message ends here: it is neither discarded nor * is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step. * re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn. * @param payload.message - the claimed message.
* @param payload.turn - the owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void 'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:215`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts)
### `agent/inbox/discarded` — emit ### `agent/inbox/discarded` — emit
@@ -105,17 +106,17 @@ One message was discarded from the live inbox.
```ts cordis-catalog ```ts cordis-catalog
/** /**
* One message was discarded from the live inbox. * One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the discarded message. * @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:223`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts)
### `agent/inbox/inserted` — emit ### `agent/inbox/inserted` — emit
@@ -124,17 +125,17 @@ One message entered the live inbox.
```ts cordis-catalog ```ts cordis-catalog
/** /**
* One message entered the live inbox. * One message entered the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the inserted message. * @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/inserted'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:205`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts)
### `agent/pre-step` — waterfall ### `agent/pre-step` — waterfall
@@ -144,18 +145,20 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p
/** /**
* Reject a proposed step or replace the messages that enter it. Calling * Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages. * `next()` preserves the current messages.
* @param agent - the agent proposing the step. * @param payload.agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step. * @param payload.messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation. * @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> 'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
``` ```
Types: [Agent](../core-data-structures/core.md) · [PreStepContext](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall ### `agent/request` — waterfall
@@ -167,19 +170,19 @@ Replace the frozen call configuration. `await next()` yields the config the mach
* the machine would use (agent options on the first request, the logged * the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible * header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages. * content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call. * @param payload.agent - the agent making the model call.
* @param turn - the open turn number. * @param payload.turn - the open turn number.
* @param step - the step whose request this is. * @param payload.step - the step whose request this is.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig> 'agent/request'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
``` ```
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall ### `agent/request-error` — waterfall
@@ -191,18 +194,22 @@ Handle one failed model-request attempt before the loop retries or closes its st
* its step. A listener returns `{ kind: 'retry' }` without calling `next()` * its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default * when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal. * `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed. * @param payload.agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy. * @param payload.turn - the turn containing the failed request.
* @param signal - the turn abort signal. * @param payload.step - the step containing the failed request attempt.
* @param payload.provider - the provider selected for the failed request.
* @param payload.failure - serializable facts normalized at the final adapter boundary.
* @param payload.retryPolicy - the policy of the adapter registration that served the failed request.
* @param payload.signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> 'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
``` ```
Types: [Agent](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [RequestFailureContext](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit ### `agent/session-start` — emit
@@ -214,17 +221,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
* `agent.inject()` to seed model-facing context. This is a notification, not * `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the * a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts. * driver starts.
* @param agent - the agent whose session lifecycle began. * @param payload.agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …). * @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void 'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit ### `agent/status` — emit
@@ -235,17 +242,17 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running`
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters * Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no * `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active. * driver remains scheduled or active.
* @param agent - the agent whose status flipped. * @param payload.agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination). * @param payload.status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void 'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:197`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts)
### `agent/turn-stopping` — serial ### `agent/turn-stopping` — serial
@@ -263,18 +270,18 @@ The turn is about to close: the model owes no response (no live tool calls, no f
* never short-circuits already-submitted next-step work: same-step * never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn * `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains. * closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary. * @param payload.agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close. * @param payload.turn - the turn about to close.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial * @mode serial
*/ */
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void 'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void
``` ```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:290`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts)
## `agent-loop/*` ## `agent-loop/*`
@@ -288,11 +295,11 @@ A declarative agent entry failed before it could publish a live agent. Consumers
* Consumers that buffer work for the configured identity use this * Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal * transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt. * factory teardown suppresses failures from the cancelled startup attempt.
* @param sessionId - exact shared agent/session identity that failed startup. * @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure. * @param payload.error - persistence, setup, or publication failure.
* @mode emit * @mode emit
*/ */
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
``` ```
Types: [SessionId](../core-data-structures/core.md) Types: [SessionId](../core-data-structures/core.md)
@@ -456,11 +463,11 @@ Goal mutation accepted by one live agent. The matching `goal/change` session eve
* Goal mutation accepted by one live agent. The matching `goal/change` * Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained. * session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal. * @param payload.agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone. * @param payload.change - fresh current projection or clear tombstone.
* @mode emit * @mode emit
*/ */
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
``` ```
Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md) Types: [Agent](../core-data-structures/core.md) · [GoalChanged](../core-data-structures/goal.md) · [Scoped](../core-data-structures/scope.md)

View File

@@ -607,19 +607,7 @@ Pre-step decisions use the same identified `UserMessage` shape as durable user-r
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
`agent/pre-step` receives the exclusive claimed batch and the proposed step's coordinates and cancellation signal. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: `agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps:
```ts type-equiv
/** Coordinates and cancellation for a proposed step. */
interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
```
It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending:

View File

@@ -615,19 +615,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag
源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
`agent/pre-step` 接收独占的已领取批次,以及拟进入步骤的坐标与取消 signal。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: `agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次:
```ts type-equiv
/** Coordinates and cancellation for a proposed step. */
interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
```
它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理:

View File

@@ -84,13 +84,13 @@ export function apply(ctx: Context): void {
// runs, so the queued FIFO order is what the transcript records. The first // runs, so the queued FIFO order is what the transcript records. The first
// child enqueue is the initial delegation, which also pins the real child id. // child enqueue is the initial delegation, which also pins the real child id.
let accepted = 0 let accepted = 0
ctx.on('agent/inbox/inserted', (agent) => { ctx.on('agent/inbox/inserted', ({ agent }) => {
if (agent.session.header.parentSession === undefined) return if (agent.session.header.parentSession === undefined) return
if (realChildId === undefined) realChildId = agent.session.header.id if (realChildId === undefined) realChildId = agent.session.header.id
accepted += 1 accepted += 1
if (accepted >= 3) followupsAccepted.resolve(undefined) if (accepted >= 3) followupsAccepted.resolve(undefined)
}) })
ctx.on('agent/pre-step', async (agent, _messages, _context, next) => { ctx.on('agent/pre-step', async ({ agent }, next) => {
if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise if (agent.session.header.parentSession !== undefined) await followupsAccepted.promise
return next() return next()
}) })

View File

@@ -302,7 +302,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => {
function waitForIdle(harness: Context, agent: Agent): Promise<void> { function waitForIdle(harness: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = harness.on('agent/status', (subject, status) => { const dispose = harness.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -59,7 +59,7 @@ export const inject = ['llm']
/** Register the keyless `cli-mock` adapter. */ /** Register the keyless `cli-mock` adapter. */
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter())
ctx.on('agent/request', async (_agent, _turn, step, _signal, next) => { ctx.on('agent/request', async ({ step }, next) => {
const config = await next() const config = await next()
return step === 2 ? { ...config, reasoningEffort: OFF } : config return step === 2 ? { ...config, reasoningEffort: OFF } : config
}) })

View File

@@ -7,7 +7,7 @@ export const name = 'seed-goal'
export const inject = ['goals'] export const inject = ['goals']
export function apply(ctx: Context): void { export function apply(ctx: Context): void {
ctx.on('agent/pre-step', (agent, _messages, _context, next) => { ctx.on('agent/pre-step', ({ agent }, next) => {
if (ctx.goals.get(agent) === undefined) { if (ctx.goals.get(agent) === undefined) {
ctx.goals.create(agent, { ctx.goals.create(agent, {
objective: 'Prove the composed goal survives in the session log', objective: 'Prove the composed goal survives in the session log',

View File

@@ -84,7 +84,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -184,13 +184,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
} }
}) })
ctx.on('agent/inbox/claimed', (agent, { message, turn }) => { ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => {
const record = ownedRecord(agent) const record = ownedRecord(agent)
const inflight = record?.inflight const inflight = record?.inflight
if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn
}) })
ctx.on('agent/error', (agent, turn, _step, error) => { ctx.on('agent/error', ({ agent, turn, error }) => {
const record = ownedRecord(agent) const record = ownedRecord(agent)
const inflight = record?.inflight const inflight = record?.inflight
if (record === undefined || inflight === undefined || inflight.turn === turn) return if (record === undefined || inflight === undefined || inflight.turn === turn) return

View File

@@ -87,7 +87,7 @@ describe('ACP prompt lifecycle', () => {
const sessionId = await newSession(harness) const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))! const agent = harness.ctx.agents.get(SessionId(sessionId))!
let injected = false let injected = false
harness.ctx.on('agent/inbox/inserted', (subject, { message }) => { harness.ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
if (subject === agent && message.source.kind === 'user' && !injected) { if (subject === agent && message.source.kind === 'user' && !injected) {
injected = true injected = true
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } })) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }))
@@ -235,7 +235,7 @@ describe('ACP prompt lifecycle', () => {
harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] }) harness = await makeBridgeHarness({ script: [errorResponse('transient boom'), textResponse('recovered')] })
// A recovery policy: schedule one retry for the failed request. // A recovery policy: schedule one retry for the failed request.
let retried = false let retried = false
harness.ctx.on('agent/request-error', async (_subject) => { harness.ctx.on('agent/request-error', async () => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } return { kind: 'retry' }
@@ -272,7 +272,7 @@ describe('ACP prompt lifecycle', () => {
it('cancels a prompt removed before its turn claims it', async () => { it('cancels a prompt removed before its turn claims it', async () => {
harness = await makeBridgeHarness({ script: [] }) harness = await makeBridgeHarness({ script: [] })
const sessionId = await newSession(harness) const sessionId = await newSession(harness)
const dispose = harness.ctx.on('agent/inbox/inserted', (agent, { message }) => { const dispose = harness.ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (message.source.kind === 'user') agent.inbox.remove(message.id) if (message.source.kind === 'user') agent.inbox.remove(message.id)
}) })

View File

@@ -48,7 +48,7 @@ afterEach(() => {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -144,9 +144,7 @@ export class BasicCompactService extends CompactService {
} }
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, signal },
_messages,
{ signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
if (!signal.aborted) { if (!signal.aborted) {
@@ -165,7 +163,7 @@ export class BasicCompactService extends CompactService {
return next() return next()
}) })
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (status === 'idle') this.overflowRetries.delete(agent) if (status === 'idle') this.overflowRetries.delete(agent)
}) })
@@ -178,12 +176,9 @@ export class BasicCompactService extends CompactService {
}) })
ctx.on('agent/request-error', async ( ctx.on('agent/request-error', async (
agent, { agent, failure, signal },
context,
signal,
next, next,
) => { ) => {
const { failure } = context
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
this.overflowAgents.set(agent.session, agent) this.overflowAgents.set(agent.session, agent)
const target = routedTarget(agent.session) const target = routedTarget(agent.session)

View File

@@ -1372,7 +1372,7 @@ describe('default one-shot summarizer', () => {
describe('automatic listener and loader composition', () => { describe('automatic listener and loader composition', () => {
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) { function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
return agentEvents(ctx, owner).waterfall( return agentEvents(ctx, owner).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal }, 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
} }
@@ -1388,8 +1388,7 @@ describe('automatic listener and loader composition', () => {
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1 const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
return agentEvents(ctx, owner).waterfall( return agentEvents(ctx, owner).waterfall(
'agent/request-error', 'agent/request-error',
{ turn, step: 1, provider: 'test', failure, retryPolicy: undefined }, { turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
signal,
next, next,
).then(action => action?.kind === 'retry') ).then(action => action?.kind === 'retry')
} }

View File

@@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -217,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] {
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => { describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => { it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8) const { ctx } = await harness(8)
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ctx.on('agent/request', async (_payload, next) => ({
...await next(), provider: 'mock', model: 'mock', ...await next(), provider: 'mock', model: 'mock',
})) }))
try { try {
@@ -315,7 +315,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService) await ctx.plugin(TokenMeterService)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ctx.on('agent/request', async (_payload, next) => ({
...await next(), provider: 'mock', model: 'mock', ...await next(), provider: 'mock', model: 'mock',
})) }))
await ctx.plugin(BasicCompactService, { await ctx.plugin(BasicCompactService, {

View File

@@ -157,9 +157,7 @@ export function apply(ctx: Context, config: Config): void {
const resolvedTimeZone = formatter.resolvedOptions().timeZone const resolvedTimeZone = formatter.resolvedOptions().timeZone
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, turn, step, signal },
_messages,
{ turn, step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -82,8 +82,7 @@ async function fire(
): Promise<void> { ): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn, step, signal },
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {
@@ -366,7 +365,7 @@ describe('real agent-loop request history', () => {
] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => { ] as const)('does not commit a preparation reading when a downstream pre-step listener %s', async (mode) => {
const adapter = new ScriptedAdapter([textResponse('unused')]) const adapter = new ScriptedAdapter([textResponse('unused')])
const ctx = await loopHarness(adapter) const ctx = await loopHarness(adapter)
ctx.on('agent/pre-step', (subject, _messages, _context, next) => { ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (mode === 'throws') throw new Error('later pre-step failure') if (mode === 'throws') throw new Error('later pre-step failure')
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
return next() return next()

View File

@@ -216,9 +216,7 @@ export function apply(ctx: Context, config: Config): void {
validateRefreshInterval(refreshIntervalMs) validateRefreshInterval(refreshIntervalMs)
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, turn, step, signal },
_messages,
{ turn, step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -138,8 +138,7 @@ async function fire(
): Promise<void> { ): Promise<void> {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn, step, signal },
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -212,9 +212,7 @@ export function apply(ctx: Context, config: Config): void {
} }
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, messages, step, signal },
messages,
{ step, signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -57,7 +57,7 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -209,8 +209,7 @@ async function workspaceContextOf(agent: Agent): Promise<UserMessage> {
async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> { async function syncWorkspaceContext(ctx: Context, agent: Agent): Promise<void> {
await agentEvents(ctx, agent).waterfall( await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: testToolSignal },
{ turn: 1, step: 1, signal: testToolSignal },
async () => ({ kind: 'enter' as const, messages: [] }), async () => ({ kind: 'enter' as const, messages: [] }),
) )
} }
@@ -245,15 +244,13 @@ async function composeBaselinePrefix(ctx: Context, agent: Agent): Promise<Messag
const signal = AbortSignal.timeout(1000) const signal = AbortSignal.timeout(1000)
await agentEvents(ctx, agent).waterfall( await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal },
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const claimed = agent.inbox.claim('next-step', 1) const claimed = agent.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 2, signal },
{ turn: 1, step: 2, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
const entered = decision.kind === 'enter' ? decision.messages : [] const entered = decision.kind === 'enter' ? decision.messages : []
@@ -968,8 +965,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(ctx, original).waterfall( await agentEvents(ctx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const inserted = original.inbox.nextStep[0] const inserted = original.inbox.nextStep[0]
@@ -978,12 +974,11 @@ describe('workspace context request injection', () => {
await fiber.dispose() await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events]) const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume') agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
const claimed = resumed.inbox.claim('next-step', 1) const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(ctx, resumed).waterfall( const decision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected') if (decision.kind !== 'enter') throw new Error('recovered baseline was rejected')
@@ -1015,8 +1010,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(ctx, original).waterfall( await agentEvents(ctx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const stale = original.inbox.nextStep[0] const stale = original.inbox.nextStep[0]
@@ -1026,12 +1020,11 @@ describe('workspace context request injection', () => {
await fiber.dispose() await fiber.dispose()
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const resumed = stubAgent(root, [...original.session.events]) const resumed = stubAgent(root, [...original.session.events])
agentEvents(ctx, resumed).emit('agent/session-start', 'resume') agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
const staleClaim = resumed.inbox.claim('next-step', 1) const staleClaim = resumed.inbox.claim('next-step', 1)
const staleDecision = await agentEvents(ctx, resumed).waterfall( const staleDecision = await agentEvents(ctx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
staleClaim, { messages: staleClaim, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }), () => Promise.resolve({ kind: 'enter' as const, messages: staleClaim }),
) )
@@ -1070,8 +1063,7 @@ describe('workspace context request injection', () => {
const original = stubAgent(root) const original = stubAgent(root)
await agentEvents(originalCtx, original).waterfall( await agentEvents(originalCtx, original).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
const stale = original.inbox.nextStep[0] const stale = original.inbox.nextStep[0]
@@ -1081,12 +1073,11 @@ describe('workspace context request injection', () => {
if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' }) if (provideFs) await resumedCtx.plugin(LocalFileSystem, { cwd: '/' })
await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes }) await resumedCtx.plugin(workspaceContext, { dshHome: home, maxBytes })
const resumed = stubAgent(root, [...original.session.events]) const resumed = stubAgent(root, [...original.session.events])
agentEvents(resumedCtx, resumed).emit('agent/session-start', 'resume') agentEvents(resumedCtx, resumed).emit('agent/session-start', { source: 'resume' })
const claimed = resumed.inbox.claim('next-step', 1) const claimed = resumed.inbox.claim('next-step', 1)
const decision = await agentEvents(resumedCtx, resumed).waterfall( const decision = await agentEvents(resumedCtx, resumed).waterfall(
'agent/pre-step', 'agent/pre-step',
claimed, { messages: claimed, turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve({ kind: 'enter' as const, messages: claimed }), () => Promise.resolve({ kind: 'enter' as const, messages: claimed }),
) )
@@ -1188,8 +1179,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[prompt], { messages: [prompt], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream), () => Promise.resolve(downstream),
) )
@@ -1246,8 +1236,7 @@ describe('workspace context request injection', () => {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
{ turn: 1, step: 1, signal: AbortSignal.timeout(1000) },
() => Promise.resolve(downstream), () => Promise.resolve(downstream),
) )
@@ -1353,7 +1342,7 @@ describe('workspace context request injection', () => {
const resumed = stubAgent(root, [...original.session.events]) const resumed = stubAgent(root, [...original.session.events])
// Resume announces its lifecycle start before the first step. // Resume announces its lifecycle start before the first step.
agentEvents(ctx, resumed).emit('agent/session-start', 'resume') agentEvents(ctx, resumed).emit('agent/session-start', { source: 'resume' })
await composeBaselinePrefix(ctx, resumed) await composeBaselinePrefix(ctx, resumed)
const baselines = baselineEvents(resumed) const baselines = baselineEvents(resumed)
@@ -1401,7 +1390,7 @@ describe('workspace context request injection', () => {
await write(join(root, 'AGENTS.md'), 'repo rule') await write(join(root, 'AGENTS.md'), 'repo rule')
const ctx = new Context() const ctx = new Context()
await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) await mountWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next() const decision = await next()
if (decision.kind === 'reject') return decision if (decision.kind === 'reject') return decision
return { return {
@@ -1675,8 +1664,7 @@ describe('workspace context request injection', () => {
const reason = new Error('cancel prefix') const reason = new Error('cancel prefix')
const pending = agentEvents(ctx, stubAgent(root)).waterfall( const pending = agentEvents(ctx, stubAgent(root)).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal: controller.signal },
{ turn: 1, step: 1, signal: controller.signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
@@ -3860,8 +3848,7 @@ describe('workspace context inbox synchronization', () => {
controller.abort(new Error('abort pre-step reconciliation')) controller.abort(new Error('abort pre-step reconciliation'))
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], 'agent/pre-step', { messages: [], turn: 1, step: 1, signal: controller.signal },
{ turn: 1, step: 1, signal: controller.signal },
async () => ({ kind: 'enter' as const, messages: [] }), async () => ({ kind: 'enter' as const, messages: [] }),
)).rejects.toThrow('abort pre-step reconciliation') )).rejects.toThrow('abort pre-step reconciliation')
@@ -3971,8 +3958,7 @@ describe('workspace context inbox synchronization', () => {
const downstream = { kind: 'enter' as const, messages: claimed } const downstream = { kind: 'enter' as const, messages: claimed }
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', claimed, 'agent/pre-step', { messages: claimed, turn: 1, step: 1, signal: testToolSignal },
{ turn: 1, step: 1, signal: testToolSignal },
async () => downstream, async () => downstream,
) )

View File

@@ -1217,92 +1217,92 @@ export const EVENT_API: readonly EventApiEntry[] = [
{ {
name: 'agent-loop/config-start-failed', name: 'agent-loop/config-start-failed',
mode: 'emit', mode: 'emit',
signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', signature: '\'agent-loop/config-start-failed\'(payload: { sessionId: SessionId; error: unknown }): void',
jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param sessionId - exact shared agent/session identity that failed startup.\n * @param error - persistence, setup, or publication failure.\n * @mode emit\n */', jsDoc: '/**\n * A declarative agent entry failed before it could publish a live agent.\n * Consumers that buffer work for the configured identity use this\n * transient signal to reject that work instead of waiting forever. Normal\n * factory teardown suppresses failures from the cancelled startup attempt.\n * @param payload.sessionId - exact shared agent/session identity that failed startup.\n * @param payload.error - persistence, setup, or publication failure.\n * @mode emit\n */',
summary: 'A declarative agent entry failed before it could publish a live agent.', summary: 'A declarative agent entry failed before it could publish a live agent.',
}, },
{ {
name: 'agent/created', name: 'agent/created',
mode: 'emit', mode: 'emit',
signature: '\'agent/created\'(this: Scoped<Agent>, agent: Agent): void', signature: '\'agent/created\'(this: Scoped<Agent>, payload: { agent: Agent }): void',
jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * A fully configured agent and live session were published. Setup is\n * composition-only; `agent/session-start` is the first startup-driving seam.\n * Synchronous listener failure vetoes publication, while returned-promise\n * rejection is reported. Detach requested during dispatch waits until every\n * creation listener has observed the stable entry.\n * @param payload.agent - the newly registered agent with its live session and completed setup.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A fully configured agent and live session were published.', summary: 'A fully configured agent and live session were published.',
}, },
{ {
name: 'agent/disposed', name: 'agent/disposed',
mode: 'emit', mode: 'emit',
signature: '\'agent/disposed\'(this: Scoped<Agent>, agent: Agent): void', signature: '\'agent/disposed\'(this: Scoped<Agent>, payload: { agent: Agent }): void',
jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * An agent left the registry; AgentLoop emits this after driver quiescence\n * and scoped-registration unwind, but before session detachment. Custom\n * registry users own their driver-ordering contract.\n * @param payload.agent - the exact agent removed from the registry.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.', summary: 'An agent left the registry; AgentLoop emits this after driver quiescence and scoped-registration unwind, but before session detachment.',
}, },
{ {
name: 'agent/error', name: 'agent/error',
mode: 'emit', mode: 'emit',
signature: '\'agent/error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void', signature: '\'agent/error\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void',
jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param agent - the agent whose turn errored.\n * @param turn - the turn in which the failure surfaced.\n * @param step - the step at which the failure surfaced.\n * @param error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * A step or turn errored. The machine reports a failure here even when\n * the error has no in-turn position for a durable record.\n * @param payload.agent - the agent whose turn errored.\n * @param payload.turn - the turn in which the failure surfaced.\n * @param payload.step - the step at which the failure surfaced.\n * @param payload.error - the failure, verbatim.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'A step or turn errored.', summary: 'A step or turn errored.',
}, },
{ {
name: 'agent/inbox/claimed', name: 'agent/inbox/claimed',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void', signature: '\'agent/inbox/claimed\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void',
jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param agent - the agent whose inbox changed.\n * @param event - the claimed message and owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * One message left the inbox inside its open turn. If the proposed step\n * is rejected, the claimed message ends here: it is neither discarded nor\n * re-emitted as a user/message, and the turn closes without a step.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the claimed message.\n * @param payload.turn - the owning turn.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message left the inbox inside its open turn.', summary: 'One message left the inbox inside its open turn.',
}, },
{ {
name: 'agent/inbox/discarded', name: 'agent/inbox/discarded',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', signature: '\'agent/inbox/discarded\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void',
jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * One message was discarded from the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the discarded message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message was discarded from the live inbox.', summary: 'One message was discarded from the live inbox.',
}, },
{ {
name: 'agent/inbox/inserted', name: 'agent/inbox/inserted',
mode: 'emit', mode: 'emit',
signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void', signature: '\'agent/inbox/inserted\'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void',
jsDoc: '/**\n * One message entered the live inbox.\n * @param agent - the agent whose inbox changed.\n * @param event - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * One message entered the live inbox.\n * @param payload.agent - the agent whose inbox changed.\n * @param payload.message - the inserted message.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One message entered the live inbox.', summary: 'One message entered the live inbox.',
}, },
{ {
name: 'agent/pre-step', name: 'agent/pre-step',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/pre-step\'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>', signature: '\'agent/pre-step\'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>',
jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param agent - the agent proposing the step.\n * @param messages - messages removed from the inbox for this step.\n * @param context - proposed turn and step coordinates plus cancellation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', jsDoc: '/**\n * Reject a proposed step or replace the messages that enter it. Calling\n * `next()` preserves the current messages.\n * @param payload.agent - the agent proposing the step.\n * @param payload.messages - messages removed from the inbox for this step.\n * @param payload.turn - the turn that will own the step.\n * @param payload.step - the step proposed by the loop.\n * @param payload.signal - the current turn\'s cancellation signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Reject a proposed step or replace the messages that enter it.', summary: 'Reject a proposed step or replace the messages that enter it.',
}, },
{ {
name: 'agent/request', name: 'agent/request',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>', signature: '\'agent/request\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>',
jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param agent - the agent making the model call.\n * @param turn - the open turn number.\n * @param step - the step whose request this is.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/', jsDoc: '/**\n * Replace the frozen call configuration. `await next()` yields the config\n * the machine would use (agent options on the first request, the logged\n * header afterwards); return a replacement to switch. Model-visible\n * content must use logged channels; this seam cannot mutate messages.\n * @param payload.agent - the agent making the model call.\n * @param payload.turn - the open turn number.\n * @param payload.step - the step whose request this is.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n*/',
summary: 'Replace the frozen call configuration.', summary: 'Replace the frozen call configuration.',
}, },
{ {
name: 'agent/request-error', name: 'agent/request-error',
mode: 'waterfall', mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>', signature: '\'agent/request-error\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>',
jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param agent - the agent whose request failed.\n * @param context - request coordinates, provider, normalized failure, and serving policy.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */', jsDoc: '/**\n * Handle one failed model-request attempt before the loop retries or closes\n * its step. A listener returns `{ kind: \'retry\' }` without calling `next()`\n * when it owns recovery, or calls `next()` to delegate. The default\n * `undefined` leaves the failure terminal.\n * @param payload.agent - the agent whose request failed.\n * @param payload.turn - the turn containing the failed request.\n * @param payload.step - the step containing the failed request attempt.\n * @param payload.provider - the provider selected for the failed request.\n * @param payload.failure - serializable facts normalized at the final adapter boundary.\n * @param payload.retryPolicy - the policy of the adapter registration that served the failed request.\n * @param payload.signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Handle one failed model-request attempt before the loop retries or closes its step.', summary: 'Handle one failed model-request attempt before the loop retries or closes its step.',
}, },
{ {
name: 'agent/session-start', name: 'agent/session-start',
mode: 'emit', mode: 'emit',
signature: '\'agent/session-start\'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void', signature: '\'agent/session-start\'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void',
jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param agent - the agent whose session lifecycle began.\n * @param source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * The session lifecycle began, once before the first turn. Use\n * `agent.inject()` to seed model-facing context. This is a notification, not\n * a veto; disposal requested by a lifecycle owner is rechecked before the\n * driver starts.\n * @param payload.agent - the agent whose session lifecycle began.\n * @param payload.source - why the session started (fresh startup, resume, …).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'The session lifecycle began, once before the first turn.', summary: 'The session lifecycle began, once before the first turn.',
}, },
{ {
name: 'agent/status', name: 'agent/status',
mode: 'emit', mode: 'emit',
signature: '\'agent/status\'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void', signature: '\'agent/status\'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void',
jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param agent - the agent whose status flipped.\n * @param status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', jsDoc: '/**\n * Agent status changed (`idle` ⇄ `running`). A waking delivery enters\n * `running` synchronously after reserving cancellation; `idle` means no\n * driver remains scheduled or active.\n * @param payload.agent - the agent whose status flipped.\n * @param payload.status - the status just entered (the transition\'s destination).\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'Agent status changed (`idle` ⇄ `running`).', summary: 'Agent status changed (`idle` ⇄ `running`).',
}, },
{ {
name: 'agent/turn-stopping', name: 'agent/turn-stopping',
mode: 'serial', mode: 'serial',
signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void', signature: '\'agent/turn-stopping\'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void',
jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param agent - the agent whose turn is at its stop boundary.\n * @param turn - the turn about to close.\n * @param signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */', jsDoc: '/**\n * The turn is about to close: the model owes no response (no live tool\n * calls, no fresh steering). Awaited before the boundary commits — a\n * listener that objects steers (`agent.steer(...)`) and the machine\n * re-reads its inbox: fresh steering runs another step, none closes the\n * turn. Data decides, so listener order cannot change the outcome. The\n * inverse control (stop a tool loop early) is data too: a tool result\n * carrying `concludesTurn` ends the turn at its step. The conclusion\n * never short-circuits already-submitted next-step work: same-step\n * `additionalContexts` or racing steering still runs, and the turn\n * closes only when that inbox drains.\n * @param payload.agent - the agent whose turn is at its stop boundary.\n * @param payload.turn - the turn about to close.\n * @param payload.signal - the current turn\'s explicit abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode serial\n */',
summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).', summary: 'The turn is about to close: the model owes no response (no live tool calls, no fresh steering).',
}, },
{ {
@@ -1357,8 +1357,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
{ {
name: 'goal/changed', name: 'goal/changed',
mode: 'emit', mode: 'emit',
signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, agent: Agent, change: GoalChanged): void', signature: '\'goal/changed\'(this: import(\'@deepseek-ai/dsh-scope\').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void',
jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param agent - agent whose session owns the goal.\n * @param change - fresh current projection or clear tombstone.\n * @mode emit\n */', jsDoc: '/**\n * Goal mutation accepted by one live agent. The matching `goal/change`\n * session event has already committed. Listener failures are contained.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @param payload.agent - agent whose session owns the goal.\n * @param payload.change - fresh current projection or clear tombstone.\n * @mode emit\n */',
summary: 'Goal mutation accepted by one live agent.', summary: 'Goal mutation accepted by one live agent.',
}, },
{ {

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -11,9 +11,10 @@ import type {
AgentStatus, AgentStatus,
CancelOptions, CancelOptions,
InboxTarget, InboxTarget,
PreStepDecision,
RequestErrorAction, RequestErrorAction,
} from '@deepseek-ai/dsh-agent' } from '@deepseek-ai/dsh-agent'
import { Inbox, agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { Inbox, agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent'
import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
import { import {
BlockAssembler, BlockAssembler,
@@ -23,7 +24,7 @@ import {
errorChain, errorChain,
markAgentLoopRequest, markAgentLoopRequest,
} from '@deepseek-ai/dsh-llm' } from '@deepseek-ai/dsh-llm'
import type { Scope } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
import { createScope } from '@deepseek-ai/dsh-scope' import { createScope } from '@deepseek-ai/dsh-scope'
import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session' import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, UserMessage } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
@@ -68,6 +69,9 @@ export class ReactLoopAgent implements Agent {
readonly scope: Scope readonly scope: Scope
readonly ctx: Context readonly ctx: Context
/** Fused scope carrier, built once in the constructor for every dispatch. */
readonly carrier: Scoped<Agent>
/** Whether this loop instance has appended its initial/resume request anchor. */ /** Whether this loop instance has appended its initial/resume request anchor. */
private requestHeaderLogged = false private requestHeaderLogged = false
private readonly runtimeContext: RuntimeContextProjection private readonly runtimeContext: RuntimeContextProjection
@@ -78,6 +82,7 @@ export class ReactLoopAgent implements Agent {
public readonly options: AgentOptions, public readonly options: AgentOptions,
public readonly session: Session, public readonly session: Session,
) { ) {
this.carrier = agentCarrier(this)
this.inbox = new Inbox(session, { this.inbox = new Inbox(session, {
inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) }, inserted: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/inserted', { message }) },
discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) }, discarded: (message) => { emitAgentEvent(loopCtx, this, 'agent/inbox/discarded', { message }) },
@@ -100,7 +105,7 @@ export class ReactLoopAgent implements Agent {
this.phase = next this.phase = next
const status = this.status const status = this.status
if (status !== previousStatus) { if (status !== previousStatus) {
emitAgentEvent(this.loopCtx, this, 'agent/status', status) emitAgentEvent(this.loopCtx, this, 'agent/status', { status })
} }
} }
@@ -178,7 +183,7 @@ export class ReactLoopAgent implements Agent {
private throwError(error: unknown): never { private throwError(error: unknown): never {
const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn const turn = this.phase.kind === 'running' ? this.phase.turn : this.phase.lastTurn
const step = this.phase.kind === 'running' ? this.phase.step : 0 const step = this.phase.kind === 'running' ? this.phase.step : 0
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error) emitAgentEvent(this.loopCtx, this, 'agent/error', { turn, step, error })
throw error throw error
} }
@@ -203,9 +208,9 @@ export class ReactLoopAgent implements Agent {
const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal)) const assembly = await this.loopCtx.systemPrompt.assemble(assembleContextFor(this, signal))
signal.throwIfAborted() signal.throwIfAborted()
const context = this.runtimeContext.project(renderContextSnapshot(assembly)) const context = this.runtimeContext.project(renderContextSnapshot(assembly))
const decision = await agentEvents(this.loopCtx, this).waterfall( const decision = await this.loopCtx.waterfall(
'agent/pre-step', claimed, { ...position, signal }, this.carrier, 'agent/pre-step', { agent: this, messages: claimed, ...position, signal },
() => Promise.resolve({ (): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter', kind: 'enter',
messages: context === undefined ? claimed : [...claimed, context], messages: context === undefined ? claimed : [...claimed, context],
}), }),
@@ -265,7 +270,7 @@ export class ReactLoopAgent implements Agent {
} }
signal.throwIfAborted() signal.throwIfAborted()
if (turnEnds && this.inbox.nextStep.length === 0) { if (turnEnds && this.inbox.nextStep.length === 0) {
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal) await this.loopCtx.serial(this.carrier, 'agent/turn-stopping', { agent: this, turn, signal })
signal.throwIfAborted() signal.throwIfAborted()
} }
if (turnEnds && this.inbox.nextStep.length === 0) break if (turnEnds && this.inbox.nextStep.length === 0) break
@@ -323,13 +328,15 @@ export class ReactLoopAgent implements Agent {
const finish = assembler.finish const finish = assembler.finish
if (finish.kind === 'error' || finish.kind === 'aborted') { if (finish.kind === 'error' || finish.kind === 'aborted') {
const action = await this.loopCtx.waterfall( const action = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, { this.carrier, 'agent/request-error', {
agent: this,
turn, turn,
step, step,
provider: request.provider, provider: request.provider,
failure: finish.failure, failure: finish.failure,
retryPolicy: preparedCall?.retryPolicy, retryPolicy: preparedCall?.retryPolicy,
}, signal, signal,
},
() => Promise.resolve<RequestErrorAction>(undefined), () => Promise.resolve<RequestErrorAction>(undefined),
) )
signal.throwIfAborted() signal.throwIfAborted()
@@ -405,7 +412,7 @@ export class ReactLoopAgent implements Agent {
}, },
)) ))
const proposedConfig = await this.loopCtx.waterfall( const proposedConfig = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request', this, turn, step, signal, this.carrier, 'agent/request', { agent: this, turn, step, signal },
() => Promise.resolve(seedConfig), () => Promise.resolve(seedConfig),
) )
signal.throwIfAborted() signal.throwIfAborted()

View File

@@ -175,11 +175,11 @@ declare module 'cordis' {
* Consumers that buffer work for the configured identity use this * Consumers that buffer work for the configured identity use this
* transient signal to reject that work instead of waiting forever. Normal * transient signal to reject that work instead of waiting forever. Normal
* factory teardown suppresses failures from the cancelled startup attempt. * factory teardown suppresses failures from the cancelled startup attempt.
* @param sessionId - exact shared agent/session identity that failed startup. * @param payload.sessionId - exact shared agent/session identity that failed startup.
* @param error - persistence, setup, or publication failure. * @param payload.error - persistence, setup, or publication failure.
* @mode emit * @mode emit
*/ */
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void
} }
} }
@@ -351,7 +351,7 @@ export class AgentLoop extends Service implements AgentFactory {
): void { ): void {
if (!this.ownership.isActive()) return if (!this.ownership.isActive()) return
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`) this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] const args: unknown[] = ['agent-loop/config-start-failed', { sessionId, error }]
for (const callback of this.ctx.events.dispatch('emit', args)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
try { try {
const returned: unknown = callback(...args) const returned: unknown = callback(...args)
@@ -400,7 +400,7 @@ export class AgentLoop extends Service implements AgentFactory {
released.resolve() released.resolve()
} }
} }
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) const disposeAgentListener = ownerCtx.on('agent/disposed', () => { checkReleased() })
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
try { try {
checkReleased() checkReleased()
@@ -525,7 +525,7 @@ export class AgentLoop extends Service implements AgentFactory {
// A synchronous announce/session-start listener may have started // A synchronous announce/session-start listener may have started
// teardown; the machine is already live (delivery works from the // teardown; the machine is already live (delivery works from the
// session-start seam), so only the liveness recheck is owed. // session-start seam), so only the liveness recheck is owed.
emitAgentEvent(loopCtx, agent, 'agent/session-start', source) emitAgentEvent(loopCtx, agent, 'agent/session-start', { source })
assertLive() assertLive()
return { agent, dispose } return { agent, dispose }
}, },

View File

@@ -31,7 +31,7 @@ async function harness(adapter: LlmAdapter): Promise<Harness> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -164,18 +164,18 @@ describe('AgentLoop initiator scope', () => {
if (context.agent === agent) capture(context.signal) if (context.agent === agent) capture(context.signal)
return next() return next()
}) })
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => {
if (subject === agent) { if (subject === agent) {
expect(ctx.agents.requireInitiator()).toBe(agent) expect(ctx.agents.requireInitiator()).toBe(agent)
preStepSignals.push(signal) preStepSignals.push(signal)
} }
return next() return next()
}) })
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { ctx.on('agent/request', async ({ agent: subject, signal }, next) => {
if (subject === agent) capture(signal) if (subject === agent) capture(signal)
return next() return next()
}) })
ctx.on('agent/turn-stopping', (subject, _turn, signal) => { ctx.on('agent/turn-stopping', ({ agent: subject, signal }) => {
if (subject === agent) capture(signal) if (subject === agent) capture(signal)
}) })
ctx.tools.register(defineContentToolFixture({ ctx.tools.register(defineContentToolFixture({

View File

@@ -60,17 +60,17 @@ describe('Agent', () => {
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start') if (session === agent.session && event.type === 'turn/start') lifecycle.push('turn/start')
}) })
ctx.on('agent/inbox/inserted', (subject, event) => { ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
if (subject === agent) inserted.push(event) if (subject === agent) inserted.push({ message })
}) })
ctx.on('agent/inbox/claimed', (subject, event) => { ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => {
if (subject === agent) { if (subject === agent) {
lifecycle.push('agent/inbox/claimed') lifecycle.push('agent/inbox/claimed')
claimed.push(event) claimed.push({ message, turn })
} }
}) })
ctx.on('agent/inbox/discarded', (subject, event) => { ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => {
if (subject === agent) discarded.push(event) if (subject === agent) discarded.push({ message })
}) })
const context = createUserMessage({ const context = createUserMessage({
content: [{ type: 'text', text: 'discard me' }], content: [{ type: 'text', text: 'discard me' }],
@@ -114,7 +114,7 @@ describe('Agent', () => {
const ctx = await harness(new MockAdapter([textResponse('ok')])) const ctx = await harness(new MockAdapter([textResponse('ok')]))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent) statuses.push(status) if (subject === agent) statuses.push(status)
}) })
@@ -152,7 +152,7 @@ describe('Agent', () => {
const ctx = await harness(new MockAdapter([textResponse('ok')])) const ctx = await harness(new MockAdapter([textResponse('ok')]))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/status', (_subject, status) => { ctx.on('agent/status', ({ status }) => {
throw new Error(`bad ${status} listener`) throw new Error(`bad ${status} listener`)
}) })

View File

@@ -40,7 +40,7 @@ function send(agent: Agent, text: string) {
/** Resolve on the agent's next idle transition (event-based, not status poll). */ /** Resolve on the agent's next idle transition (event-based, not status poll). */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })
@@ -156,7 +156,7 @@ describe('Agent.cancel()', () => {
const running = Promise.withResolvers<undefined>() const running = Promise.withResolvers<undefined>()
let disposalDone: Promise<void> | undefined let disposalDone: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'running') return if (subject !== agent || status !== 'running') return
disposalDone = handle.dispose() disposalDone = handle.dispose()
running.resolve(undefined) running.resolve(undefined)
@@ -200,7 +200,7 @@ describe('Agent.cancel()', () => {
const replacementRegistered = Promise.withResolvers<undefined>() const replacementRegistered = Promise.withResolvers<undefined>()
let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
send(agent, 'cancelled replacement') send(agent, 'cancelled replacement')
replacementObservation = agent.whenIdle().then(() => ({ replacementObservation = agent.whenIdle().then(() => ({
@@ -239,7 +239,7 @@ describe('Agent.cancel()', () => {
const replacementRegistered = Promise.withResolvers<undefined>() const replacementRegistered = Promise.withResolvers<undefined>()
let replacementIdle: Promise<void> | undefined let replacementIdle: Promise<void> | undefined
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
send(agent, 'cancelled replacement') send(agent, 'cancelled replacement')
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -440,7 +440,7 @@ describe('Agent.cancel()', () => {
}) })
let cancelled = false let cancelled = false
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (subject === agent && !cancelled) { if (subject === agent && !cancelled) {
cancelled = true cancelled = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -465,7 +465,7 @@ describe('Agent.cancel()', () => {
// durable turn-start commit and must drop the reserved work. // durable turn-start commit and must drop the reserved work.
let streamed = false let streamed = false
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true }) ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'running') agent.cancel({ kind: 'user' }) if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
}) })
@@ -485,7 +485,7 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let replaced = false let replaced = false
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'running' || replaced) return if (subject !== agent || status !== 'running' || replaced) return
replaced = true replaced = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -664,7 +664,7 @@ describe('Agent.cancel()', () => {
switch (stage) { switch (stage) {
case 'pre-step': case 'pre-step':
ctx.on('agent/pre-step', async (subject, _message, { signal }, next) => { ctx.on('agent/pre-step', async ({ agent: subject, signal }, next) => {
if (subject === agent) await blockUntilAbort(signal) if (subject === agent) await blockUntilAbort(signal)
return next() return next()
}) })
@@ -679,13 +679,13 @@ describe('Agent.cancel()', () => {
}) })
break break
case 'request': case 'request':
ctx.on('agent/request', async (subject, _turn, _step, signal, next) => { ctx.on('agent/request', async ({ agent: subject, signal }, next) => {
if (subject === agent) await blockUntilAbort(signal) if (subject === agent) await blockUntilAbort(signal)
return next() return next()
}) })
break break
case 'stopping': case 'stopping':
ctx.on('agent/turn-stopping', async (subject, _turn, signal) => { ctx.on('agent/turn-stopping', async ({ agent: subject, signal }) => {
if (subject === agent) await blockUntilAbort(signal) if (subject === agent) await blockUntilAbort(signal)
}) })
break break

View File

@@ -19,7 +19,7 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })
@@ -170,7 +170,7 @@ describe('config-driven session id', () => {
await cleanupStarted.promise await cleanupStarted.promise
expect(first.status).toBe('idle') expect(first.status).toBe('idle')
const failures: unknown[] = [] const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
const secondLoop = await ctx.plugin(AgentLoop, config) const secondLoop = await ctx.plugin(AgentLoop, config)
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.agents.get(sessionId)).toBe(first) expect(ctx.agents.get(sessionId)).toBe(first)
@@ -234,7 +234,7 @@ describe('config-driven session id', () => {
const failures: { sessionId: SessionId; error: unknown }[] = [] const failures: { sessionId: SessionId; error: unknown }[] = []
ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure })
ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never)
ctx.on('agent-loop/config-start-failed', (sessionId, error) => { ctx.on('agent-loop/config-start-failed', ({ sessionId, error }) => {
failures.push({ sessionId, error }) failures.push({ sessionId, error })
}) })
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure)
@@ -274,7 +274,7 @@ describe('config-driven session id', () => {
// Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary.
// oxlint-disable-next-line typescript/prefer-promise-reject-errors // oxlint-disable-next-line typescript/prefer-promise-reject-errors
ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never)
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
@@ -307,7 +307,7 @@ describe('config-driven session id', () => {
const released = vi.fn() const released = vi.fn()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const failures: unknown[] = [] const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
const loop = await ctx.plugin(AgentLoop, { const loop = await ctx.plugin(AgentLoop, {
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }], agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
@@ -479,7 +479,7 @@ describe('startup reporting after factory teardown', () => {
gate.promise.catch(() => undefined) gate.promise.catch(() => undefined)
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise) vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(gate.promise)
const failures: unknown[] = [] const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const loop = await ctx.plugin(AgentLoop, { const loop = await ctx.plugin(AgentLoop, {

View File

@@ -40,7 +40,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -191,7 +191,7 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([textResponse('must not run')]) const adapter = new MockAdapter([textResponse('must not run')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a-empty-batch'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', (subject, _messages, _context, next) => { ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (subject !== agent) return next() if (subject !== agent) return next()
return Promise.resolve({ kind: 'enter', messages: [] }) return Promise.resolve({ kind: 'enter', messages: [] })
}) })
@@ -288,7 +288,7 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'leave an unmatched historical call') send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
const disposeInjection = ctx.on('agent/pre-step', async (subject, _messages, { turn }, next) => { const disposeInjection = ctx.on('agent/pre-step', async ({ agent: subject, turn }, next) => {
const decision = await next() const decision = await next()
if (subject === agent && turn === 2 && decision.kind === 'enter') { if (subject === agent && turn === 2 && decision.kind === 'enter') {
disposeInjection() disposeInjection()
@@ -382,7 +382,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
const statuses: string[] = [] const statuses: string[] = []
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
ctx.on('agent/status', (_agent, status) => void statuses.push(status)) ctx.on('agent/status', ({ status }) => void statuses.push(status))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go') send(agent, 'go')
@@ -411,7 +411,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
ctx.on('agent/status', (_agent, status) => { ctx.on('agent/status', ({ status }) => {
if (status === 'idle') throw new Error('broken status listener') if (status === 'idle') throw new Error('broken status listener')
}) })
@@ -457,7 +457,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
return { ...await next(), provider: 'mock', model: 'mock' } return { ...await next(), provider: 'mock', model: 'mock' }
}) })
@@ -540,7 +540,7 @@ describe('turn numbering continues across seeded sessions', () => {
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) forked.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } }))
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
ctx2.on('agent/status', (subject, status) => { ctx2.on('agent/status', ({ agent: subject, status }) => {
if (subject === forked && status === 'idle') resolve() if (subject === forked && status === 'idle') resolve()
}) })
}) })
@@ -586,7 +586,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
const errors: unknown[] = [] const errors: unknown[] = []
ctx.on('agent/error', (_agent, turn, step, error) => { ctx.on('agent/error', ({ turn, step, error }) => {
expect({ turn, step }).toEqual({ turn: 1, step: 1 }) expect({ turn, step }).toEqual({ turn: 1, step: 1 })
errors.push(error) errors.push(error)
}) })
@@ -710,7 +710,7 @@ describe('turn and step boundary recovery', () => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') } if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -743,7 +743,7 @@ describe('turn and step boundary recovery', () => {
} }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -800,7 +800,7 @@ describe('turn and step boundary recovery', () => {
} }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -893,14 +893,14 @@ describe('turn and step boundary recovery', () => {
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
let threw = false let threw = false
ctx.on('agent/pre-step', (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', (_payload, next) => {
if (threw) return next() if (threw) return next()
threw = true threw = true
void fiber.dispose() void fiber.dispose()
throw new Error('boom pre-step during disposal') throw new Error('boom pre-step during disposal')
}) })
const errorEmits: Error[] = [] const errorEmits: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errorEmits.push(error) if (error instanceof Error) errorEmits.push(error)
}) })
@@ -926,7 +926,7 @@ describe('turn and step boundary recovery', () => {
if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') } if (!threw && event.type === 'turn/start') { threw = true; throw new Error('boom turn/start append') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -959,7 +959,7 @@ describe('turn and step boundary recovery', () => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') } if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -1000,7 +1000,7 @@ describe('turn and step boundary recovery', () => {
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') } if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -1215,7 +1215,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx) await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
await blocker await blocker
return next() return next()
}) })
@@ -1261,7 +1261,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await mountInvariants(ctx) await mountInvariants(ctx)
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
await blocker await blocker
return next() return next()
}) })

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -120,7 +120,7 @@ describe('thrown-value propagation', () => {
}) })
const errors: unknown[] = [] const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) ctx.on('agent/error', ({ error }) => void errors.push(error))
send(agent, 'fails before turn start') send(agent, 'fails before turn start')
send(agent, 'survives as the next item') send(agent, 'survives as the next item')
@@ -143,7 +143,7 @@ describe('thrown-value propagation', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!threwOnce) { if (!threwOnce) {
threwOnce = true threwOnce = true
throw { code: 500 } throw { code: 500 }
@@ -167,7 +167,7 @@ describe('durable error rendering', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!threwOnce) { if (!threwOnce) {
threwOnce = true threwOnce = true
throw new LlmError('server overloaded', 'RATE_LIMIT') throw new LlmError('server overloaded', 'RATE_LIMIT')
@@ -250,7 +250,7 @@ describe('request-error action edges', () => {
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject) => { ctx.on('agent/request-error', async ({ agent: subject }) => {
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
return { kind: 'retry' } return { kind: 'retry' }
}) })
@@ -271,7 +271,7 @@ describe('request-error action edges', () => {
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject, _context, signal, next) => { ctx.on('agent/request-error', async ({ agent: subject, signal }, next) => {
await next() await next()
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
expect(signal.aborted).toBe(true) expect(signal.aborted).toBe(true)
@@ -350,7 +350,7 @@ describe('persistent step-close rejection', () => {
if (event.type === 'step/end') throw new Error('step close permanently rejected') if (event.type === 'step/end') throw new Error('step close permanently rejected')
}) })
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) })
send(agent, 'go') send(agent, 'go')
await agent.whenIdle() await agent.whenIdle()
@@ -406,7 +406,7 @@ describe('turn close failure containment', () => {
} }
}) })
const errors: unknown[] = [] const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) }) ctx.on('agent/error', ({ error }) => { errors.push(error) })
send(agent, 'go') send(agent, 'go')
await agent.whenIdle() await agent.whenIdle()
@@ -484,11 +484,11 @@ describe('driver bookkeeping edges', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('reject-next-step'), { provider: 'mock', model: 'mock' })
let proposals = 0 let proposals = 0
ctx.on('agent/pre-step', async (_subject, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
proposals += 1 proposals += 1
return proposals === 2 ? { kind: 'reject' } : next() return proposals === 2 ? { kind: 'reject' } : next()
}) })
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
content: [{ type: 'text', text: 'do not enter the next step' }], content: [{ type: 'text', text: 'do not enter the next step' }],
source: { kind: 'plugin', plugin: 'test' }, source: { kind: 'plugin', plugin: 'test' },

View File

@@ -41,7 +41,7 @@ async function harness(adapter: MockAdapter) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -65,7 +65,7 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const seen: string[] = [] const seen: string[] = []
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { ctx.on('agent/pre-step', async ({ messages }, next) => {
seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join('')) seen.push(messages[0]!.content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next() return next()
}) })
@@ -92,8 +92,8 @@ describe('agent/pre-step', () => {
})) }))
const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('prompt-coordinates'), { provider: 'mock', model: 'mock' })
const seen: Array<{ turn: number; step: number; messages: number }> = [] const seen: Array<{ turn: number; step: number; messages: number }> = []
ctx.on('agent/pre-step', async (_agent, messages, context, next) => { ctx.on('agent/pre-step', async ({ messages, turn, step }, next) => {
seen.push({ turn: context.turn, step: context.step, messages: messages.length }) seen.push({ turn, step, messages: messages.length })
return next() return next()
}) })
@@ -113,7 +113,7 @@ describe('agent/pre-step', () => {
const entered = Promise.withResolvers<undefined>() const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PreStepDecision>() const decision = Promise.withResolvers<PreStepDecision>()
const observed: UserMessage[] = [] const observed: UserMessage[] = []
ctx.on('agent/pre-step', async (subject, messages) => { ctx.on('agent/pre-step', async ({ agent: subject, messages }) => {
if (subject !== agent) return { kind: 'enter', messages } if (subject !== agent) return { kind: 'enter', messages }
const message = messages[0]! const message = messages[0]!
expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message)).toBe(true)
@@ -161,7 +161,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({ ({
kind: 'enter', kind: 'enter',
messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }], messages: [{ ...messages[0]!, content: [{ type: 'text', text: 'REWRITTEN' }] }],
@@ -182,7 +182,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages): Promise<PreStepDecision> => ctx.on('agent/pre-step', async ({ messages }): Promise<PreStepDecision> =>
({ ({
kind: 'enter', kind: 'enter',
messages: [...messages, createUserMessage({ messages: [...messages, createUserMessage({
@@ -211,15 +211,15 @@ describe('agent/pre-step', () => {
provider: 'mock', provider: 'mock',
model: 'mock', model: 'mock',
}) })
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
content: [{ type: 'text', text: 'pending context' }], content: [{ type: 'text', text: 'pending context' }],
source: { kind: 'plugin', plugin: 'test' }, source: { kind: 'plugin', plugin: 'test' },
})) }))
}) })
ctx.on('agent/pre-step', async (_subject, _messages, context, next) => { ctx.on('agent/pre-step', async ({ step }, next) => {
const decision = await next() const decision = await next()
return context.step === 1 || decision.kind === 'reject' return step === 1 || decision.kind === 'reject'
? decision ? decision
: { kind: 'enter', messages: [] } : { kind: 'enter', messages: [] }
}) })
@@ -262,7 +262,7 @@ describe('agent/pre-step', () => {
const decision = Promise.withResolvers<PreStepDecision>() const decision = Promise.withResolvers<PreStepDecision>()
let claimed: UserMessage[] = [] let claimed: UserMessage[] = []
let firstProposal = true let firstProposal = true
ctx.on('agent/pre-step', async (_agent, messages) => { ctx.on('agent/pre-step', async ({ messages }) => {
if (!firstProposal) return { kind: 'enter', messages } if (!firstProposal) return { kind: 'enter', messages }
firstProposal = false firstProposal = false
claimed = messages claimed = messages
@@ -372,14 +372,14 @@ describe('agent/pre-step', () => {
provider: 'mock', provider: 'mock',
model: 'mock', model: 'mock',
}) })
ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { ctx.on('agent/pre-step', async ({ messages }, next) => {
const decision = await next() const decision = await next()
return messages.some(message => return messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt')) message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))
? { kind: 'reject' as const } ? { kind: 'reject' as const }
: decision : decision
}) })
ctx.on('agent/pre-step', async (subject, messages, _signal, next) => { ctx.on('agent/pre-step', async ({ agent: subject, messages }, next) => {
if (messages.some(message => if (messages.some(message =>
message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) { message.content.some(block => block.type === 'text' && block.text === 'blocked prompt'))) {
subject.inject(createUserMessage({ subject.inject(createUserMessage({
@@ -482,7 +482,7 @@ describe('agent/pre-step', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content) const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('') .map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' return text === 'secret'
@@ -519,17 +519,17 @@ describe('agent/pre-step', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threw = false let threw = false
ctx.on('agent/pre-step', async (_agent, messages) => { ctx.on('agent/pre-step', async ({ messages }) => {
if (!threw) { threw = true; throw new Error('prompt hook broke') } if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'enter' as const, messages } return { kind: 'enter' as const, messages }
}) })
const errors: Error[] = [] const errors: Error[] = []
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) }) ctx.on('agent/status', ({ agent: subject, status }) => { if (subject === agent) statuses.push(status) })
ctx.on('session/event', (session, event) => { ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason) if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
}) })
@@ -559,7 +559,7 @@ describe('agent/session-start', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const sources: SessionStartSource[] = [] const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) ctx.on('agent/session-start', ({ source }) => void sources.push(source))
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
// fires synchronously at create, before any turn // fires synchronously at create, before any turn
@@ -576,7 +576,7 @@ describe('agent/session-start', () => {
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } })) agent.inject(createUserMessage({ content: [{ type: 'text', text: 'session preamble' }], source: { kind: 'plugin', plugin: 'test' } }))
}) })
@@ -724,11 +724,11 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
name: 'native-guard', name: 'native-guard',
apply(ctx: Context) { apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction. // 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => { ctx.on('agent/session-start', ({ agent, source }) => {
agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } })) agent.inject(createUserMessage({ content: [{ type: 'text', text: `policy active (started: ${source})` }], source: { kind: 'plugin', plugin: 'native-guard' } }))
}) })
// 2. PreStep: reject a forbidden prompt, annotate the rest. // 2. PreStep: reject a forbidden prompt, annotate the rest.
ctx.on('agent/pre-step', async (_agent, messages, _signal, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ messages }, next): Promise<PreStepDecision> => {
const text = messages.flatMap(message => message.content) const text = messages.flatMap(message => message.content)
.map(b => (b.type === 'text' ? b.text : '')).join('') .map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) { if (text.includes('rm -rf')) {

View File

@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter, persona = '') {
/** Wait for the agent's next transition to idle after a waking send. */ /** Wait for the agent's next transition to idle after a waking send. */
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -216,7 +216,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok after rescue')]) const adapter = new MockAdapter([textResponse('ok after rescue')])
const ctx = await harness(adapter, 'In {{cwd}}.') const ctx = await harness(adapter, 'In {{cwd}}.')
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -263,7 +263,7 @@ describe('agent loop', () => {
assembly.variables['model'] = 'mock' assembly.variables['model'] = 'mock'
return next() return next()
}) })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
const config = await next() const config = await next()
return { ...config, provider: 'mock', model: 'mock' } return { ...config, provider: 'mock', model: 'mock' }
}) })
@@ -553,7 +553,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
let fail = true let fail = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => { ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (subject !== agent || !fail) return next() if (subject !== agent || !fail) return next()
fail = false fail = false
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } })) subject.steer(createUserMessage({ content: [{ type: 'text', text: 'pending steering' }], source: { kind: 'user' } }))
@@ -713,7 +713,7 @@ describe('agent loop', () => {
let steps = 0 let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 3) { if (steps < 3) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })) subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } }))
} }
@@ -785,7 +785,7 @@ describe('agent loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
const config = await next() const config = await next()
// The seed is frozen — config is not a mutable per-call knob; a switch // The seed is frozen — config is not a mutable per-call knob; a switch
// is proposed by returning a replacement, and the loop logs it. // is proposed by returning a replacement, and the loop logs it.
@@ -816,7 +816,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const fires: { turn: number; step: number; signal: AbortSignal }[] = [] const fires: { turn: number; step: number; signal: AbortSignal }[] = []
ctx.on('agent/pre-step', (subject, _messages, { turn, step, signal }, next) => { ctx.on('agent/pre-step', ({ agent: subject, turn, step, signal }, next) => {
if (subject === agent) fires.push({ turn, step, signal }) if (subject === agent) fires.push({ turn, step, signal })
return next() return next()
}) })
@@ -837,7 +837,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let boundaryOpen = true let boundaryOpen = true
ctx.on('agent/pre-step', (subject, _messages, _context, next) => { ctx.on('agent/pre-step', ({ agent: subject }, next) => {
if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start' if (subject === agent) boundaryOpen = subject.session.events.at(-1)?.type === 'step/start'
return next() return next()
}) })
@@ -855,13 +855,13 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let throwOnce = true let throwOnce = true
ctx.on('agent/pre-step', (_agent, _messages, _context, next) => { ctx.on('agent/pre-step', (_payload, next) => {
if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') } if (throwOnce) { throwOnce = false; throw new Error('boom in pre-step') }
return next() return next()
}) })
const errors: Error[] = [] const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => { ctx.on('agent/error', ({ error }) => {
if (error instanceof Error) errors.push(error) if (error instanceof Error) errors.push(error)
}) })
@@ -933,7 +933,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default // Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops). // (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-stopping', (subject) => { ctx.on('agent/turn-stopping', ({ agent: subject }) => {
if (steps < 2) { if (steps < 2) {
subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })) subject.steer(createUserMessage({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } }))
} }
@@ -1296,7 +1296,7 @@ describe('agent loop', () => {
const errors: unknown[] = [] const errors: unknown[] = []
const reasons: TurnEndReason[] = [] const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { ctx.on('agent/error', ({ error }) => {
errors.push(error) errors.push(error)
}) })
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })

View File

@@ -50,7 +50,7 @@ async function harness() {
/** Resolve on the agent's next transition to idle (event-based, not polled). */ /** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: Agent): Promise<void> { function nextIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -63,7 +63,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise<void> {
* the seen list plus a disposer for the listener (per the registry convention). */ * the seen list plus a disposer for the listener (per the registry convention). */
function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
const seen: string[] = [] const seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent) seen.push(status) if (subject === agent) seen.push(status)
}) })
return { seen, dispose } return { seen, dispose }

View File

@@ -59,7 +59,7 @@ async function loopHarness(): Promise<Context> {
function waitForIdle(context: Context, agent: Agent): Promise<void> { function waitForIdle(context: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = context.on('agent/status', (subject, status) => { const dispose = context.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -62,12 +62,12 @@ describe('agent/request-error', () => {
retryPolicy: ResolvedRetryPolicy | undefined retryPolicy: ResolvedRetryPolicy | undefined
}[] = [] }[] = []
const statuses: string[] = [] const statuses: string[] = []
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent) statuses.push(status) if (subject === agent) statuses.push(status)
}) })
ctx.on('agent/request-error', async (subject, context) => { ctx.on('agent/request-error', async ({ agent: subject, turn, step, failure, retryPolicy }) => {
expect(subject).toBe(agent) expect(subject).toBe(agent)
seen.push(context) seen.push({ turn, step, failure, retryPolicy })
return { kind: 'retry' } return { kind: 'retry' }
}) })
@@ -102,7 +102,7 @@ describe('agent/request-error', () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')]) const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject) => { ctx.on('agent/request-error', async ({ agent: subject }) => {
subject.cancel({ kind: 'user' }) subject.cancel({ kind: 'user' })
return { kind: 'retry' } return { kind: 'retry' }
}) })

View File

@@ -38,7 +38,7 @@ async function harnessRoutes(
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -122,7 +122,7 @@ describe('request stability across the loop', () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { ctx.on('agent/request', async ({ turn }, next) => {
const config = await next() const config = await next()
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
}) })
@@ -198,7 +198,7 @@ describe('request stability across the loop', () => {
provider: 'deepseek', provider: 'deepseek',
model: 'deepseek-model', model: 'deepseek-model',
}) })
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { ctx.on('agent/request', async ({ turn }, next) => {
const config = await next() const config = await next()
return turn === 2 return turn === 2
? { ...config, provider: 'other', model: 'other-model' } ? { ...config, provider: 'other', model: 'other-model' }
@@ -232,7 +232,7 @@ describe('request stability across the loop', () => {
model: 'deepseek-model', model: 'deepseek-model',
maxTokens: 4_096, maxTokens: 4_096,
}) })
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => { ctx.on('agent/request', async ({ turn }, next) => {
const config = await next() const config = await next()
return turn === 2 return turn === 2
? { ...config, provider: 'other', model: 'other-model' } ? { ...config, provider: 'other', model: 'other-model' }
@@ -460,7 +460,7 @@ describe('request stability across the loop', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let injected = false let injected = false
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
if (!injected) { if (!injected) {
injected = true injected = true
agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } })) agent.inject(createUserMessage({ content: [{ type: 'text', text: '[late context]' }], source: { kind: 'plugin', plugin: 'test' } }))
@@ -539,7 +539,7 @@ describe('request stability across the loop', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => { ctx.on('agent/request', async (_payload, next) => {
const config = await next() const config = await next()
// next() resolves the SAME frozen seed — in-place shaping after // next() resolves the SAME frozen seed — in-place shaping after
// delegation is unrepresentable, so a "mutate what next() returned" // delegation is unrepresentable, so a "mutate what next() returned"
@@ -576,7 +576,7 @@ describe('request stability across the loop', () => {
send(agent, 'go') send(agent, 'go')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' }) ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'now with guidance' })
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ctx.on('agent/request', async (_payload, next) => ({
...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'], ...await next(), temperature: 0.5, maxTokens: 99, stop: ['<END>'],
})) }))
send(agent, 'again') send(agent, 'again')
@@ -658,7 +658,7 @@ describe('request/context capacity records', () => {
send(agent, 'first') send(agent, 'first')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent ctx.on('agent/request', ({ agent: subject }, next) => subject === agent
? Promise.resolve({ provider: 'mock', model: 'large' }) ? Promise.resolve({ provider: 'mock', model: 'large' })
: next()) : next())
send(agent, 'second') send(agent, 'second')
@@ -686,7 +686,7 @@ describe('request/context capacity records', () => {
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' }) const agent = ctx.agentLoop.create(SessionId('capacity-clear'), { provider: 'mock', model: 'known' })
let model = 'known' let model = 'known'
ctx.on('agent/request', (subject, _turn, _step, _signal, next) => subject === agent ctx.on('agent/request', ({ agent: subject }, next) => subject === agent
? Promise.resolve({ provider: 'mock', model }) ? Promise.resolve({ provider: 'mock', model })
: next()) : next())

View File

@@ -66,7 +66,7 @@ function preparationFromSnapshot(
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })
@@ -260,7 +260,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
const adapter1 = new MockAdapter([textResponse('a')]) const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = [] const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) ctx1.on('agent/session-start', ({ source }) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup']) expect(sources1).toEqual(['startup'])
a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })) a1.followup(createUserMessage({ content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }))
@@ -279,7 +279,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) ctx2.llm.registerAdapter(['mock'], adapter2)
const sources2: string[] = [] const sources2: string[] = []
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) ctx2.on('agent/session-start', ({ source }) => void sources2.push(source))
await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
expect(sources2).toEqual(['resume']) expect(sources2).toEqual(['resume'])
await ctx2.fiber.dispose() await ctx2.fiber.dispose()
@@ -298,11 +298,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(ctx.agents.get(sessionId)?.session).toBe(session) expect(ctx.agents.get(sessionId)?.session).toBe(session)
order.push('session/created') order.push('session/created')
}) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
expect(agent.status).toBe('idle') expect(agent.status).toBe('idle')
order.push('agent/created') order.push('agent/created')
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow() expect(() => { agent.cancel({ kind: 'user' }) }).not.toThrow()
order.push('agent/session-start') order.push('agent/session-start')
}) })
@@ -882,7 +882,7 @@ describe('configured-start failure edges', () => {
configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
const configFailures: unknown[] = [] const configFailures: unknown[] = []
configured.on('agent-loop/config-start-failed', (_id, error) => { configFailures.push(error) }) configured.on('agent-loop/config-start-failed', ({ error }) => { configFailures.push(error) })
const configWarnings: string[] = [] const configWarnings: string[] = []
const configWarn = configured.logger.warn.bind(configured.logger) const configWarn = configured.logger.warn.bind(configured.logger)
configured.logger.warn = ((...args: unknown[]) => { configured.logger.warn = ((...args: unknown[]) => {
@@ -915,7 +915,7 @@ describe('configured-start failure edges', () => {
return gate.promise return gate.promise
} }
const failures: unknown[] = [] const failures: unknown[] = []
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) ctx.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
const configured = new Context() const configured = new Context()
await configured.plugin(LlmService) await configured.plugin(LlmService)
@@ -926,7 +926,7 @@ describe('configured-start failure edges', () => {
await configured.plugin(SessionPersistenceJsonl, { root }) await configured.plugin(SessionPersistenceJsonl, { root })
configured.llm.registerAdapter(['mock'], new MockAdapter([])) configured.llm.registerAdapter(['mock'], new MockAdapter([]))
configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal) configured.sessionPersistence.prepare = (id, signal) => ctx.sessionPersistence.prepare(id, signal)
configured.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) configured.on('agent-loop/config-start-failed', ({ error }) => { failures.push(error) })
const loop = await configured.plugin(AgentLoop, { const loop = await configured.plugin(AgentLoop, {
agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }], agents: [{ id: 'main', resumeSessionId: sessionId, provider: 'mock', model: 'mock' }],
}) })

View File

@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -199,7 +199,7 @@ describe('agent scope lifecycle', () => {
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' }) const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
const heard: string[] = [] const heard: string[] = []
a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) a.ctx.on('agent/status', ({ agent: subject, status }) => void heard.push(`a-sees:${subject.id}:${status}`))
a.ctx.on('session/event', (_s, event) => { a.ctx.on('session/event', (_s, event) => {
if (event.type === 'user/message') heard.push('a-sees:user-message') if (event.type === 'user/message') heard.push('a-sees:user-message')
}) })
@@ -217,7 +217,7 @@ describe('agent scope lifecycle', () => {
it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => { it('runs setup in the guaranteed slot: scoped world complete before session-start and the first assembly', async () => {
const ctx = await harness() const ctx = await harness()
const order: string[] = [] const order: string[] = []
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
order.push('session-start') order.push('session-start')
// The scoped section is already registered by the time session-start fires. // The scoped section is already registered by the time session-start fires.
void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => { void ctx.systemPrompt.assemble(assembleContextFor(agent)).then((assembly) => {
@@ -673,19 +673,19 @@ describe('agent scope lifecycle', () => {
ctx.on('session/created', (session) => { ctx.on('session/created', (session) => {
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
}) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
if (agent.id !== SessionId('agent-created-barrier-s')) return if (agent.id !== SessionId('agent-created-barrier-s')) return
lifecycle.push('agent-created:dispose') lifecycle.push('agent-created:dispose')
disposeCurrentLifecycle(ownerCtx) disposeCurrentLifecycle(ownerCtx)
}) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
if (agent.id !== SessionId('agent-created-barrier-s')) return if (agent.id !== SessionId('agent-created-barrier-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
lifecycle.push('agent-created:observer') lifecycle.push('agent-created:observer')
}) })
ctx.on('agent/disposed', (agent) => { ctx.on('agent/disposed', ({ agent }) => {
if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
}) })
ctx.on('session/disposed', (session) => { ctx.on('session/disposed', (session) => {
@@ -720,8 +720,8 @@ describe('agent scope lifecycle', () => {
const starts: string[] = [] const starts: string[] = []
let ownerCtx!: Context let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create> let creating!: ReturnType<typeof ctx.agents.create>
ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/session-start', ({ agent }) => void starts.push(agent.id))
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx) if (agent.id === SessionId('listener-dispose-s')) disposeCurrentLifecycle(ownerCtx)
}) })
@@ -749,15 +749,15 @@ describe('agent scope lifecycle', () => {
const statuses: string[] = [] const statuses: string[] = []
let scopeDisposed = false let scopeDisposed = false
let observerSawLive = false let observerSawLive = false
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
if (agent.id !== SessionId('session-start-dispose-s')) return if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent announced = agent
disposeCurrentLifecycle(ownerCtx) disposeCurrentLifecycle(ownerCtx)
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
if (agent.id !== SessionId('session-start-dispose-s')) return if (agent.id !== SessionId('session-start-dispose-s')) return
expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.get(agent.id)).toBe(agent)
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
@@ -840,7 +840,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness() const ctx = await harness()
let boom = true let boom = true
const disposed: string[] = [] const disposed: string[] = []
ctx.on('agent/disposed', agent => void disposed.push(agent.id)) ctx.on('agent/disposed', ({ agent }) => void disposed.push(agent.id))
ctx.on('session/created', () => { ctx.on('session/created', () => {
if (boom) { boom = false; throw new Error('boom created') } if (boom) { boom = false; throw new Error('boom created') }
}) })
@@ -861,11 +861,11 @@ describe('agent scope lifecycle', () => {
const lifecycle: string[] = [] const lifecycle: string[] = []
ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) }) ctx.on('session/created', (session) => { lifecycle.push(`session-created:${session.id}`) })
ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) }) ctx.on('session/disposed', (session) => { lifecycle.push(`session-disposed:${session.id}`) })
ctx.on('agent/created', (agent) => { ctx.on('agent/created', ({ agent }) => {
lifecycle.push(`agent-created:${agent.id}`) lifecycle.push(`agent-created:${agent.id}`)
throw new Error('agent observer failed') throw new Error('agent observer failed')
}) })
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) ctx.on('agent/disposed', ({ agent }) => { lifecycle.push(`agent-disposed:${agent.id}`) })
await expect(ctx.agents.create({ await expect(ctx.agents.create({
sessionId: SessionId('partial-session'), sessionId: SessionId('partial-session'),
@@ -911,10 +911,10 @@ describe('agent scope lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' }) const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
const heard: string[] = [] const heard: string[] = []
agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) agent.ctx.on('agent/error', ({ agent: subject, turn }) => void heard.push(`${subject.id}:${turn}`))
agentEvents(ctx, other).emit('agent/error', 1, 0, new Error('not for a1')) agentEvents(ctx, other).emit('agent/error', { turn: 1, step: 0, error: new Error('not for a1') })
agentEvents(ctx, agent).emit('agent/error', 2, 0, new Error('for a1')) agentEvents(ctx, agent).emit('agent/error', { turn: 2, step: 0, error: new Error('for a1') })
expect(heard).toEqual(['a1:2']) expect(heard).toEqual(['a1:2'])
}) })
@@ -1064,7 +1064,7 @@ describe('agent scope lifecycle', () => {
}) })
const agent = handle.agent const agent = handle.agent
let reentered = false let reentered = false
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || reentered) return if (subject !== agent || status !== 'idle' || reentered) return
reentered = true reentered = true
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } })) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'reentrant' }], source: { kind: 'user' } }))

View File

@@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } if (subject === agent && status === 'idle') { dispose(); resolve() }
}) })
}) })

View File

@@ -33,7 +33,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -17,25 +17,38 @@ type Params<F> = F extends (...args: infer P) => unknown ? P : never
type Return<F> = F extends (...args: never[]) => infer R ? R : never type Return<F> = F extends (...args: never[]) => infer R ? R : never
/** /**
* The event names whose subject is an agent: handler parameters start with an * The event names whose subject is an agent: the handler's first parameter is
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier * a payload object carrying the `agent` subject AND the handler declares a
* contract). The `this` check keeps accidental first-parameter-happens-to-be- * `Scoped<Agent>` `this` (the scope-carrier contract). The `this` check keeps
* an-Agent events (or zero-arg events, whose parameter tuple would satisfy a * accidental payload-happens-to-carry-an-Agent events (or zero-arg events,
* bare rest-tuple check via callability) out of the fused-dispatch surface. * whose parameter tuple would satisfy a bare rest-tuple check via callability)
* out of the fused-dispatch surface.
*/ */
export type AgentSubjectEvent = { export type AgentSubjectEvent = {
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown [K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
? P extends [Agent, ...unknown[]] ? K : never ? P extends [infer Payload, ...unknown[]]
? Payload extends { agent: Agent } ? K : never
: never
: never : never
}[keyof Events] }[keyof Events]
/** The event arguments AFTER the injected agent subject. */ /** The full payload object of one agent-subject event. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never type PayloadOf<K extends AgentSubjectEvent> = Params<Events[K]> extends [infer Payload, ...unknown[]] ? Payload : never
/** The event arguments AFTER the payload: the waterfall `next` when present. */
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [unknown, ...infer R] ? R : never
/**
* The payload as emit-side callers pass it: the full payload minus the agent
* field, which the fused dispatcher injects so subject and scope key cannot
* diverge.
*/
type PayloadRest<K extends AgentSubjectEvent> = Omit<PayloadOf<K> & object, 'agent'>
/** /**
* The fused dispatcher {@link agentEvents} returns: each method dispatches the * The fused dispatcher {@link agentEvents} returns: each method dispatches the
* named agent-subject event with the agent's scope carrier as `thisArg` and * named agent-subject event with the agent's scope carrier as `thisArg` and
* the agent itself injected as the first event argument. * the agent itself injected into the payload.
*/ */
export interface AgentEventDispatch { export interface AgentEventDispatch {
/** /**
@@ -44,30 +57,35 @@ export interface AgentEventDispatch {
* contained per listener, so a notification cannot veto lifecycle progress * contained per listener, so a notification cannot veto lifecycle progress
* or starve a later observer. * or starve a later observer.
* @param name - the agent-subject event to emit. * @param name - the agent-subject event to emit.
* @param rest - the event's arguments after the injected agent. * @param payload - the event's payload fields; `agent` is injected.
*/ */
emit<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void emit<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): void
/** /**
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope. * Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
* @param name - the agent-subject event to dispatch. * @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent. * @param payload - the event's payload fields; `agent` is injected.
* @returns the serial chain's result (the first bail value, if any). * @returns the serial chain's result (the first bail value, if any).
*/ */
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>> serial<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>): Promise<Awaited<Return<Events[K]>>>
/** /**
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The * Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
* declared event parameters already end with the `next` callback, so `rest` * declared event parameters already end with the `next` callback, so `rest`
* is exactly the event's arguments after the injected agent — the final * is exactly the event's arguments after the payload — the final element
* element being the innermost `next` (the default the listener chain wraps). * being the innermost `next` (the default the listener chain wraps).
* @param name - the agent-subject event to dispatch. * @param name - the agent-subject event to dispatch.
* @param rest - the event's arguments after the injected agent. * @param payload - the event's payload fields; `agent` is injected.
* @param rest - the event's arguments after the payload (the `next` callback).
* @returns the waterfall's composed result. * @returns the waterfall's composed result.
*/ */
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]> waterfall<K extends AgentSubjectEvent>(name: K, payload: PayloadRest<K>, ...rest: Tail<K>): Return<Events[K]>
} }
/** /**
* Return the fused scope carrier for one agent subject. * Build the fused scope carrier for one agent subject.
*
* The carrier is a stateless routing object; callers that dispatch repeatedly
* for the same agent (the loop driver) build it once in the agent's
* constructor and reuse it, so hot-path dispatches never allocate.
* @param agent - the subject agent and scope key. * @param agent - the subject agent and scope key.
* @returns the carrier passed as the event dispatcher `this` value. * @returns the carrier passed as the event dispatcher `this` value.
*/ */
@@ -84,17 +102,21 @@ export function agentCarrier(agent: Agent): Scoped<Agent> {
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch { export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
const carrier = agentCarrier(agent) const carrier = agentCarrier(agent)
// The ordinary dispatch methods forward through Cordis' variadic mixins. The // The ordinary dispatch methods forward through Cordis' variadic mixins. The
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument // fused (carrier, name, payload, ...rest) tuple is provably a valid argument
// list for the matching thisArg overload, but TypeScript cannot relate the // list for the matching thisArg overload, but TypeScript cannot relate the
// generic Tail<K> spread back to that overload's conditional parameter // generic Tail<K> spread back to that overload's conditional parameter
// tuple — hence one contained, shape-preserving cast per method. // tuple — hence one contained, shape-preserving cast per method.
const fused = <K extends AgentSubjectEvent>(payload: PayloadRest<K>): PayloadOf<K> =>
// The dispatcher owns the subject injection; callers pass PayloadRest, so
// the fused record is exactly the declared payload.
({ agent, ...payload } as PayloadOf<K>)
return { return {
emit(name, ...rest) { emit(name, payload) {
// Cordis emit invokes callbacks through Array.map: one synchronous throw // Cordis emit invokes callbacks through Array.map: one synchronous throw
// starves later listeners, and returned promises are discarded. Agent // starves later listeners, and returned promises are discarded. Agent
// notifications are non-vetoing, so resolve the same filtered callback // notifications are non-vetoing, so resolve the same filtered callback
// set ourselves and contain both failure modes independently. // set ourselves and contain both failure modes independently.
const args: unknown[] = [carrier, name, agent, ...rest] const args: unknown[] = [carrier, name, fused(payload)]
const callbacks = ctx.events.dispatch('emit', args) const callbacks = ctx.events.dispatch('emit', args)
for (const callback of callbacks) { for (const callback of callbacks) {
try { try {
@@ -107,15 +129,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
} }
} }
}, },
async serial(name, ...rest) { async serial(name, payload) {
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never> const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
return await serial(carrier, name, agent, ...rest) return await serial(carrier, name, fused(payload))
}, },
waterfall(name, ...rest) { waterfall(name, payload, ...rest) {
// oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function // oxlint-disable-next-line typescript/unbound-method -- the events mixin accessor returns a pre-bound function
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
return waterfall(carrier, name, agent, ...rest) return waterfall(carrier, name, fused(payload), ...rest)
}, },
} }
} }
@@ -125,15 +147,15 @@ export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
* @param ctx - the context to dispatch through. * @param ctx - the context to dispatch through.
* @param agent - the subject agent and scope key. * @param agent - the subject agent and scope key.
* @param name - the agent-subject event to emit. * @param name - the agent-subject event to emit.
* @param rest - the event arguments after the injected agent. * @param payload - the event's payload fields; `agent` is injected.
*/ */
export function emitAgentEvent<K extends AgentSubjectEvent>( export function emitAgentEvent<K extends AgentSubjectEvent>(
ctx: Context, ctx: Context,
agent: Agent, agent: Agent,
name: K, name: K,
...rest: Tail<K> payload: PayloadRest<K>,
): void { ): void {
agentEvents(ctx, agent).emit(name, ...rest) agentEvents(ctx, agent).emit(name, payload)
} }
/** /**

View File

@@ -498,7 +498,7 @@ export class AgentRegistry extends Service {
/** Emit the paired disposal edge through the entry's stable carrier. */ /** Emit the paired disposal edge through the entry's stable carrier. */
private emitDisposed(entry: AgentEntry): void { private emitDisposed(entry: AgentEntry): void {
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent] const args: unknown[] = [entry.carrier, 'agent/disposed', { agent: entry.agent }]
for (const callback of this.ctx.events.dispatch('emit', args)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
try { try {
const returned: unknown = callback(...args) const returned: unknown = callback(...args)
@@ -530,7 +530,7 @@ export class AgentRegistry extends Service {
// lifecycle edge; detach still pairs a partially delivered first edge. // lifecycle edge; detach still pairs a partially delivered first edge.
entry.announcing = true entry.announcing = true
entry.announced = true entry.announced = true
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent] const args: unknown[] = [entry.carrier, 'agent/created', { agent: entry.agent }]
try { try {
for (const callback of this.ctx.events.dispatch('emit', args)) { for (const callback of this.ctx.events.dispatch('emit', args)) {
// A synchronous creation failure vetoes publication and rolls back. // A synchronous creation failure vetoes publication and rolls back.

View File

@@ -14,7 +14,7 @@ export const inject = ['invariants']
/** Install the agent contribution into its child registration fiber. */ /** Install the agent contribution into its child registration fiber. */
const install: InvariantInstaller = (ctx, fail) => { const install: InvariantInstaller = (ctx, fail) => {
const lastStatus = new WeakMap<Agent, AgentStatus>() const lastStatus = new WeakMap<Agent, AgentStatus>()
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
const previous = lastStatus.get(agent) const previous = lastStatus.get(agent)
if (previous === status) { if (previous === status) {
fail(`agent/status repeated ${status} (no-op transition)`) fail(`agent/status repeated ${status} (no-op transition)`)

View File

@@ -53,7 +53,7 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
}) })
const disposeRequest = agentCtx.on( const disposeRequest = agentCtx.on(
'agent/request', 'agent/request',
async (_agent, _turn, _step, _signal, next): Promise<LlmCallConfig> => { async (_payload, next): Promise<LlmCallConfig> => {
const resolved = await next() const resolved = await next()
const selected = target.assembled const selected = target.assembled
if (selected === undefined) return resolved if (selected === undefined) return resolved

View File

@@ -48,35 +48,11 @@ export interface CancelOptions {
*/ */
export type AgentStatus = 'idle' | 'running' export type AgentStatus = 'idle' | 'running'
/** Coordinates and cancellation for a proposed step. */
export interface PreStepContext {
/** Turn that will own the step. */
readonly turn: number
/** Step proposed by the loop. */
readonly step: number
/** Current turn cancellation signal. */
readonly signal: AbortSignal
}
/** Whether and with which messages the loop enters a proposed step. */ /** Whether and with which messages the loop enters a proposed step. */
export type PreStepDecision = export type PreStepDecision =
| { kind: 'reject' } | { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[] } | { kind: 'enter'; messages: UserMessage[] }
/** One failed model-request attempt presented to recovery listeners. */
export interface RequestFailureContext {
/** Turn containing the failed request. */
readonly turn: number
/** Step containing the failed request attempt. */
readonly step: number
/** Provider selected for the failed request. */
readonly provider: string
/** Serializable facts normalized at the final adapter boundary. */
readonly failure: LlmFailure
/** Policy of the adapter registration that served the failed request. */
readonly retryPolicy: ResolvedRetryPolicy | undefined
}
/** Action returned by a listener that owns model-request recovery. */ /** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined export type RequestErrorAction = { kind: 'retry' } | undefined
@@ -171,105 +147,112 @@ declare module 'cordis' {
* Synchronous listener failure vetoes publication, while returned-promise * Synchronous listener failure vetoes publication, while returned-promise
* rejection is reported. Detach requested during dispatch waits until every * rejection is reported. Detach requested during dispatch waits until every
* creation listener has observed the stable entry. * creation listener has observed the stable entry.
* @param agent - the newly registered agent with its live session and completed setup. * @param payload.agent - the newly registered agent with its live session and completed setup.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/created'(this: Scoped<Agent>, agent: Agent): void 'agent/created'(this: Scoped<Agent>, payload: { agent: Agent }): void
/** /**
* An agent left the registry; AgentLoop emits this after driver quiescence * An agent left the registry; AgentLoop emits this after driver quiescence
* and scoped-registration unwind, but before session detachment. Custom * and scoped-registration unwind, but before session detachment. Custom
* registry users own their driver-ordering contract. * registry users own their driver-ordering contract.
* @param agent - the exact agent removed from the registry. * @param payload.agent - the exact agent removed from the registry.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void 'agent/disposed'(this: Scoped<Agent>, payload: { agent: Agent }): void
/** /**
* Agent status changed (`idle` ⇄ `running`). A waking delivery enters * Agent status changed (`idle` ⇄ `running`). A waking delivery enters
* `running` synchronously after reserving cancellation; `idle` means no * `running` synchronously after reserving cancellation; `idle` means no
* driver remains scheduled or active. * driver remains scheduled or active.
* @param agent - the agent whose status flipped. * @param payload.agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination). * @param payload.status - the status just entered (the transition's destination).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void 'agent/status'(this: Scoped<Agent>, payload: { agent: Agent; status: AgentStatus }): void
/** /**
* One message entered the live inbox. * One message entered the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the inserted message. * @param payload.message - the inserted message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/inserted'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/inserted'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
/** /**
* One message left the inbox inside its open turn. If the proposed step * One message left the inbox inside its open turn. If the proposed step
* is rejected, the claimed message ends here: it is neither discarded nor * is rejected, the claimed message ends here: it is neither discarded nor
* re-emitted as a user/message, and the turn closes without a step. * re-emitted as a user/message, and the turn closes without a step.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the claimed message and owning turn. * @param payload.message - the claimed message.
* @param payload.turn - the owning turn.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/claimed'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage; turn: number }): void 'agent/inbox/claimed'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; turn: number }): void
/** /**
* One message was discarded from the live inbox. * One message was discarded from the live inbox.
* @param agent - the agent whose inbox changed. * @param payload.agent - the agent whose inbox changed.
* @param event - the discarded message. * @param payload.message - the discarded message.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/inbox/discarded'(this: Scoped<Agent>, agent: Agent, event: { message: UserMessage }): void 'agent/inbox/discarded'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage }): void
// ---- session lifecycle (emit) ---- // ---- session lifecycle (emit) ----
/** /**
* The session lifecycle began, once before the first turn. Use * The session lifecycle began, once before the first turn. Use
* `agent.inject()` to seed model-facing context. This is a notification, not * `agent.inject()` to seed model-facing context. This is a notification, not
* a veto; disposal requested by a lifecycle owner is rechecked before the * a veto; disposal requested by a lifecycle owner is rechecked before the
* driver starts. * driver starts.
* @param agent - the agent whose session lifecycle began. * @param payload.agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …). * @param payload.source - why the session started (fresh startup, resume, …).
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void 'agent/session-start'(this: Scoped<Agent>, payload: { agent: Agent; source: SessionStartSource }): void
// ---- the machine's extension seams ---- // ---- the machine's extension seams ----
/** /**
* Reject a proposed step or replace the messages that enter it. Calling * Reject a proposed step or replace the messages that enter it. Calling
* `next()` preserves the current messages. * `next()` preserves the current messages.
* @param agent - the agent proposing the step. * @param payload.agent - the agent proposing the step.
* @param messages - messages removed from the inbox for this step. * @param payload.messages - messages removed from the inbox for this step.
* @param context - proposed turn and step coordinates plus cancellation. * @param payload.turn - the turn that will own the step.
* @param payload.step - the step proposed by the loop.
* @param payload.signal - the current turn's cancellation signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, messages: UserMessage[], context: PreStepContext, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> 'agent/pre-step'(this: Scoped<Agent>, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise<PreStepDecision>): Promise<PreStepDecision>
/** /**
* Replace the frozen call configuration. `await next()` yields the config * Replace the frozen call configuration. `await next()` yields the config
* the machine would use (agent options on the first request, the logged * the machine would use (agent options on the first request, the logged
* header afterwards); return a replacement to switch. Model-visible * header afterwards); return a replacement to switch. Model-visible
* content must use logged channels; this seam cannot mutate messages. * content must use logged channels; this seam cannot mutate messages.
* @param agent - the agent making the model call. * @param payload.agent - the agent making the model call.
* @param turn - the open turn number. * @param payload.turn - the open turn number.
* @param step - the step whose request this is. * @param payload.step - the step whose request this is.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig> 'agent/request'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; signal: AbortSignal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/** /**
* Handle one failed model-request attempt before the loop retries or closes * Handle one failed model-request attempt before the loop retries or closes
* its step. A listener returns `{ kind: 'retry' }` without calling `next()` * its step. A listener returns `{ kind: 'retry' }` without calling `next()`
* when it owns recovery, or calls `next()` to delegate. The default * when it owns recovery, or calls `next()` to delegate. The default
* `undefined` leaves the failure terminal. * `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed. * @param payload.agent - the agent whose request failed.
* @param context - request coordinates, provider, normalized failure, and serving policy. * @param payload.turn - the turn containing the failed request.
* @param signal - the turn abort signal. * @param payload.step - the step containing the failed request attempt.
* @param payload.provider - the provider selected for the failed request.
* @param payload.failure - serializable facts normalized at the final adapter boundary.
* @param payload.retryPolicy - the policy of the adapter registration that served the failed request.
* @param payload.signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall * @mode waterfall
*/ */
'agent/request-error'(this: Scoped<Agent>, agent: Agent, context: RequestFailureContext, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction> 'agent/request-error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; provider: string; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined; signal: AbortSignal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
/** /**
* The turn is about to close: the model owes no response (no live tool * The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a * calls, no fresh steering). Awaited before the boundary commits — a
@@ -281,25 +264,25 @@ declare module 'cordis' {
* never short-circuits already-submitted next-step work: same-step * never short-circuits already-submitted next-step work: same-step
* `additionalContexts` or racing steering still runs, and the turn * `additionalContexts` or racing steering still runs, and the turn
* closes only when that inbox drains. * closes only when that inbox drains.
* @param agent - the agent whose turn is at its stop boundary. * @param payload.agent - the agent whose turn is at its stop boundary.
* @param turn - the turn about to close. * @param payload.turn - the turn about to close.
* @param signal - the current turn's explicit abort signal. * @param payload.signal - the current turn's explicit abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial * @mode serial
*/ */
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void 'agent/turn-stopping'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void
// ---- error notifications (emit) ---- // ---- error notifications (emit) ----
/** /**
* A step or turn errored. The machine reports a failure here even when * A step or turn errored. The machine reports a failure here even when
* the error has no in-turn position for a durable record. * the error has no in-turn position for a durable record.
* @param agent - the agent whose turn errored. * @param payload.agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced. * @param payload.turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced. * @param payload.step - the step at which the failure surfaced.
* @param error - the failure, verbatim. * @param payload.error - the failure, verbatim.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit * @mode emit
*/ */
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: unknown): void 'agent/error'(this: Scoped<Agent>, payload: { agent: Agent; turn: number; step: number; error: unknown }): void
} }
} }

View File

@@ -145,8 +145,8 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const agent = stubAgent('a1') const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent) const dispose = ctx.agents.register(agent)
@@ -195,9 +195,9 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/created', () => { throw new Error('creation veto') }) ctx.on('agent/created', () => { throw new Error('creation veto') })
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined()
@@ -213,7 +213,7 @@ describe('AgentRegistry', () => {
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never) ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
ctx.on('agent/disposed', () => { throw new Error('disposed sync') }) ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never) ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
ctx.on('agent/disposed', agent => void heard.push(agent.id)) ctx.on('agent/disposed', ({ agent }) => void heard.push(agent.id))
const dispose = ctx.agents.register(stubAgent('contained')) const dispose = ctx.agents.register(stubAgent('contained'))
await Promise.resolve() await Promise.resolve()
@@ -232,8 +232,8 @@ describe('AgentRegistry', () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(AgentRegistry) await ctx.plugin(AgentRegistry)
const lifecycle: string[] = [] const lifecycle: string[] = []
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`)) ctx.on('agent/created', ({ agent }) => void lifecycle.push(`created:${agent.id}`))
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) ctx.on('agent/disposed', ({ agent }) => void lifecycle.push(`disposed:${agent.id}`))
const first = stubAgent('split') const first = stubAgent('split')
const detachFirst = ctx.agents.enter(first, undefined) const detachFirst = ctx.agents.enter(first, undefined)
@@ -280,9 +280,9 @@ describe('agentEvents()', () => {
const agent = stubAgent('event') const agent = stubAgent('event')
ctx.on('agent/status', () => { throw new Error('sync listener') }) ctx.on('agent/status', () => { throw new Error('sync listener') })
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never) ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
ctx.on('agent/status', (_agent, status) => void heard.push(status)) ctx.on('agent/status', ({ status }) => void heard.push(status))
agentEvents(ctx, agent).emit('agent/status', 'running') agentEvents(ctx, agent).emit('agent/status', { status: 'running' })
await Promise.resolve() await Promise.resolve()
expect(heard).toEqual(['running']) expect(heard).toEqual(['running'])
expect(warnings).toEqual([ expect(warnings).toEqual([
@@ -296,12 +296,12 @@ describe('agentEvents()', () => {
const agent = stubAgent('serial-event') const agent = stubAgent('serial-event')
const signal = new AbortController().signal const signal = new AbortController().signal
const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = [] const heard: Array<{ agent: Agent; turn: number; signal: AbortSignal }> = []
ctx.on('agent/turn-stopping', async (subject, turn, receivedSignal) => { ctx.on('agent/turn-stopping', async ({ agent: subject, turn, signal: receivedSignal }) => {
await Promise.resolve() await Promise.resolve()
heard.push({ agent: subject, turn, signal: receivedSignal }) heard.push({ agent: subject, turn, signal: receivedSignal })
}) })
await agentEvents(ctx, agent).serial('agent/turn-stopping', 3, signal) await agentEvents(ctx, agent).serial('agent/turn-stopping', { turn: 3, signal })
expect(heard).toEqual([{ agent, turn: 3, signal }]) expect(heard).toEqual([{ agent, turn: 3, signal }])
}) })

View File

@@ -21,17 +21,17 @@ describe('agent status invariants', () => {
const ctx = await setup() const ctx = await setup()
const agent = mockAgent('a1') const agent = mockAgent('a1')
expect(() => { expect(() => {
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' })
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' })
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'idle') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'idle' })
}).not.toThrow() }).not.toThrow()
}) })
it('rejects a no-op transition', async () => { it('rejects a no-op transition', async () => {
const ctx = await setup() const ctx = await setup()
const agent = mockAgent('a3') const agent = mockAgent('a3')
ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' })
expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', agent, 'running') }) expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/status', { agent, status: 'running' }) })
.toThrow(/no-op transition/) .toThrow(/no-op transition/)
}) })
@@ -39,7 +39,7 @@ describe('agent status invariants', () => {
const ctx = await setup() const ctx = await setup()
const a = mockAgent('a5') const a = mockAgent('a5')
const b = mockAgent('b5') const b = mockAgent('b5')
ctx.emit(scopeTarget(a, a), 'agent/status', a, 'running') ctx.emit(scopeTarget(a, a), 'agent/status', { agent: a, status: 'running' })
expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', b, 'running') }).not.toThrow() expect(() => { ctx.emit(scopeTarget(b, b), 'agent/status', { agent: b, status: 'running' }) }).not.toThrow()
}) })
}) })

View File

@@ -21,7 +21,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed), 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed) )).resolves.toBe(seed)
target.current = { target.current = {
@@ -32,7 +32,7 @@ describe('installAgentLlmTarget()', () => {
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
target.current = { provider: 'beta', model: 'b1' } target.current = { provider: 'beta', model: 'b1' }
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed), 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toEqual({ )).resolves.toEqual({
provider: 'alpha', provider: 'alpha',
model: 'a1', model: 'a1',
@@ -48,13 +48,13 @@ describe('installAgentLlmTarget()', () => {
temperature: 0.2, temperature: 0.2,
} }
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(inherited), 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(inherited),
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
dispose() dispose()
expect((await ctx.systemPrompt.assemble()).variables).toEqual({}) expect((await ctx.systemPrompt.assemble()).variables).toEqual({})
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 2, 0, signal, () => Promise.resolve(seed), 'agent/request', { turn: 2, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toBe(seed) )).resolves.toBe(seed)
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })

View File

@@ -8,20 +8,20 @@
type ScopedSubjectResolver = (args: readonly unknown[]) => unknown type ScopedSubjectResolver = (args: readonly unknown[]) => unknown
const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | null>> = Object.freeze({
'agent/created': args => args[0], 'agent/created': args => (args[0] as Record<string, unknown>)['agent'],
'agent/disposed': args => args[0], 'agent/disposed': args => (args[0] as Record<string, unknown>)['agent'],
'agent/error': args => args[0], 'agent/error': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/claimed': args => args[0], 'agent/inbox/claimed': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/discarded': args => args[0], 'agent/inbox/discarded': args => (args[0] as Record<string, unknown>)['agent'],
'agent/inbox/inserted': args => args[0], 'agent/inbox/inserted': args => (args[0] as Record<string, unknown>)['agent'],
'agent/pre-step': args => args[0], 'agent/pre-step': args => (args[0] as Record<string, unknown>)['agent'],
'agent/request': args => args[0], 'agent/request': args => (args[0] as Record<string, unknown>)['agent'],
'agent/request-error': args => args[0], 'agent/request-error': args => (args[0] as Record<string, unknown>)['agent'],
'agent/session-start': args => args[0], 'agent/session-start': args => (args[0] as Record<string, unknown>)['agent'],
'agent/status': args => args[0], 'agent/status': args => (args[0] as Record<string, unknown>)['agent'],
'agent/turn-stopping': args => args[0], 'agent/turn-stopping': args => (args[0] as Record<string, unknown>)['agent'],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'], 'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0], 'goal/changed': args => (args[0] as Record<string, unknown>)['agent'],
'session/created': null, 'session/created': null,
'session/disposed': null, 'session/disposed': null,
'session/event': null, 'session/event': null,

View File

@@ -28,7 +28,7 @@ describe('scoped-dispatch invariants', () => {
const ctx = await setup() const ctx = await setup()
expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow() expect(() => { emit(ctx, undefined, 'ordinary/event', []) }).not.toThrow()
const agent = { id: 'a1' } const agent = { id: 'a1' }
expect(() => { emit(ctx, undefined, 'agent/error', [agent, 1, 0, new Error('x')]) }) expect(() => { emit(ctx, undefined, 'agent/error', [{ agent, turn: 1, step: 0, error: new Error('x') }]) })
.toThrow(/dispatched without a scope carrier/) .toThrow(/dispatched without a scope carrier/)
}) })
@@ -45,34 +45,34 @@ describe('scoped-dispatch invariants', () => {
source: { kind: 'user' }, source: { kind: 'user' },
}) })
const agentRows = { const agentRows = {
'agent/created': [agent], 'agent/created': [{ agent }],
'agent/disposed': [agent], 'agent/disposed': [{ agent }],
'agent/status': [agent, 'idle'], 'agent/status': [{ agent, status: 'idle' }],
'agent/inbox/inserted': [agent, { message }], 'agent/inbox/inserted': [{ agent, message }],
'agent/inbox/claimed': [agent, { message, turn: 1 }], 'agent/inbox/claimed': [{ agent, message, turn: 1 }],
'agent/inbox/discarded': [agent, { message }], 'agent/inbox/discarded': [{ agent, message }],
'agent/session-start': [agent, 'startup'], 'agent/session-start': [{ agent, source: 'startup' }],
'agent/pre-step': [agent, [message], { turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })], 'agent/pre-step': [{ agent, messages: [message], turn: 1, step: 1, signal }, () => Promise.resolve({ kind: 'enter', messages: [message] })],
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)], 'agent/request': [{ agent, turn: 1, step: 1, signal }, () => Promise.resolve(config)],
'agent/request-error': [ 'agent/request-error': [
agent,
{ {
agent,
turn: 1, turn: 1,
step: 1, step: 1,
provider: 'p', provider: 'p',
failure: { message: 'request', code: 'UNKNOWN' }, failure: { message: 'request', code: 'UNKNOWN' },
retryPolicy: undefined, retryPolicy: undefined,
signal,
}, },
signal,
() => Promise.resolve(undefined), () => Promise.resolve(undefined),
], ],
'agent/turn-stopping': [agent, 1, signal], 'agent/turn-stopping': [{ agent, turn: 1, signal }],
'agent/error': [agent, 1, 0, new Error('x')], 'agent/error': [{ agent, turn: 1, step: 0, error: new Error('x') }],
} satisfies { [K in AgentEventName]: EventArgs<K> } } satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [ const rows: Array<[string, unknown[]]> = [
...Object.entries(agentRows), ...Object.entries(agentRows),
['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]],
['goal/changed', [agent, { operation: 'create', ref: { id: 'goal-a', revision: 1 } }]], ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]],
['system-prompt/assemble', [[], { scope: agent }]], ['system-prompt/assemble', [[], { scope: agent }]],
['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]],
['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]],

View File

@@ -48,7 +48,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const agent = ctx.agentLoop.create(SessionId(`acp-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal }, 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -41,7 +41,7 @@ async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd }) const agent = ctx.agentLoop.create(SessionId('agent-spine-prefix'), {}, { cwd })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal }, 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -45,7 +45,7 @@ async function composePrefix(ctx: Context): Promise<Message[]> {
const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' }) const agent = ctx.agentLoop.create(SessionId(`cli-demo-prefix-${randomUUID()}`), {}, { cwd: '/tmp' })
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal }, 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -401,7 +401,7 @@ describe('runOneShot and executeCli', () => {
if (session === agent.session && event.type === 'assistant/message' if (session === agent.session && event.type === 'assistant/message'
&& event.data.turn === 1) startupStarted() && event.data.turn === 1) startupStarted()
}) })
ctx.on('agent/turn-stopping', async (subject, turn) => { ctx.on('agent/turn-stopping', async ({ agent: subject, turn }) => {
if (subject === agent && turn === 1) await releaseStartup.promise if (subject === agent && turn === 1) await releaseStartup.promise
}) })
agent.followup(createUserMessage({ agent.followup(createUserMessage({
@@ -432,7 +432,7 @@ describe('runOneShot and executeCli', () => {
} }
let replacementQueued = false let replacementQueued = false
ctx.on('agent/status', (subject, status) => { ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject !== agent || status !== 'idle' || replacementQueued) return if (subject !== agent || status !== 'idle' || replacementQueued) return
replacementQueued = true replacementQueued = true
agent.followup(createUserMessage({ agent.followup(createUserMessage({

View File

@@ -25,7 +25,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise<Context> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -243,20 +243,20 @@ export function apply(ctx: Context): void {
// One composite effect keeps the step fence installed until this // One composite effect keeps the step fence installed until this
// plugin's own scheduling tasks settle. // plugin's own scheduling tasks settle.
ctx.effect(function* () { ctx.effect(function* () {
ctx.on('agent/error', (agent) => { ctx.on('agent/error', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
disarm(state) disarm(state)
}) })
ctx.on('agent/created', (agent) => { stateFor(agent) }) ctx.on('agent/created', ({ agent }) => { stateFor(agent) })
ctx.on('agent/disposed', (agent) => { states.delete(agent) }) ctx.on('agent/disposed', ({ agent }) => { states.delete(agent) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
state.attempt = undefined state.attempt = undefined
state.competingQueued = false state.competingQueued = false
state.needsCheckpoint = false state.needsCheckpoint = false
}) })
ctx.on('agent/status', (agent, status) => { ctx.on('agent/status', ({ agent, status }) => {
const state = stateFor(agent) const state = stateFor(agent)
if (status === 'idle') { if (status === 'idle') {
state.competingQueued = false state.competingQueued = false
@@ -275,13 +275,13 @@ export function apply(ctx: Context): void {
requestDrive(state) requestDrive(state)
} }
}) })
ctx.on('goal/changed', (agent) => { ctx.on('goal/changed', ({ agent }) => {
const state = stateFor(agent) const state = stateFor(agent)
state.needsCheckpoint = true state.needsCheckpoint = true
requestDrive(state) requestDrive(state)
}) })
ctx.on('agent/inbox/inserted', (agent, { message }) => { ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return if (!agent.inbox.nextTurn.some(candidate => candidate.id === message.id)) return
const state = stateFor(agent) const state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
@@ -289,14 +289,14 @@ export function apply(ctx: Context): void {
state.competingQueued = true state.competingQueued = true
if (attempt?.phase === 'queued') attempt.stale = true if (attempt?.phase === 'queued') attempt.stale = true
}) })
ctx.on('agent/inbox/claimed', (agent, { message }) => { ctx.on('agent/inbox/claimed', ({ agent, message }) => {
const state = stateFor(agent) const state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
attempt.phase = 'claimed' attempt.phase = 'claimed'
} }
}) })
ctx.on('agent/inbox/discarded', (agent, { message }) => { ctx.on('agent/inbox/discarded', ({ agent, message }) => {
const state = stateFor(agent) const state = stateFor(agent)
const attempt = state.attempt const attempt = state.attempt
if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) { if (attempt !== undefined && sameQueued(message.content, message.source, attempt)) {
@@ -346,7 +346,7 @@ export function apply(ctx: Context): void {
&& source.round === goal.roundsStarted + 1 && source.round === goal.roundsStarted + 1
} }
ctx.on('agent/pre-step', async (agent, messages, { signal }, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent, messages, signal }, next): Promise<PreStepDecision> => {
const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } => const submitted = messages.find((message): message is UserMessage & { source: GoalMessageSource } =>
isGoalRoundSource(message.source)) isGoalRoundSource(message.source))
if (submitted === undefined) return next() if (submitted === undefined) return next()

View File

@@ -107,7 +107,7 @@ function onInboxMessage(
agent: Agent, agent: Agent,
listener: (message: UserMessage) => void, listener: (message: UserMessage) => void,
): () => void { ): () => void {
return ctx.on('agent/inbox/inserted', (subject, { message }) => { return ctx.on('agent/inbox/inserted', ({ agent: subject, message }) => {
if (subject === agent) listener(message) if (subject === agent) listener(message)
}) })
} }
@@ -118,7 +118,7 @@ function onClaimedMessage(
agent: Agent, agent: Agent,
listener: (message: UserMessage) => void, listener: (message: UserMessage) => void,
): () => void { ): () => void {
return ctx.on('agent/inbox/claimed', (subject, { message }) => { return ctx.on('agent/inbox/claimed', ({ agent: subject, message }) => {
if (subject === agent) listener(message) if (subject === agent) listener(message)
}) })
} }
@@ -247,7 +247,7 @@ describe('same-session goal driving', () => {
it('maps a downstream step rejection to blocked without entering the round', async () => { it('maps a downstream step rejection to blocked without entering the round', async () => {
const test = await harness([]) const test = await harness([])
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const }) ? Promise.resolve({ kind: 'reject' as const })
: next()) : next())
test.ctx.goals.create(test.agent, { objective: 'respect policy' }) test.ctx.goals.create(test.agent, { objective: 'respect policy' })
@@ -265,10 +265,10 @@ describe('same-session goal driving', () => {
it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => { it('does not reserve again when a stopped-goal observer queues cancel-scoped work', async () => {
const test = await harness([textResponse('human follow-up')]) const test = await harness([textResponse('human follow-up')])
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => messages[0]?.source.kind === 'goal' test.ctx.on('agent/pre-step', ({ messages }, next) => messages[0]?.source.kind === 'goal'
? Promise.resolve({ kind: 'reject' as const }) ? Promise.resolve({ kind: 'reject' as const })
: next()) : next())
test.ctx.on('goal/changed', (agent, change) => { test.ctx.on('goal/changed', ({ agent, change }) => {
if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } })) if (change.operation === 'block') agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the blocker' }], source: { kind: 'user' } }))
}) })
test.ctx.goals.create(test.agent, { objective: 'stop and inspect' }) test.ctx.goals.create(test.agent, { objective: 'stop and inspect' })
@@ -370,7 +370,7 @@ describe('same-session goal driving', () => {
it('rechecks revision after downstream prompt hooks before admitting', async () => { it('rechecks revision after downstream prompt hooks before admitting', async () => {
const test = await harness([textResponse('new revision')]) const test = await harness([textResponse('new revision')])
let edited = false let edited = false
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
if (messages[0]?.source.kind === 'goal' && !edited) { if (messages[0]?.source.kind === 'goal' && !edited) {
edited = true edited = true
const current = test.ctx.goals.get(agent) const current = test.ctx.goals.get(agent)
@@ -389,7 +389,7 @@ describe('same-session goal driving', () => {
it('does not block a goal that downstream paused before rejecting its prompt', async () => { it('does not block a goal that downstream paused before rejecting its prompt', async () => {
const test = await harness([]) const test = await harness([])
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) { if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0)) {
return next() return next()
} }
@@ -432,7 +432,7 @@ describe('same-session goal driving', () => {
test.agent.inbox.prepend('next-step', roundZeroContext) test.agent.inbox.prepend('next-step', roundZeroContext)
}) })
let edited = false let edited = false
test.ctx.on('agent/pre-step', async (agent, messages, _context, next) => { test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
const decision = await next() const decision = await next()
if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision if (!messages.some(message => message.source.kind === 'goal' && message.source.round > 0) || edited) return decision
edited = true edited = true
@@ -513,8 +513,10 @@ describe('same-session goal driving', () => {
const test = await harness([]) const test = await harness([])
test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed'))) test.ctx.on('session/flush', () => Promise.reject(new Error('clear checkpoint failed')))
agentEvents(test.ctx, test.agent).emit('goal/changed', { agentEvents(test.ctx, test.agent).emit('goal/changed', {
operation: 'clear', change: {
ref: { id: GoalId('cleared-goal'), revision: 2 }, operation: 'clear',
ref: { id: GoalId('cleared-goal'), revision: 2 },
},
}) })
await new Promise<void>((resolve) => { setImmediate(resolve) }) await new Promise<void>((resolve) => { setImmediate(resolve) })
@@ -529,7 +531,7 @@ describe('same-session goal driving', () => {
]) ])
// The llm-retry shape: schedule one retry for the failed goal-round request. // The llm-retry shape: schedule one retry for the failed goal-round request.
let retried = false let retried = false
test.ctx.on('agent/request-error', async (_subject) => { test.ctx.on('agent/request-error', async (_payload) => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } return { kind: 'retry' }
@@ -552,7 +554,7 @@ describe('same-session goal driving', () => {
// attempt through cancel-requested) and THEN throws: the catch finds no // attempt through cancel-requested) and THEN throws: the catch finds no
// matching reservation and must not reschedule a paused goal. // matching reservation and must not reschedule a paused goal.
let fired = false let fired = false
test.ctx.on('agent/pre-step', async (agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', async ({ agent, messages }, next) => {
if (messages[0]?.source.kind === 'goal' && !fired) { if (messages[0]?.source.kind === 'goal' && !fired) {
fired = true fired = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -576,7 +578,7 @@ describe('same-session goal driving', () => {
// Registered after goal-session's own listener: the throw propagates back // Registered after goal-session's own listener: the throw propagates back
// through goal-session's next() await, dropping the whole step proposal. // through goal-session's next() await, dropping the whole step proposal.
let threw = false let threw = false
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', async ({ messages }, next) => {
if (messages[0]?.source.kind === 'goal' && !threw) { if (messages[0]?.source.kind === 'goal' && !threw) {
threw = true threw = true
throw new Error('downstream pre-step hook exploded') throw new Error('downstream pre-step hook exploded')
@@ -598,7 +600,7 @@ describe('same-session goal driving', () => {
textResponse('goal round ran'), textResponse('goal round ran'),
]) ])
let retried = false let retried = false
test.ctx.on('agent/request-error', async (_subject) => { test.ctx.on('agent/request-error', async (_payload) => {
if (!retried) { if (!retried) {
retried = true retried = true
return { kind: 'retry' } return { kind: 'retry' }
@@ -721,7 +723,7 @@ describe('same-session goal driving', () => {
it('fails a post-hook read closed before the prompt can enter history', async () => { it('fails a post-hook read closed before the prompt can enter history', async () => {
const test = await harness([]) const test = await harness([])
let armed = true let armed = true
test.ctx.on('agent/pre-step', (_agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', ({ messages }, next) => {
if (messages[0]?.source.kind === 'goal' && armed) { if (messages[0]?.source.kind === 'goal' && armed) {
armed = false armed = false
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
@@ -809,7 +811,7 @@ describe('same-session goal driving', () => {
it('rejects the step when downstream cancellation clears the reservation', async () => { it('rejects the step when downstream cancellation clears the reservation', async () => {
const test = await harness([]) const test = await harness([])
let cancelled = false let cancelled = false
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
if (messages[0]?.source.kind === 'goal' && !cancelled) { if (messages[0]?.source.kind === 'goal' && !cancelled) {
cancelled = true cancelled = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -864,7 +866,7 @@ describe('same-session goal driving', () => {
it('resets process-local scheduling state at a session-start edge', async () => { it('resets process-local scheduling state at a session-start edge', async () => {
const test = await harness([textResponse('after explicit resume')]) const test = await harness([textResponse('after explicit resume')])
const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 }) const created = test.ctx.goals.create(test.agent, { objective: 'restart safely', maxGoalRounds: 1 })
agentEvents(test.ctx, test.agent).emit('agent/session-start', 'resume') agentEvents(test.ctx, test.agent).emit('agent/session-start', { source: 'resume' })
await Promise.resolve() await Promise.resolve()
expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 }) expect(test.ctx.goals.get(test.agent)).toMatchObject({ activation: 'disarmed', roundsStarted: 0 })
@@ -898,7 +900,7 @@ describe('same-session goal driving', () => {
const test = await harness([textResponse('round one')]) const test = await harness([textResponse('round one')])
test.ctx.on('session/event', (session, event) => { test.ctx.on('session/event', (session, event) => {
if (session === test.agent.session && event.type === 'turn/end') { if (session === test.agent.session && event.type === 'turn/end') {
agentEvents(test.ctx, test.agent).emit('agent/error', event.data.turn, 1, new Error('post-turn flush failed')) agentEvents(test.ctx, test.agent).emit('agent/error', { turn: event.data.turn, step: 1, error: new Error('post-turn flush failed') })
} }
}) })
test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 }) test.ctx.goals.create(test.agent, { objective: 'stop when durability is lost', maxGoalRounds: 8 })
@@ -923,7 +925,7 @@ describe('same-session goal driving', () => {
await handle.dispose() await handle.dispose()
const warn = vi.spyOn(test.ctx.logger, 'warn') const warn = vi.spyOn(test.ctx.logger, 'warn')
agentEvents(test.ctx, handle.agent).emit('agent/error', closed.data.turn, 1, new Error('late flush failure')) agentEvents(test.ctx, handle.agent).emit('agent/error', { turn: closed.data.turn, step: 1, error: new Error('late flush failure') })
expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined() expect(test.ctx.agents.get(handle.agent.id)).toBeUndefined()
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session')) expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('goal-session'))
@@ -959,7 +961,7 @@ describe('same-session goal driving', () => {
it('waits for work queued by a pause observer before considering the next round', async () => { it('waits for work queued by a pause observer before considering the next round', async () => {
const test = await harness(['hang', textResponse('inspection answer')]) const test = await harness(['hang', textResponse('inspection answer')])
test.ctx.on('goal/changed', (agent, change) => { test.ctx.on('goal/changed', ({ agent, change }) => {
if (agent === test.agent && change.operation === 'pause') { if (agent === test.agent && change.operation === 'pause') {
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } })) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'inspect the pause' }], source: { kind: 'user' } }))
} }
@@ -982,7 +984,7 @@ describe('same-session goal driving', () => {
it('does not re-block a goal the downstream veto already saw cancelled', async () => { it('does not re-block a goal the downstream veto already saw cancelled', async () => {
const test = await harness([]) const test = await harness([])
let vetoed = false let vetoed = false
test.ctx.on('agent/pre-step', (agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', ({ agent, messages }, next) => {
if (messages[0]?.source.kind === 'goal' && !vetoed) { if (messages[0]?.source.kind === 'goal' && !vetoed) {
vetoed = true vetoed = true
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
@@ -1007,7 +1009,7 @@ describe('same-session goal driving', () => {
it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => { it('awaits a claimed reservation stuck in pre-step during teardown without cancelling', async () => {
const test = await harness([]) const test = await harness([])
let release: (() => void) | undefined let release: (() => void) | undefined
test.ctx.on('agent/pre-step', async (_agent, messages, _signal, next) => { test.ctx.on('agent/pre-step', async ({ messages }, next) => {
if (messages[0]?.source.kind === 'goal' && release === undefined) { if (messages[0]?.source.kind === 'goal' && release === undefined) {
await new Promise<void>((resolve) => { release = resolve }) await new Promise<void>((resolve) => { release = resolve })
} }

View File

@@ -134,10 +134,10 @@ declare module 'cordis' {
* Goal mutation accepted by one live agent. The matching `goal/change` * Goal mutation accepted by one live agent. The matching `goal/change`
* session event has already committed. Listener failures are contained. * session event has already committed. Listener failures are contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @param agent - agent whose session owns the goal. * @param payload.agent - agent whose session owns the goal.
* @param change - fresh current projection or clear tombstone. * @param payload.change - fresh current projection or clear tombstone.
* @mode emit * @mode emit
*/ */
'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, agent: Agent, change: GoalChanged): void 'goal/changed'(this: import('@deepseek-ai/dsh-scope').Scoped<Agent>, payload: { agent: Agent; change: GoalChanged }): void
} }
} }

View File

@@ -193,7 +193,7 @@ export class GoalService extends Service {
this.resolved = { this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
} }
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', ({ agent }) => {
this.cache(agent.session).activation = 'disarmed' this.cache(agent.session).activation = 'disarmed'
}) })
// The `goal` projection unit: last-wins fold of goal/change whole values // The `goal` projection unit: last-wins fold of goal/change whole values
@@ -547,7 +547,7 @@ export class GoalService extends Service {
ref: { ...ref }, ref: { ...ref },
...goal === undefined ? {} : { goal }, ...goal === undefined ? {} : { goal },
} }
agentEvents(this.ctx, agent).emit('goal/changed', notification) agentEvents(this.ctx, agent).emit('goal/changed', { change: notification })
} }
/** Build a detached current view. */ /** Build a detached current view. */

View File

@@ -83,7 +83,7 @@ describe('GoalService creation and replay', () => {
vi.setSystemTime(1_700_000_000_000) vi.setSystemTime(1_700_000_000_000)
const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 }) const { ctx, agent, session } = await harness({ defaultMaxGoalRounds: 17 })
const seen: string[] = [] const seen: string[] = []
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) })
const goal = ctx.goals.create(agent, { objective: ' finish the feature ' }) const goal = ctx.goals.create(agent, { objective: ' finish the feature ' })
@@ -191,7 +191,7 @@ describe('GoalService creation and replay', () => {
const { ctx, agent, session } = await harness() const { ctx, agent, session } = await harness()
let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' }) let goal = ctx.goals.create(agent, { objective: 'stay stopped after resume' })
expect(goal.activation).toBe('armed') expect(goal.activation).toBe('armed')
agentEvents(ctx, agent).emit('agent/session-start', 'resume') agentEvents(ctx, agent).emit('agent/session-start', { source: 'resume' })
expect(ctx.goals.get(agent)?.activation).toBe('disarmed') expect(ctx.goals.get(agent)?.activation).toBe('disarmed')
goal = ctx.goals.resume(agent, goal) goal = ctx.goals.resume(agent, goal)
expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 }) expect(goal).toMatchObject({ phase: 'active', activation: 'armed', revision: 2 })
@@ -223,7 +223,7 @@ describe('GoalService creation and replay', () => {
await fiber.dispose() await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined() expect(ctx.get('goals')).toBeUndefined()
agentEvents(ctx, stub.agent).emit('agent/session-start', 'resume') agentEvents(ctx, stub.agent).emit('agent/session-start', { source: 'resume' })
expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' }) expect(first.get(stub.agent)).toMatchObject({ id: goal.id, activation: 'armed' })
await ctx.plugin(GoalService) await ctx.plugin(GoalService)
@@ -384,7 +384,7 @@ describe('GoalService mutations', () => {
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
const seen: string[] = [] const seen: string[] = []
ctx.on('goal/changed', () => { throw new Error('broken observer') }) ctx.on('goal/changed', () => { throw new Error('broken observer') })
ctx.on('goal/changed', (_subject, change) => { seen.push(change.operation) }) ctx.on('goal/changed', ({ change }) => { seen.push(change.operation) })
expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active') expect(ctx.goals.create(agent, { objective: 'notify' }).phase).toBe('active')
expect(seen).toEqual(['create']) expect(seen).toEqual(['create'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('broken observer'))

View File

@@ -399,7 +399,7 @@ describe('goal tool state transitions', () => {
let turn = openTurn(root, { kind: 'user' }) let turn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'continue later' }) const created = ctx.goals.create(root.agent, { objective: 'continue later' })
closeTurn(root, turn) closeTurn(root, turn)
agentEvents(ctx, root.agent).emit('agent/session-start', 'resume') agentEvents(ctx, root.agent).emit('agent/session-start', { source: 'resume' })
expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed') expect(ctx.goals.get(root.agent)?.activation).toBe('disarmed')
turn = openTurn(root, { kind: 'user' }, '继续') turn = openTurn(root, { kind: 'user' }, '继续')
const resumed = await execute(ctx, 'update_goal', { const resumed = await execute(ctx, 'update_goal', {

View File

@@ -223,7 +223,7 @@ export function apply(ctx: Context, config: Config): void {
// A user interjection changes the context; repetition across it is not a // A user interjection changes the context; repetition across it is not a
// loop. Pure reset hook: always delegates (attaching nothing, vetoing // loop. Pure reset hook: always delegates (attaching nothing, vetoing
// nothing). // nothing).
ctx.on('agent/pre-step', (agent, messages, _context, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', ({ agent, messages }, next): Promise<PreStepDecision> => {
if (messages.some(message => message.source.kind === 'user')) chains.delete(agent) if (messages.some(message => message.source.kind === 'user')) chains.delete(agent)
return next() return next()
}) })

View File

@@ -32,7 +32,7 @@ async function harness(config: Config = {}): Promise<Context> {
} }
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) return new Promise((resolve) => { const d = ctx.on('agent/status', ({ agent: s, status: st }) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
} }
/** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */ /** Every injected-context user message in the agent's log, flattened to joined text + source for terse assertions. */

View File

@@ -203,7 +203,7 @@ export function apply(ctx: Context, config: Config): void {
// SessionStart injects context when its detached hook resolves; a slow hook // SessionStart injects context when its detached hook resolves; a slow hook
// may miss the first request. // may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery. // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', (agent, source) => { ctx.on('agent/session-start', ({ agent, source }) => {
detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal }) detached.track(runPoint('SessionStart', source, sessionStartPayload(ctx, agent, source), { agent, signal: detached.signal })
.then((merged) => { .then((merged) => {
const context = contextFrom(merged) const context = contextFrom(merged)
@@ -216,7 +216,7 @@ export function apply(ctx: Context, config: Config): void {
// --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no // --- UserPromptSubmit → PreStepDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). --- // matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next() if (messages.length === 0) return next()
const content = messages.flatMap(message => message.content) const content = messages.flatMap(message => message.content)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal }) const merged = await runPoint('UserPromptSubmit', '', promptPayload(ctx, agent, content), { agent, turn, signal })
@@ -267,7 +267,7 @@ export function apply(ctx: Context, config: Config): void {
// A blocking Stop hook steers at the stopping boundary, which makes the // A blocking Stop hook steers at the stopping boundary, which makes the
// machine observe pending input and run another step. // machine observe pending input and run another step.
// TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile. // TODO(stop-loop-guard): cap consecutive forced continuations; hooks must self-limit meanwhile.
ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise<void> => { ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal }) const merged = await runPoint('Stop', '', stopPayload(ctx, agent), { agent, turn, signal })
if (merged.decision === 'deny') { if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation. // A blocking Stop hook forces continuation.

View File

@@ -520,7 +520,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter) const ctx = await harness(path, adapter)
ctx.on('agent/pre-step', async (_agent, messages) => ({ ctx.on('agent/pre-step', async ({ messages }) => ({
kind: 'enter' as const, kind: 'enter' as const,
messages: [{ messages: [{
...messages[0]!, ...messages[0]!,

View File

@@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void {
// SessionStart injects plain stdout when its detached hook resolves; a slow // SessionStart injects plain stdout when its detached hook resolves; a slow
// hook may miss the first request. // hook may miss the first request.
// TODO(session-start-gating): add a startup gate before promising first-turn delivery. // TODO(session-start-gating): add a startup gate before promising first-turn delivery.
ctx.on('agent/session-start', (agent, source) => { ctx.on('agent/session-start', ({ agent, source }) => {
detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal }) detached.track(runPoint('SessionStart', source, { ...base(ctx, agent, 'SessionStart', model), source }, { agent, plainStdoutAsContext: true, signal: detached.signal })
.then((merged) => { .then((merged) => {
const context = contextFrom(merged) const context = contextFrom(merged)
@@ -196,7 +196,7 @@ export function apply(ctx: Context, config: Config): void {
}) })
// UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask.
ctx.on('agent/pre-step', async (agent, messages, { turn, signal }, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent, messages, turn, signal }, next): Promise<PreStepDecision> => {
if (messages.length === 0) return next() if (messages.length === 0) return next()
const payload = { const payload = {
...base(ctx, agent, 'UserPromptSubmit', model), ...base(ctx, agent, 'UserPromptSubmit', model),
@@ -257,7 +257,7 @@ export function apply(ctx: Context, config: Config): void {
// TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can // TODO(stop-loop-guard): Codex supplies `stop_hook_active` so a Stop hook can
// avoid continuing the same turn indefinitely. It is always false here, so an // avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits. // unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-stopping', async (agent, turn, signal): Promise<void> => { ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise<void> => {
const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
/* jscpd:ignore-end */ /* jscpd:ignore-end */
if (merged.decision === 'deny') { if (merged.decision === 'deny') {

View File

@@ -128,7 +128,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] }) hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n') }] }] })
const adapter = new MockAdapter([textResponse('ok')]) const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(join(d, 'hooks.json'), adapter) const ctx = await harness(join(d, 'hooks.json'), adapter)
ctx.on('agent/pre-step', async (_agent, messages) => ({ ctx.on('agent/pre-step', async ({ messages }) => ({
kind: 'enter' as const, kind: 'enter' as const,
messages: [{ messages: [{
...messages[0]!, ...messages[0]!,

View File

@@ -2595,10 +2595,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
ctx.on('session/disposed', (session: Session) => { ctx.on('session/disposed', (session: Session) => {
queue.push(frame({ type: 'host/session-removed', sessionId: session.id })) queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}), }),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => { ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => {
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' })) queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}), }),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => { ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) })) queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
}), }),
ctx.on('domain/changed', (change) => { ctx.on('domain/changed', (change) => {

View File

@@ -280,7 +280,7 @@ describe('sessions.fork', () => {
}) })
const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' }
await expect(agentEvents(child.ctx, child).waterfall( await expect(agentEvents(child.ctx, child).waterfall(
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(fallback), 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback),
)).resolves.toMatchObject({ )).resolves.toMatchObject({
provider: 'inherited-provider', provider: 'inherited-provider',
model: 'inherited-model', model: 'inherited-model',

View File

@@ -181,13 +181,13 @@ describe('Web session model selection', () => {
reasoningEffort: 'max', reasoningEffort: 'max',
}) })
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed), 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' }) )).resolves.toMatchObject({ provider: 'deepseek-official', model: 'deepseek-chat' })
expect((await ctx.systemPrompt.assemble()).variables) expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' })
await expect(agentEvents(ctx, agent).waterfall( await expect(agentEvents(ctx, agent).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(seed), 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed),
)).resolves.toMatchObject({ )).resolves.toMatchObject({
provider: 'deepseek-official', provider: 'deepseek-official',
model: 'private-preview', model: 'private-preview',

View File

@@ -5,9 +5,9 @@
* @module @deepseek-ai/dsh-llm-retry * @module @deepseek-ai/dsh-llm-retry
*/ */
import type { Context } from 'cordis' import type { Context, Events } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { Agent, RequestErrorAction, RequestFailureContext } from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -172,12 +172,9 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
} }
async function recover( async function recover(
agent: Agent, { agent, turn, step, provider, failure, retryPolicy: policy, signal }: Parameters<Events['agent/request-error']>[0],
context: RequestFailureContext,
signal: AbortSignal,
next: () => Promise<RequestErrorAction>, next: () => Promise<RequestErrorAction>,
): Promise<RequestErrorAction> { ): Promise<RequestErrorAction> {
const { turn, step, provider, failure, retryPolicy: policy } = context
if (policy === undefined) return next() if (policy === undefined) return next()
if (policy.mode === 'always') { if (policy.mode === 'always') {
if (signal.aborted || lifetime.signal.aborted) return if (signal.aborted || lifetime.signal.aborted) return
@@ -228,16 +225,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
} }
const disposeListener = ctx.on('agent/request-error', ( const disposeListener = ctx.on('agent/request-error', (
agent: Agent, payload,
context: RequestFailureContext,
signal: AbortSignal,
next: () => Promise<RequestErrorAction>, next: () => Promise<RequestErrorAction>,
) => { ) => {
// A waterfall may have captured this callback before its registration was // A waterfall may have captured this callback before its registration was
// removed. Lifetime cancellation must prevent that stale callback from // removed. Lifetime cancellation must prevent that stale callback from
// entering a downstream policy after disposal. // entering a downstream policy after disposal.
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined) if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
return track(recover(agent, context, signal, next)) return track(recover(payload, next))
}) })
ctx.effect(() => async () => { ctx.effect(() => async () => {

View File

@@ -506,7 +506,7 @@ describe('provider-routed retry policy', () => {
;({ ctx: context } = await harness(adapter, { ;({ ctx: context } = await harness(adapter, {
other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }), other: alwaysConfig({ initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }),
}, (ctx) => { }, (ctx) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ctx.on('agent/request', async (_payload, next) => ({
...await next(), ...await next(),
provider: 'other', provider: 'other',
})) }))
@@ -543,7 +543,7 @@ describe('provider-routed retry policy', () => {
backoff: { initialDelayMs: 1, maxDelayMs: 1 }, backoff: { initialDelayMs: 1, maxDelayMs: 1 },
}), }),
}, (ctx) => { }, (ctx) => {
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({ ctx.on('agent/request', async (_payload, next) => ({
...await next(), ...await next(),
provider: adapter.requests.length === 0 ? 'mock' : 'other', provider: adapter.requests.length === 0 ? 'mock' : 'other',
})) }))
@@ -881,7 +881,7 @@ describe('provider-routed retry policy', () => {
context = mounted.ctx context = mounted.ctx
const downstream = Promise.withResolvers<RequestErrorAction>() const downstream = Promise.withResolvers<RequestErrorAction>()
const entered = Promise.withResolvers<undefined>() const entered = Promise.withResolvers<undefined>()
context.on('agent/request-error', (agent) => { context.on('agent/request-error', ({ agent }) => {
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
entered.resolve(undefined) entered.resolve(undefined)
return downstream.promise return downstream.promise
@@ -917,7 +917,7 @@ describe('provider-routed retry policy', () => {
const captured = Promise.withResolvers<undefined>() const captured = Promise.withResolvers<undefined>()
let invokeCaptured: (() => Promise<void>) | undefined let invokeCaptured: (() => Promise<void>) | undefined
const mounted = await harness(adapter, {}, (ctx) => { const mounted = await harness(adapter, {}, (ctx) => {
ctx.on('agent/request-error', (_agent, _context, _signal, next) => { ctx.on('agent/request-error', (_payload, next) => {
return new Promise<RequestErrorAction>((resolve) => { return new Promise<RequestErrorAction>((resolve) => {
invokeCaptured = async () => { resolve(await next()) } invokeCaptured = async () => { resolve(await next()) }
captured.resolve(undefined) captured.resolve(undefined)
@@ -926,7 +926,7 @@ describe('provider-routed retry policy', () => {
}) })
context = mounted.ctx context = mounted.ctx
let downstreamCalls = 0 let downstreamCalls = 0
context.on('agent/request-error', async (_agent, _context, _signal, next) => { context.on('agent/request-error', async (_payload, next) => {
downstreamCalls += 1 downstreamCalls += 1
return next() return next()
}) })
@@ -980,7 +980,7 @@ describe('provider-routed retry policy', () => {
textResponse('must not run'), textResponse('must not run'),
]) ])
;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => { ;({ ctx: context } = await harness(adapter, { mock: policy }, (ctx) => {
ctx.on('agent/request-error', async (agent, _context, _signal, next) => { ctx.on('agent/request-error', async ({ agent }, next) => {
agent.cancel({ kind: 'user' }) agent.cancel({ kind: 'user' })
return next() return next()
}) })

View File

@@ -202,9 +202,7 @@ export class PlanModeService extends Service {
// the session. A failed append remains pending for a later boundary, and // the session. A failed append remains pending for a later boundary, and
// policy cannot block the step. // policy cannot block the step.
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent, { agent, signal },
_messages,
{ signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -42,7 +42,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()
@@ -139,7 +139,7 @@ describe('plan mode through the agent loop', () => {
]) ])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' }) const agent = ctx.agentLoop.create(SessionId('it-plan-retry-flip'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject, _context, _signal, next) => { ctx.on('agent/request-error', async ({ agent: subject }, next) => {
if (subject !== agent) return next() if (subject !== agent) return next()
ctx.planMode.set(agent, true) ctx.planMode.set(agent, true)
return { kind: 'retry' } return { kind: 'retry' }

View File

@@ -45,7 +45,7 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti
// Seeded plan state lands before the creation announcement, matching resume. // Seeded plan state lands before the creation announcement, matching resume.
if (active !== undefined) session.append('plan/mode', { active }) if (active !== undefined) session.append('plan/mode', { active })
// The loop announces creation after publication. // The loop announces creation after publication.
ctx.emit('agent/created', agent) ctx.emit('agent/created', { agent })
return agent return agent
} }
@@ -74,8 +74,7 @@ async function boundary(ctx: Context, agent: Agent & { session: Session }, type:
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await events.waterfall( const decision = await events.waterfall(
'agent/pre-step', 'agent/pre-step',
[message], { messages: [message], turn: 1, step: 1, signal },
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [message] }), () => Promise.resolve({ kind: 'enter' as const, messages: [message] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {

View File

@@ -76,7 +76,7 @@ export function apply(ctx: Context): void {
// Before each request, persist everything committed by the preceding step; // Before each request, persist everything committed by the preceding step;
// the first step's call is an intentional no-op beyond any prompt intake. // the first step's call is an intentional no-op beyond any prompt intake.
ctx.on('agent/pre-step', async (agent, _messages, _context, next): Promise<PreStepDecision> => { ctx.on('agent/pre-step', async ({ agent }, next): Promise<PreStepDecision> => {
await ctx.sessions.flush(agent.session) await ctx.sessions.flush(agent.session)
return next() return next()
}) })

View File

@@ -228,7 +228,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => {
ctx.on('session/flush', (current) => { flushed.push(current.id) }) ctx.on('session/flush', (current) => { flushed.push(current.id) })
const signal = new AbortController().signal const signal = new AbortController().signal
await agentEvents(ctx, agent).waterfall( await agentEvents(ctx, agent).waterfall(
'agent/pre-step', [], { turn: 1, step: 1, signal }, 'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter', messages: [] }), () => Promise.resolve({ kind: 'enter', messages: [] }),
) )
expect(flushed).toEqual([session.id]) expect(flushed).toEqual([session.id])

View File

@@ -135,9 +135,7 @@ export function apply(ctx: Context, config: Config = {}): void {
// Register after the tool so reverse teardown removes guidance first. Exact definition // Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog. // identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
ctx.on('agent/pre-step', async ( ctx.on('agent/pre-step', async (
agent: Agent, { agent, signal },
_messages,
{ signal },
next, next,
): Promise<PreStepDecision> => { ): Promise<PreStepDecision> => {
const decision = await next() const decision = await next()

View File

@@ -86,8 +86,7 @@ async function fireStep(ctx: Context, agent: Agent, turn: number, step: number):
const signal = new AbortController().signal const signal = new AbortController().signal
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn, step, signal },
{ turn, step, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {
@@ -105,8 +104,7 @@ async function proposeStep(
const signal = new AbortController().signal const signal = new AbortController().signal
return await agentEvents(ctx, agent).waterfall( return await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
messages, { messages, turn: 1, step: 1, signal },
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages }), () => Promise.resolve({ kind: 'enter' as const, messages }),
) )
} }
@@ -131,8 +129,7 @@ async function composePrefix(ctx: Context, cwd: string, signal = new AbortContro
async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> { async function composePrefixForAgent(ctx: Context, agent: Agent, signal = new AbortController().signal): Promise<Message[]> {
const decision = await agentEvents(ctx, agent).waterfall( const decision = await agentEvents(ctx, agent).waterfall(
'agent/pre-step', 'agent/pre-step',
[], { messages: [], turn: 1, step: 1, signal },
{ turn: 1, step: 1, signal },
() => Promise.resolve({ kind: 'enter' as const, messages: [] }), () => Promise.resolve({ kind: 'enter' as const, messages: [] }),
) )
if (decision.kind === 'enter') { if (decision.kind === 'enter') {
@@ -234,7 +231,7 @@ describe('dsh-tool-skill', () => {
source: 'runtime', source: 'runtime',
content: 'User-only body.', content: 'User-only body.',
}) })
ctx.on('agent/pre-step', async (_agent, _messages, _context, next) => { ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next() const decision = await next()
if (decision.kind === 'reject') return decision if (decision.kind === 'reject') return decision
return { return {

View File

@@ -75,7 +75,7 @@ function prePublicationAbort(): Error {
/** Append one one-shot descriptor inside the child's initial turn before its first request. */ /** Append one one-shot descriptor inside the child's initial turn before its first request. */
function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void { function attachDescriptorAppend(childCtx: Context, descriptor: SubagentDescriptorData): void {
let appended = false let appended = false
childCtx.on('agent/pre-step', async (agent, _messages, _context, next) => { childCtx.on('agent/pre-step', async ({ agent }, next) => {
const decision = await next() const decision = await next()
if (!appended && decision.kind === 'enter') { if (!appended && decision.kind === 'enter') {
appended = true appended = true

View File

@@ -42,7 +42,7 @@ export async function spawnHarness(workdir: string): Promise<Context> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -283,7 +283,7 @@ export class SubagentContinuationManager {
// child-first ordering. // child-first ordering.
const scope = ctx.plugin(function activationOwner() {}) const scope = ctx.plugin(function activationOwner() {})
this.ownerCtx = scope.ctx this.ownerCtx = scope.ctx
ctx.on('agent/disposed', (agent) => { ctx.on('agent/disposed', ({ agent }) => {
this.closingScopes.delete(agent) this.closingScopes.delete(agent)
}) })
ctx.effect(function* (this: SubagentContinuationManager) { ctx.effect(function* (this: SubagentContinuationManager) {
@@ -854,12 +854,12 @@ export class SubagentContinuationManager {
// quiet Agent from one whose accepted turn has not been admitted yet. // quiet Agent from one whose accepted turn has not been admitted yet.
// Registered through the child's own scoped context, so scope filtering // Registered through the child's own scoped context, so scope filtering
// already restricts both listeners to this exact agent. // already restricts both listeners to this exact agent.
handle.agent.ctx.on('agent/inbox/claimed', (_agent, { message }) => { handle.agent.ctx.on('agent/inbox/claimed', ({ message }) => {
/* v8 ignore next -- a claim of an id this manager never admitted needs /* v8 ignore next -- a claim of an id this manager never admitted needs
* another sender on the same child, which no current path allows. */ * another sender on the same child, which no current path allows. */
if (activation.accepted.delete(message.id)) this.wake(activation) if (activation.accepted.delete(message.id)) this.wake(activation)
}) })
handle.agent.ctx.on('agent/inbox/discarded', (_agent, { message }) => { handle.agent.ctx.on('agent/inbox/discarded', ({ message }) => {
if (activation.accepted.delete(message.id)) this.wake(activation) if (activation.accepted.delete(message.id)) this.wake(activation)
}) })
// Agent creation committed setup at its publication boundary; // Agent creation committed setup at its publication boundary;

View File

@@ -159,10 +159,10 @@ describe('SubagentService.startContinuable', () => {
it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => { it('returns both identities at inbox acceptance, without waiting for the turn or the log', async () => {
const { ctx, parent, adapter } = await setup([textResponse('first answer')]) const { ctx, parent, adapter } = await setup([textResponse('first answer')])
const enqueued: { id: MessageId; loggedYet: boolean }[] = [] const enqueued: { id: MessageId; loggedYet: boolean }[] = []
ctx.on('agent/inbox/inserted', (agent, accepted) => { ctx.on('agent/inbox/inserted', ({ agent, message }) => {
// Acceptance is the boundary `startContinuable` resolves at, so observe // Acceptance is the boundary `startContinuable` resolves at, so observe
// the log state exactly there rather than after later microtasks. // the log state exactly there rather than after later microtasks.
enqueued.push({ id: accepted.message.id, loggedYet: hasUserText(agent.session.events, 'child task') }) enqueued.push({ id: message.id, loggedYet: hasUserText(agent.session.events, 'child task') })
}) })
const started = await ctx.subagents.startContinuable(startSpec(parent)) const started = await ctx.subagents.startContinuable(startSpec(parent))
@@ -231,7 +231,7 @@ describe('SubagentService.startContinuable', () => {
const { ctx, parent } = await setup([textResponse('unused')]) const { ctx, parent } = await setup([textResponse('unused')])
const controller = new AbortController() const controller = new AbortController()
// Abort inside the child's creation window: setup runs before publication. // Abort inside the child's creation window: setup runs before publication.
ctx.on('agent/created', (child) => { ctx.on('agent/created', ({ agent: child }) => {
if (child !== parent) controller.abort('caller gave up') if (child !== parent) controller.abort('caller gave up')
}) })
@@ -753,7 +753,7 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() }) await vi.waitFor(() => { expect(ctx.agents.get(grandchild.childId)).toBeDefined() })
const disposals: SessionId[] = [] const disposals: SessionId[] = []
ctx.on('agent/disposed', (agent) => { disposals.push(agent.id) }) ctx.on('agent/disposed', ({ agent }) => { disposals.push(agent.id) })
const drained = drainManager(ctx) const drained = drainManager(ctx)
// Let the held model call observe its cancellation so quiescence can settle. // Let the held model call observe its cancellation so quiescence can settle.
hold.resolve(undefined) hold.resolve(undefined)
@@ -984,7 +984,7 @@ describe('continuable durability and teardown', () => {
const drains: Promise<void>[] = [] const drains: Promise<void>[] = []
const accepted: MessageId[] = [] const accepted: MessageId[] = []
ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) }) ctx.on('subagent/start', () => { drains.push(drainManager(ctx)) })
ctx.on('agent/inbox/inserted', (_agent, item) => { accepted.push(item.message.id) }) ctx.on('agent/inbox/inserted', ({ message }) => { accepted.push(message.id) })
await expect(ctx.subagents.startContinuable(startSpec(parent))) await expect(ctx.subagents.startContinuable(startSpec(parent)))
.rejects.toMatchObject({ code: 'DRAINING' }) .rejects.toMatchObject({ code: 'DRAINING' })
@@ -998,12 +998,12 @@ describe('continuable durability and teardown', () => {
const { ctx, parent } = await setup([]) const { ctx, parent } = await setup([])
const order: string[] = [] const order: string[] = []
const drains: Promise<void>[] = [] const drains: Promise<void>[] = []
ctx.on('agent/created', (child) => { ctx.on('agent/created', ({ agent: child }) => {
if (child === parent) return if (child === parent) return
const draining = drainManager(ctx).then(() => { order.push('drain') }) const draining = drainManager(ctx).then(() => { order.push('drain') })
drains.push(draining) drains.push(draining)
}) })
ctx.on('agent/disposed', (child) => { ctx.on('agent/disposed', ({ agent: child }) => {
if (child !== parent) order.push('disposed') if (child !== parent) order.push('disposed')
}) })
@@ -1025,8 +1025,8 @@ describe('continuable durability and teardown', () => {
await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) })
const child = ctx.agents.get(started.childId)! const child = ctx.agents.get(started.childId)!
const order: string[] = [] const order: string[] = []
child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { child.ctx.on('agent/inbox/inserted', ({ message }) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'before drain')) { if (message.content.some(block => block.type === 'text' && block.text === 'before drain')) {
order.push('enqueue') order.push('enqueue')
} }
}) })
@@ -1208,7 +1208,7 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = [] const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) }) ctx.on('subagent/end', (info) => { ends.push(info) })
// Block the resumed prompt so this epoch produces nothing of its own. // Block the resumed prompt so this epoch produces nothing of its own.
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
if (subject === parent) return next() if (subject === parent) return next()
return { kind: 'reject' } return { kind: 'reject' }
}) })
@@ -1356,8 +1356,8 @@ describe('continuable review regressions', () => {
// Cancel from the synchronous enqueue observer: the discard fires after the // Cancel from the synchronous enqueue observer: the discard fires after the
// id is recorded but before `followup()` returns. // id is recorded but before `followup()` returns.
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' }) child.cancel({ kind: 'user' })
} }
}) })
@@ -1388,8 +1388,8 @@ describe('continuable review regressions', () => {
await followup(ctx, parent, started.childId, message('queued')) await followup(ctx, parent, started.childId, message('queued'))
expect(activation.accepted.size).toBe(1) expect(activation.accepted.size).toBe(1)
const off = child.ctx.on('agent/inbox/inserted', (_agent, accepted) => { const off = child.ctx.on('agent/inbox/inserted', ({ message }) => {
if (accepted.message.content.some(block => block.type === 'text' && block.text === 'doomed')) { if (message.content.some(block => block.type === 'text' && block.text === 'doomed')) {
child.cancel({ kind: 'user' }) child.cancel({ kind: 'user' })
} }
}) })
@@ -1406,7 +1406,7 @@ describe('continuable review regressions', () => {
const ends: SubagentRunEndInfo[] = [] const ends: SubagentRunEndInfo[] = []
ctx.on('subagent/end', (info) => { ends.push(info) }) ctx.on('subagent/end', (info) => { ends.push(info) })
// Block admission so the child's only turn never opens. // Block admission so the child's only turn never opens.
ctx.on('agent/pre-step', async (subject, _messages, _context, next) => { ctx.on('agent/pre-step', async ({ agent: subject }, next) => {
if (subject === parent) return next() if (subject === parent) return next()
return { kind: 'reject' } return { kind: 'reject' }
}) })
@@ -1428,7 +1428,7 @@ describe('continuable review regressions', () => {
const registeredAtEnqueue: boolean[] = [] const registeredAtEnqueue: boolean[] = []
// A synchronous inbox observer runs before the admitting microtask, the // A synchronous inbox observer runs before the admitting microtask, the
// exact window where `Agent.status` is still idle. // exact window where `Agent.status` is still idle.
ctx.on('agent/inbox/inserted', (agent) => { ctx.on('agent/inbox/inserted', ({ agent }) => {
if (agent.session.header.parentSession !== undefined) { if (agent.session.header.parentSession !== undefined) {
registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent) registeredAtEnqueue.push(ctx.agents.get(agent.id) === agent)
} }

View File

@@ -164,9 +164,9 @@ describe('dsh-tool-subagent-report', () => {
const { started, child } = await startChild(ctx, parent) const { started, child } = await startChild(ctx, parent)
const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length const parentRequests = adapter.requests.filter(request => request.sessionId === parent.id).length
const enqueues: string[] = [] const enqueues: string[] = []
ctx.on('agent/inbox/inserted', (agent, item) => { ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (agent === parent) { if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
} }
}) })
@@ -190,9 +190,9 @@ describe('dsh-tool-subagent-report', () => {
const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } }) const { ctx, parent, adapter } = await setup({ config: { reportDelivery: 'wakeup' } })
const { child } = await startChild(ctx, parent) const { child } = await startChild(ctx, parent)
const enqueues: string[] = [] const enqueues: string[] = []
ctx.on('agent/inbox/inserted', (agent, item) => { ctx.on('agent/inbox/inserted', ({ agent, message }) => {
if (agent === parent) { if (agent === parent) {
enqueues.push(agent.inbox.nextTurn.some(message => message.id === item.message.id) ? 'queued' : 'steering') enqueues.push(agent.inbox.nextTurn.some(queued => queued.id === message.id) ? 'queued' : 'steering')
} }
}) })

View File

@@ -89,7 +89,7 @@ export class TelemetryCoordinator {
this.hintFlush(session) this.hintFlush(session)
}) })
}) })
ctx.on('agent/error', (agent, turn, step, error) => { ctx.on('agent/error', ({ agent, turn, step, error }) => {
this.contain(() => { this.contain(() => {
this.relayAgentError(agent, turn, step, error) this.relayAgentError(agent, turn, step, error)
}) })

View File

@@ -427,7 +427,7 @@ describe('TelemetryCoordinator lifecycle and containment', () => {
const session = liveSession(ctx, 'erring') const session = liveSession(ctx, 'erring')
// Only the members the relay reads; the full Agent surface is irrelevant here. // Only the members the relay reads; the full Agent surface is irrelevant here.
const agent = { id: 'agent-1', session } as Agent const agent = { id: 'agent-1', session } as Agent
ctx.emit('agent/error', agent, 3, 2, error) ctx.emit('agent/error', { agent, turn: 3, step: 2, error })
const record = backend.records.find(r => r.channel === 'ops')! const record = backend.records.find(r => r.channel === 'ops')!
expect(record.severity).toBe('error') expect(record.severity).toBe('error')
expect(record.attributes).toMatchObject({ expect(record.attributes).toMatchObject({

View File

@@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
dispose() dispose()
resolve() resolve()

View File

@@ -72,7 +72,7 @@ export class HarnessSdkServer {
const payload: SessionEventNotification = { sessionId: String(session.id), event } const payload: SessionEventNotification = { sessionId: String(session.id), event }
this.transport.notify('session.event', payload) this.transport.notify('session.event', payload)
})) }))
this.disposers.push(ctx.on('agent/status', (agent, status) => { this.disposers.push(ctx.on('agent/status', ({ agent, status }) => {
this.transport.notify('session.status', { sessionId: String(agent.session.id), status }) this.transport.notify('session.status', { sessionId: String(agent.session.id), status })
})) }))
this.disposers.push(ctx.on('session/created', (session) => { this.disposers.push(ctx.on('session/created', (session) => {

View File

@@ -254,8 +254,8 @@ describe('HarnessSdkServer', () => {
session, session,
} satisfies Pick<Agent, 'id' | 'session'>) as Agent } satisfies Pick<Agent, 'id' | 'session'>) as Agent
ctx.emit('agent/status', agent, 'running') ctx.emit('agent/status', { agent, status: 'running' })
ctx.emit('agent/status', agent, 'idle') ctx.emit('agent/status', { agent, status: 'idle' })
expect(transport.notifications.filter(notification => notification.method === 'session.status')) expect(transport.notifications.filter(notification => notification.method === 'session.status'))
.toEqual([ .toEqual([