From 009d113e0e6f8109a39792995e365b0a31c011de Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 21:58:07 +0800 Subject: [PATCH] refactor(agent): trim obsolete loop surfaces --- docs/cordis-catalog/events.md | 30 +++++------ docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/core.md | 46 ++++++----------- docs/core-data-structures/core.zh.md | 46 ++++++----------- docs/event-producer-consumer.md | 32 ++++++------ .../bash/tool-bash/tests/integration.spec.ts | 3 +- packages/compact/compact-basic/src/index.ts | 10 ++++ packages/compact/compact-basic/src/region.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/core/agent-loop/src/agent.ts | 34 +++++++++++-- .../tests/config-session-id.spec.ts | 20 +++----- .../tests/contract-regressions.spec.ts | 2 +- .../agent-loop/tests/interception.spec.ts | 2 - packages/core/agent-loop/tests/loop.spec.ts | 2 +- packages/core/agent/src/types.ts | 44 +++++----------- packages/core/agent/tests/agent.spec.ts | 34 +++---------- packages/core/session/src/request-header.ts | 20 +++----- packages/core/session/src/types.ts | 26 ++-------- .../core/session/tests/request-header.spec.ts | 19 +++---- .../agent-spine-demo/tests/agent-core.spec.ts | 1 - packages/examples/cli-demo/src/cli.ts | 1 - packages/examples/cli-demo/tests/cli.spec.ts | 1 - packages/goal/goal-session/src/index.ts | 6 +-- packages/goal/goal-session/src/outcome.ts | 4 +- .../goal-session/tests/goal-session.spec.ts | 2 - packages/llm/llm-retry/src/index.ts | 8 +++ packages/llm/llm-retry/src/invariant.ts | 4 +- .../tests/loader-composition.spec.ts | 1 - packages/llm/llm-retry/tests/retry.spec.ts | 47 +++++++++++++++++ packages/llm/llm/src/types.ts | 3 +- packages/llm/token-meter/src/index.ts | 1 - .../src/package-managers/link-workspace.ts | 1 - packages/sdk/helper/tests/documents.spec.ts | 2 +- .../session-query/src/extraction.ts | 4 -- packages/ui/acp/src/codec.ts | 6 +-- packages/ui/acp/tests/codec.spec.ts | 1 - packages/ui/tui/src/index.ts | 50 +++++++++---------- packages/ui/tui/tests/harness.ts | 7 ++- packages/ui/tui/tests/tui.spec.ts | 13 +++-- 40 files changed, 252 insertions(+), 288 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 942ccb7ba2..4d391d2bd7 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:328`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:310`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:261`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:436`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:418`](../../packages/core/agent/src/types.ts) ### `agent/idle` — emit @@ -119,7 +119,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [IdleReason](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -140,7 +140,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:306`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:288`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -163,7 +163,7 @@ Pending inbox items were dropped without delivering them, so every enqueued id r Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -182,7 +182,7 @@ An item entered the queued or steering inbox. Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -205,7 +205,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:337`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -229,7 +229,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach 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:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:363`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -255,7 +255,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:378`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -277,7 +277,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to 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:341`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:323`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -297,7 +297,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s 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:288`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -321,7 +321,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:350`](../../packages/core/agent/src/types.ts) ### `agent/stopping` — serial @@ -347,7 +347,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:411`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:393`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e639eeba3e..359f707d13 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1709,7 +1709,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:148`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:149`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 882352e7dc..38ef98551a 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -451,21 +451,21 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` is an abstract class: concrete drivers implement the abstract members, while `followup`/`steer`/`inject` are shared concrete delegates to the single abstract `send` over the (`target` × `wakeup`) matrix. +`Agent` is an interface over the public live-agent contract. Concrete drivers own the `followup`/`steer`/`inject` aliases and route them through `send`'s (`target` × `wakeup`) matrix. ```ts type-equiv /** Public live-agent handle with aliases over the unified delivery primitive. */ -abstract class Agent { +interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** * The unified delivery primitive over the (`target` × `wakeup`) matrix. @@ -484,7 +484,7 @@ abstract class Agent { * @param options - target queue, wakeup decision, and source. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options: SendOptions): AgentMessageId + send(content: ContentBlock[], options: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -495,10 +495,10 @@ abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise + whenIdle(): Promise /** * Queue an ordinary follow-up turn and wake the driver — the @@ -508,13 +508,7 @@ abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-turn', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Submit steering into the running turn — the `next-step`/wakeup preset of @@ -527,13 +521,7 @@ abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Append model-facing context without running the model — the @@ -545,13 +533,7 @@ abstract class Agent { * @param options - context source. * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: false, - source: options?.source ?? { kind: 'plugin', plugin: '' }, - }) - } + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Re-open a turn on the current session log without a new prompt — the @@ -560,7 +542,7 @@ abstract class Agent { * immediately. Repeated calls before the scheduled retry coalesce. * @throws while other agent work is running. */ - abstract retry(): void + retry(): void } ``` diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index e686216530..276e8a7b1d 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -453,21 +453,21 @@ type AgentCancelCause = | { readonly kind: 'parent' } ``` -`Agent` 是抽象类:具体驱动器实现抽象成员,而 `followup`/`steer`/`inject` 是共享的具体委托方法,它们都委托给覆盖(`target` × `wakeup`)矩阵的唯一抽象 `send`。 +`Agent` 是公开 live-agent 契约的接口。具体驱动器实现 `followup`/`steer`/`inject` 别名,并通过 `send` 的(`target` × `wakeup`)矩阵完成路由。 ```ts type-equiv /** Public live-agent handle with aliases over the unified delivery primitive. */ -abstract class Agent { +interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** * The unified delivery primitive over the (`target` × `wakeup`) matrix. @@ -486,7 +486,7 @@ abstract class Agent { * @param options - target queue, wakeup decision, and source. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options: SendOptions): AgentMessageId + send(content: ContentBlock[], options: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -497,10 +497,10 @@ abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise + whenIdle(): Promise /** * Queue an ordinary follow-up turn and wake the driver — the @@ -510,13 +510,7 @@ abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-turn', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Submit steering into the running turn — the `next-step`/wakeup preset of @@ -529,13 +523,7 @@ abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Append model-facing context without running the model — the @@ -547,13 +535,7 @@ abstract class Agent { * @param options - context source. * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: false, - source: options?.source ?? { kind: 'plugin', plugin: '' }, - }) - } + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Re-open a turn on the current session log without a new prompt — the @@ -562,7 +544,7 @@ abstract class Agent { * immediately. Repeated calls before the scheduled retry coalesce. * @throws while other agent work is running. */ - abstract retry(): void + retry(): void } ``` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index adb9ef56d6..5d973b85ca 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:328`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:436`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/idle` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:306`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:355`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:341`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`acp`](../packages/ui/acp), [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/stopping` | `serial` | [`packages/core/agent/src/types.ts:411`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:310`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:252`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:261`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:418`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/idle` | `emit` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:288`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:337`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:363`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:378`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:323`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`acp`](../packages/ui/acp), [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/stopping` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 99b4b70734..f679cfaa51 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -114,7 +114,8 @@ describe('bash tool through the agent loop', () => { const result = findEvent(events(agent), 'tool/result') expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`) - await expect.poll(() => existsSync(location!.path)).toBe(true) + await ctx.sessions.flush(agent.session) + expect(existsSync(location!.path)).toBe(true) const header = JSON.parse(readFileSync(location!.path, 'utf8').split('\n')[0]!) as { type: string; id: string } expect(header).toMatchObject({ type: 'session', id: 'session-env-id' }) await handle.dispose() diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 2d5cc68ee1..2e6f75317b 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -112,6 +112,7 @@ export class BasicCompactService extends CompactService { private readonly warnedPressureConfigTargets = new Set() private readonly overflowRetries = new WeakMap() + private readonly overflowAgents = new WeakMap() constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) @@ -158,6 +159,14 @@ export class BasicCompactService extends CompactService { this.overflowRetries.delete(agent) }) + // A successful response starts a fresh overflow-recovery sequence even + // when tool calls continue the same turn into another request. + ctx.on('session/event', (session, event) => { + if (event.type !== 'assistant/message') return + const agent = this.overflowAgents.get(session) + if (agent !== undefined) this.overflowRetries.delete(agent) + }) + ctx.on('agent/request-error', async ( agent, _turn, @@ -168,6 +177,7 @@ export class BasicCompactService extends CompactService { next, ) => { if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() + this.overflowAgents.set(agent.session, agent) const target = routedTarget(agent.session) if (target === undefined) return next() const policy = resolveTargetPolicy(this.config, target) diff --git a/packages/compact/compact-basic/src/region.ts b/packages/compact/compact-basic/src/region.ts index 3f1d330205..7ac97d1c0c 100644 --- a/packages/compact/compact-basic/src/region.ts +++ b/packages/compact/compact-basic/src/region.ts @@ -199,7 +199,7 @@ function buildSummarizationInput( return { ...header?.system === undefined ? {} : { system: header.system }, ...header?.tools === undefined ? {} : { tools: header.tools }, - messages: [...header?.messagePrefix ?? [], ...regionMessages], + messages: regionMessages, } } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 5cbc429d52..2b8038bdab 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -389,7 +389,6 @@ describe('context-overflow recovery across the real loop and compact-basic', () agentOptions: { provider: 'mock', model: 'mock' }, }) agent.followup([{ type: 'text', text: 'continue from history' }]) - await expect.poll(() => adapter.conversationRequests.length).toBe(3) await agent.whenIdle() expect(adapter.conversationRequests).toHaveLength(3) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 1a8150f0cf..2f33e60e1e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1168,7 +1168,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Agent', - declaration: 'export abstract class Agent {\n abstract readonly id: SessionId;\n abstract readonly options: AgentOptions;\n abstract readonly session: Session;\n abstract readonly status: AgentStatus;\n abstract readonly ctx: Context;\n abstract send(content: ContentBlock[], options: SendOptions): AgentMessageId;\n abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n abstract whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n abstract retry(): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options: SendOptions): AgentMessageId;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId;\n retry(): void;\n}', }, { name: 'AgentCancelCause', diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 0de2685ab5..42847eaac7 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,13 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { Agent, AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { AgentMessageId, agentCarrier, agentInterruptReasonOf, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { AgentMessage, + Agent, + AliasSendOptions, CancelOptions, AgentInterruptReason, AgentOptions, @@ -43,7 +45,7 @@ type StepOutcome = * The concrete {@link Agent}: each `run()` owns one turn and repeats model * steps while tools or steering require another request. */ -export class ReactLoopAgent extends Agent { +export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ private queued: { message: AgentMessage; wakeup: boolean }[] = [] /** Input taken into the session log at step boundaries. */ @@ -75,7 +77,6 @@ export class ReactLoopAgent extends Agent { public readonly options: AgentOptions, public readonly session: Session, ) { - super() this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0 this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx.extend({ agent: this }) @@ -118,6 +119,33 @@ export class ReactLoopAgent extends Agent { return id } + /** Queue one ordinary prompt turn and wake the driver. */ + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { + target: 'next-turn', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + }) + } + + /** Steer the open turn, falling back to a waking prompt while idle. */ + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { + target: 'next-step', + wakeup: true, + source: options?.source ?? { kind: 'user' }, + }) + } + + /** Append model-facing context without waking the driver. */ + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { + return this.send(content, { + target: 'next-step', + wakeup: false, + source: options?.source ?? { kind: 'plugin', plugin: '' }, + }) + } + /** * Clear all pending work and abort the active turn; the first cause wins. * The cause is signal payload for observers and the durable turn/end diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 0bd8a61881..15df16d2cf 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -140,13 +140,9 @@ describe('config-driven session id', () => { first.inject([{ type: 'text', text: 'persist before replacement' }], { source: { kind: 'plugin', plugin: 'test' }, }) - await expect.poll(async () => { - try { - return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events) - } catch { - return '' - } - }).toContain('persist before replacement') + await ctx.sessions.flush(first.session) + expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + .toContain('persist before replacement') const firstDisposal = firstLoop.dispose() await cleanupStarted.promise @@ -190,13 +186,9 @@ describe('config-driven session id', () => { first.inject([{ type: 'text', text: 'persist before cancellation' }], { source: { kind: 'plugin', plugin: 'test' }, }) - await expect.poll(async () => { - try { - return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events) - } catch { - return '' - } - }).toContain('persist before cancellation') + await ctx.sessions.flush(first.session) + expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)) + .toContain('persist before cancellation') const firstDisposal = firstLoop.dispose() await cleanupStarted.promise diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 3889ee1a33..78574c693d 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -436,7 +436,7 @@ describe('disposal leaves the two-state status contract balanced', () => { await fiber.dispose() await driverDone(agent) // must not hang - await expect.poll(() => ctx.agents.get(SessionId('scoped')) === undefined).toBe(true) + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index cf80a6e809..7a08a169ba 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -161,7 +161,6 @@ describe('agent/prompt-submit', () => { expect(log.some(e => e.type === 'turn/end')).toBe(false) expect(log.some(e => e.type === 'user/message')).toBe(false) expect(log.some(e => e.type === 'step/start')).toBe(false) - expect(log.some(e => e.type === 'prompt/blocked')).toBe(false) expect(reasons).toEqual([]) }) @@ -189,7 +188,6 @@ describe('agent/prompt-submit', () => { expect(userMsgs).toHaveLength(1) expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }]) expect(adapter.requests.length).toBeGreaterThanOrEqual(1) - expect(log.filter(e => e.type === 'prompt/blocked')).toHaveLength(0) expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1) expect(reasons).toEqual([{ kind: 'completed' }]) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index a3162a7229..78025c4159 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1041,7 +1041,7 @@ describe('agent loop', () => { await fiber.dispose() await driverDone(agent) - await expect.poll(() => ctx.agents.get(SessionId('scoped')) === undefined).toBe(true) + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() }) it('creates agents from config on startup', async () => { diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index d352000a8c..1c71d9bb60 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -146,17 +146,17 @@ export type AgentCancelCause = export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' } /** Public live-agent handle with aliases over the unified delivery primitive. */ -export abstract class Agent { +export interface Agent { /** The single identity shared with {@link session}. */ - abstract readonly id: SessionId + readonly id: SessionId /** The provider route and model this agent's requests use. */ - abstract readonly options: AgentOptions + readonly options: AgentOptions /** The live session this agent drives; its log is the durable source of truth. */ - abstract readonly session: Session + readonly session: Session /** The current lifecycle state, mirrored on every `agent/status` transition. */ - abstract readonly status: AgentStatus + readonly status: AgentStatus /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ - abstract readonly ctx: Context + readonly ctx: Context /** * The unified delivery primitive over the (`target` × `wakeup`) matrix. @@ -175,7 +175,7 @@ export abstract class Agent { * @param options - target queue, wakeup decision, and source. * @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events. */ - abstract send(content: ContentBlock[], options: SendOptions): AgentMessageId + send(content: ContentBlock[], options: SendOptions): AgentMessageId /** * Clear queued and steering work — unless `keepInbox` — and abort the active @@ -186,10 +186,10 @@ export abstract class Agent { * @param cause - the stable caller intent carried by the current turn signal. * @param options - cancellation options; `keepInbox` preserves pending work. */ - abstract cancel(cause: AgentCancelCause, options?: CancelOptions): void + cancel(cause: AgentCancelCause, options?: CancelOptions): void /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ - abstract whenIdle(): Promise + whenIdle(): Promise /** * Queue an ordinary follow-up turn and wake the driver — the @@ -199,13 +199,7 @@ export abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-turn', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Submit steering into the running turn — the `next-step`/wakeup preset of @@ -218,13 +212,7 @@ export abstract class Agent { * @param options - message source. * @returns the accepted message's {@link AgentMessageId}. */ - steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: true, - source: options?.source ?? { kind: 'user' }, - }) - } + steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Append model-facing context without running the model — the @@ -236,13 +224,7 @@ export abstract class Agent { * @param options - context source. * @returns the accepted message's {@link AgentMessageId}. */ - inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId { - return this.send(content, { - target: 'next-step', - wakeup: false, - source: options?.source ?? { kind: 'plugin', plugin: '' }, - }) - } + inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId /** * Re-open a turn on the current session log without a new prompt — the @@ -251,7 +233,7 @@ export abstract class Agent { * immediately. Repeated calls before the scheduled retry coalesce. * @throws while other agent work is running. */ - abstract retry(): void + retry(): void } declare module 'cordis' { diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index ecf1c6f616..c6311bb93a 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -3,60 +3,38 @@ import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { - Agent, AgentMessageId, agentEvents, agentInterruptReasonOf, } from '@deepseek-ai/dsh-agent' import type { + Agent, AgentCancelCause, AgentFactory, CreateAgentOptions, ResumeAgentOptions, - SendOptions, } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string, overrides: Partial = {}): Agent { const id = SessionId(rawId) - // Agent is an abstract class, so its alias methods live on the prototype and - // object spread would drop them; build the full literal and merge overrides. - return Object.assign(Object.create(Agent.prototype) as Agent, { + return { id, options: {}, session: new Session(id), status: 'idle', ctx: new Context(), send: () => AgentMessageId('stub'), + followup: () => AgentMessageId('stub'), + steer: () => AgentMessageId('stub'), + inject: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle() { return Promise.resolve() }, ...overrides, - }) + } } -describe('Agent delivery aliases', () => { - it('materializes complete SendOptions for every preset', () => { - const calls: SendOptions[] = [] - const agent = stubAgent('aliases', { - send(_content, options) { - if (options !== undefined) calls.push(options) - return AgentMessageId('stub') - }, - }) - - agent.followup([]) - agent.steer([]) - agent.inject([]) - - expect(calls).toEqual([ - { target: 'next-turn', wakeup: true, source: { kind: 'user' } }, - { target: 'next-step', wakeup: true, source: { kind: 'user' } }, - { target: 'next-step', wakeup: false, source: { kind: 'plugin', plugin: '' } }, - ]) - }) -}) - describe('AgentRegistry', () => { it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => { const ctx = new Context() diff --git a/packages/core/session/src/request-header.ts b/packages/core/session/src/request-header.ts index 8dd61ee884..ad2b61faed 100644 --- a/packages/core/session/src/request-header.ts +++ b/packages/core/session/src/request-header.ts @@ -8,13 +8,13 @@ */ import { callConfigEquals } from '@deepseek-ai/dsh-llm' -import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { EpochHeader, SessionEvent } from './types.ts' /** - * Normalize a header to canonical form: an empty system prompt, an empty tool - * list, and an empty session prefix become absent fields, matching how requests - * are built. Logging, folding, and comparison use this one representation. + * Normalize a header to canonical form: an empty system prompt and empty tool + * list become absent fields, matching how requests are built. Logging, folding, + * and comparison use this one representation. * @param header - the header to normalize (not mutated). * @returns the canonical header. */ @@ -23,7 +23,6 @@ export function canonicalHeader(header: EpochHeader): EpochHeader { config: header.config, ...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {}, ...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {}, - ...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {}, } } @@ -32,21 +31,14 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean { return JSON.stringify(a) === JSON.stringify(b) } -/** Canonical JSON equality over session-prefix arrays; absence equals empty. */ -function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean { - return JSON.stringify(a ?? []) === JSON.stringify(b ?? []) -} - /** - * Field-wise equality over canonical headers. Tool schemas compare in order; - * the session prefix compares as canonical JSON. + * Field-wise equality over canonical headers. Tool schemas compare in order. * @param a - one canonical header. * @param b - the other. - * @returns whether config, system, tools, and session prefix all match. + * @returns whether config, system, and tools all match. */ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean { if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false - if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false const at = a.tools ?? [] const bt = b.tools ?? [] return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema)) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index ce3db1aece..fbca6b466e 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -1,5 +1,5 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm' import type { JsonValue } from './json.ts' /** Identifies one session in the store (and its persistence artifacts). */ @@ -117,11 +117,6 @@ export interface TurnEndReasonMap { disposed: { kind: 'disposed' } /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } - /** - * Policy blocked the turn's claimed prompt before the first step. The - * zero-step turn still records a balanced durable boundary and veto reason. - */ - rejected: { kind: 'rejected'; reason: string } /** * A persistence backend closed a crash-orphaned turn on reload. The loop never * emits this marker, and the events recorded before the crash remain intact. @@ -151,9 +146,9 @@ export interface TodoItem { } /** - * Logged request state outside derived history: call config, system prompt, - * tools, and prefix. The latest full `request/header` snapshot reconstructs it; - * canonical empty optional fields are absent. + * Logged request state outside derived history: call config, system prompt, and + * tools. The latest full `request/header` snapshot reconstructs it; canonical + * empty optional fields are absent. */ export interface EpochHeader { /** The conversation's call configuration (provider, model, and sampling scalars). */ @@ -162,14 +157,6 @@ export interface EpochHeader { system?: string /** Assembled tool schemas; absent for a tool-less request. */ tools?: ToolSchema[] - /** - * The session prefix: request-only messages sent BEFORE the entire derived - * history (the `agent/session-prefix` waterfall's product, composed once - * per loop instance and reused for every request it sends). Not session - * history — `deriveMessages()` never returns it — so the header is its - * only durable record; absent when the instance composed none. - */ - messagePrefix?: Message[] } /** @@ -225,11 +212,6 @@ export interface SessionEventMap { * injection may append this event between turns without running the model. */ 'user/message': UserMessageData - /** - * Durable record of a prompt veto and its reason. It is log-only: the blocked - * prompt never enters the model-visible surface, and its turn runs zero steps. - */ - 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** Raw stream chunk — token-level replay fidelity. */ 'assistant/chunk': { turn: number; step: number; chunk: StreamChunk } /** diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index b185cf6e56..5dc985c070 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' -import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' const CONFIG = { provider: 'mock', model: 'm' } @@ -11,33 +11,28 @@ function tool(name: string, description = 'd'): ToolSchema { return { name, description, parameters: { type: 'object' } } } -function msg(text: string): Message { - return { role: 'user', content: [{ type: 'text', text }] } -} - describe('canonicalHeader', () => { it('normalizes empty optional fields to absence and preserves populated fields', () => { - expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG }) - const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) - expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) + expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG }) + const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] }) + expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] }) }) }) describe('headerEquals', () => { - const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] }) + const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] }) it('compares every canonical field and preserves tool order', () => { expect(headerEquals(base, structuredClone(base))).toBe(true) expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false) expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) - expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false) expect(headerEquals(base, { ...base, tools: [] })).toBe(false) expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false) expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false) }) - it('treats absent and empty prefix/tool arrays as equivalent canonical absence', () => { - expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true) + it('treats absent and empty tool arrays as equivalent canonical absence', () => { + expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [] })).toBe(true) }) }) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index c3c162b4eb..a8e5049442 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -226,7 +226,6 @@ describe('dsh-agent-spine-demo bundle', () => { }) handle.agent.followup([{ type: 'text', text: 'recover' }]) - await expect.poll(() => adapter.requests).toBe(2) await waitForIdle(ctx, handle.agent) expect(adapter.requests).toBe(2) diff --git a/packages/examples/cli-demo/src/cli.ts b/packages/examples/cli-demo/src/cli.ts index de09a7b490..cbac81b164 100644 --- a/packages/examples/cli-demo/src/cli.ts +++ b/packages/examples/cli-demo/src/cli.ts @@ -388,7 +388,6 @@ export function formatTurnFailure(reason: TurnEndReason): string { case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}` case 'disposed': return 'was disposed' case 'max-tokens': return 'reached the model output-token limit' - case 'rejected': return `was rejected: ${reason.reason}` case 'interrupted': return 'was interrupted during persistence recovery' default: return `ended with ${JSON.stringify(reason)}` } diff --git a/packages/examples/cli-demo/tests/cli.spec.ts b/packages/examples/cli-demo/tests/cli.spec.ts index f82a62441a..08583cb02d 100644 --- a/packages/examples/cli-demo/tests/cli.spec.ts +++ b/packages/examples/cli-demo/tests/cli.spec.ts @@ -501,7 +501,6 @@ describe('formatTurnFailure', () => { [{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'], [{ kind: 'disposed' }, 'was disposed'], [{ kind: 'max-tokens' }, 'output-token limit'], - [{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'], [{ kind: 'interrupted' }, 'persistence recovery'], ] for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected) diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 71ba7bf432..4caccccbef 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -37,7 +37,6 @@ interface RoundAttempt extends RoundIdentity { phase: 'queued' | 'admitted' turn: number | undefined reason: TurnEndReason | undefined - rejectedReason: string | undefined stale: boolean } @@ -186,9 +185,7 @@ export function apply(ctx: Context): void { const goal = currentGoal(state) if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision && goal.phase === 'active' && goal.activation === 'armed') { - const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale - ? { kind: 'blocked', code: 'prompt-rejected', message: attempt.rejectedReason } as const - : classifyGoalRound(attempt.reason, durable) + const outcome = classifyGoalRound(attempt.reason, durable) if (!attempt.stale) applyOutcome(state, goal, outcome) } if (!readyToDrive(state)) return @@ -214,7 +211,6 @@ export function apply(ctx: Context): void { phase: 'queued', turn: undefined, reason: undefined, - rejectedReason: undefined, stale: false, } state.attempt = reservation diff --git a/packages/goal/goal-session/src/outcome.ts b/packages/goal/goal-session/src/outcome.ts index d3048cb6f4..e138bf2030 100644 --- a/packages/goal/goal-session/src/outcome.ts +++ b/packages/goal/goal-session/src/outcome.ts @@ -8,7 +8,7 @@ export type GoalRoundOutcome = | { readonly kind: 'pause'; readonly reason: string } | { readonly kind: 'blocked' - readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'prompt-rejected' | 'unknown-turn-outcome' + readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'unknown-turn-outcome' readonly message: string } | { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' } @@ -35,8 +35,6 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal } case 'max-tokens': return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' } - case 'rejected': - return { kind: 'blocked', code: 'prompt-rejected', message: reason.reason } case 'disposed': return { kind: 'disarm', reason: 'disposed' } case 'interrupted': diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index ebfca96a37..4008d23882 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -141,8 +141,6 @@ describe('goal-round outcome policy', () => { { kind: 'blocked', code: 'turn-error', message: 'broken' }], [{ kind: 'max-tokens' }, true, { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }], - [{ kind: 'rejected', reason: 'policy' }, true, - { kind: 'blocked', code: 'prompt-rejected', message: 'policy' }], [{ kind: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }], [{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }], [{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }], diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 040575e710..da2252b6d2 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -176,6 +176,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna retries.delete(agent) }) + // A completed model response ends the consecutive-failure sequence even + // when its tool calls keep the turn running into another request. + ctx.on('session/event', (session, event) => { + if (event.type !== 'assistant/message') return + const agent = ctx.agents.get(session.id) + if (agent?.session === session) retries.delete(agent) + }) + const disposeListener = ctx.on('agent/request-error', ( agent: Agent, turn: number, diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index be5ce3b046..4edfc8929e 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -88,7 +88,9 @@ function validateRetry( } const chainStart = retryChainStart(history, turn) - const chainRetries = history.slice(Math.max(chainStart, 0)) + const chain = history.slice(Math.max(chainStart, 0)) + const lastSuccess = chain.findLastIndex(prior => prior.type === 'assistant/message') + const chainRetries = chain.slice(lastSuccess + 1) .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) { fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index d57a6d3f88..240693c614 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -102,7 +102,6 @@ describe('real Loader composition', () => { loaded.llm.registerAdapter(['mock'], adapter) const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' }) agent.followup([{ type: 'text', text: 'recover' }]) - await expect.poll(() => adapter.requests).toBe(2) await agent.whenIdle() expect(adapter.requests).toBe(2) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 40bbd32e72..c4c87388b0 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -50,6 +50,16 @@ function textResponse(text: string): StreamChunk[] { ] } +function toolResponse(callId: string, name: string): StreamChunk[] { + const id = CallId(callId) + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id, name, argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: '{}' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + async function harness( adapter: LlmAdapter, config: retry.Config = {}, @@ -263,6 +273,43 @@ describe('bounded transient retry policy', () => { expect(adapter.requests).toHaveLength(4) }) + it('resets the retry budget after a successful tool-call response within the same drain', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + new LlmError('first busy', 'SERVER'), + toolResponse('work-1', 'work'), + new LlmError('second busy', 'SERVER'), + textResponse('done'), + ]) + ;({ ctx: context } = await harness(adapter, { maxTransientRetries: 1 })) + context.tools.register(defineContentToolFixture({ + name: 'work', + description: 'continue into another model step', + parameters: {}, + async execute() { + return [{ type: 'text', text: 'worked' }] + }, + })) + const agent = context.agentLoop.create(SessionId('retry-reset-after-success'), { + provider: 'mock', + model: 'mock', + }) + + const firstRetry = waitForRetry(context, agent, 1) + agent.followup([{ type: 'text', text: 'go' }]) + await firstRetry + const secondRetry = waitForRetry(context, agent, 1) + await vi.advanceTimersByTimeAsync(500) + await secondRetry + const idle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(500) + await idle + + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.retry)) + .toEqual([1, 1]) + expect(adapter.requests).toHaveLength(4) + }) + it('accepts the zero-delay lower jitter bound', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index f9f97d532a..3cd274de2c 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -204,8 +204,7 @@ export interface GenerateOptions { /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as - * `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a - * hand-built one-shot passes any list. + * the derived history (dsh-agent-loop); a hand-built one-shot passes any list. */ messages: Message[] /** System prompt text (adapters map to the provider's system slot). */ diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index e17b0c06b6..34dae235db 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -378,7 +378,6 @@ export class TokenMeterService extends Service { private _estimateHeader(header: EpochHeader | undefined): number { if (header === undefined) return 0 let tokens = 0 - for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message) if (header.system !== undefined) { tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD } diff --git a/packages/sdk/helper/src/package-managers/link-workspace.ts b/packages/sdk/helper/src/package-managers/link-workspace.ts index e44ea3ea0b..1a6b518fa9 100644 --- a/packages/sdk/helper/src/package-managers/link-workspace.ts +++ b/packages/sdk/helper/src/package-managers/link-workspace.ts @@ -67,7 +67,6 @@ export class LinkWorkspace { try { manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue throw new Error(`cannot read linked package at ${directory}: ${String(error)}`) } if (!manifest.name || typeof manifest.name !== 'string') continue diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 19d8496475..1e4b944f00 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -386,7 +386,7 @@ describe('package manager strategies', () => { temporary.push(unreadable) await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true }) await mkdir(join(unreadable, 'packages'), { recursive: true }) - await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('not a DeepSeek Harness repository root') + await expect(LinkWorkspace.open(unreadable)).rejects.toThrow('cannot read linked package') const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-')) temporary.push(unnamed) await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true }) diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 4cbe850352..ff2dcd7064 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/packages/session-query/session-query/src/extraction.ts @@ -16,8 +16,6 @@ export function extractSessionEventText(event: SessionEvent): string { case 'assistant/message': case 'steering/message': return contentText(event.data.content) - case 'prompt/blocked': - return joinText([contentText(event.data.content), event.data.reason]) case 'tool/call': return joinText([event.data.name, event.data.arguments]) case 'tool/result': @@ -51,8 +49,6 @@ function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string : joinText(['error', reason.message, reason.code ?? '']) case 'aborted': return 'aborted' - case 'rejected': - return joinText(['rejected', reason.reason]) case 'disposed': case 'max-tokens': case 'interrupted': diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 91453e3387..cbd6b43fdb 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -17,8 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr * Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum. * * `completed` and the defensive `error` case map to `end_turn`; - * `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map - * to `cancelled`. The bridge rejects error turns before this mapping. Unknown + * `max-tokens` maps to `max_tokens`; `aborted` and `disposed` map to + * `cancelled`. The bridge rejects error turns before this mapping. Unknown * merge-extensible kinds use legal fallback `end_turn` rather than breaking * the prompt RPC. * @param reason - the harness turn-end reason to translate. @@ -34,8 +34,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { return 'cancelled' case 'disposed': return 'cancelled' - case 'rejected': - return 'cancelled' case 'error': return 'end_turn' // Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 9cd2ca33a1..aa7e7f2d13 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -20,7 +20,6 @@ describe('turnEndToStopReason', () => { expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens') expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled') - expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled') expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn') }) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 6748b5086f..8597150846 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -41,6 +41,7 @@ import z from 'schemastery' import { installAgentLlmTarget, type Agent, + type AgentMessageId, type AgentLlmTarget, type AgentLlmTargetRef, type AgentStatus, @@ -1299,7 +1300,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string { case 'error': return `turn ${event.data.turn}: error` case 'disposed': return `turn ${event.data.turn}: disposed` case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'rejected': return `turn ${event.data.turn}: rejected` case 'interrupted': return `turn ${event.data.turn}: interrupted` default: return `turn ${event.data.turn}: unknown result` } @@ -1875,11 +1875,10 @@ export function createTuiChat( let toolsExpanded = false let streaming: StreamingAssistantComponent | undefined let runningStatus: RunningStatus | undefined - // TUI steering submissions that the loop has not yet drained, shown as a - // badge on the status line. Each entry is the submitted message's serialized - // source, so an unrelated steering/message cannot consume its slot. Leaving - // `running` clears entries discarded by cancellation. - const pendingSteering: string[] = [] + // TUI steering submissions that the inbox has not yet claimed or discarded. + // Correlation ids avoid guessing whether a running-state submission actually + // joined steering or fell back to the queued-turn FIFO during turn close. + const pendingSteering = new Set() let disposed = false let shuttingDown: Promise | undefined // Optional: skills mount conditionally, so read the global service store @@ -2124,7 +2123,7 @@ export function createTuiChat( const renderStatus = (running: RunningStatus): void => { const at = now() running.loader.setMessage( - formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.length), + formatTurnStatus(running.phase, at - running.phaseStartedAt, at - running.stepStartedAt, pendingSteering.size), ) } @@ -2152,7 +2151,7 @@ export function createTuiChat( const phase = prior?.phase ?? 'waiting' const phaseStartedAt = prior?.phaseStartedAt ?? at const stepStartedAt = prior?.stepStartedAt ?? at - const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.length) + const message = formatTurnStatus(phase, at - phaseStartedAt, at - stepStartedAt, pendingSteering.size) const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message) statusContainer.addChild(loader) const running: RunningStatus = { @@ -2264,9 +2263,6 @@ export function createTuiChat( } break } - case 'prompt/blocked': - appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') - break case 'assistant/chunk': if (options.renderChunks) { if (streaming === undefined) { @@ -2326,8 +2322,6 @@ export function createTuiChat( appendNotice('Turn cancelled.', 'warning') } else if (event.data.reason.kind === 'max-tokens') { appendNotice('The model reached its output-token limit.', 'warning') - } else if (event.data.reason.kind === 'rejected') { - appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning') } else if (event.data.reason.kind === 'interrupted') { appendNotice('The previous process ended during this turn.', 'warning') } @@ -2788,8 +2782,7 @@ export function createTuiChat( appendNotice(`Agent "${agent.id}" is disposed.`, 'error') } else if (agent.status === 'running') { const source = { kind: 'user' } as const - agent.steer(content, { source }) - pendingSteering.push(JSON.stringify(source)) + pendingSteering.add(agent.steer(content, { source })) refreshStatus() } else { agent.followup(content, { source: { kind: 'user' } }) @@ -3137,17 +3130,6 @@ export function createTuiChat( if (event.type === 'tool/result') fileSearch.invalidate() recordEventUsage(tokens, event) advanceTurnPhase(event) - if (event.type === 'steering/message') { - // A queued steering message reached the model as it drained; drop its - // entry from the badge. Matching by source keeps a loop-authored - // continuation reason popping its own enqueued slot rather than a pending - // user message's slot. - const drained = pendingSteering.indexOf(JSON.stringify(event.data.source)) - if (drained >= 0) { - pendingSteering.splice(drained, 1) - refreshStatus() - } - } if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { rebuildTranscript(false) return @@ -3155,12 +3137,24 @@ export function createTuiChat( renderEvent(event, { addHistory: false, renderChunks: true }) requestRender() }) + const settlePendingSteering = (id: AgentMessageId): void => { + if (pendingSteering.delete(id)) refreshStatus() + } + const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => { + if (subject === agent) settlePendingSteering(message.id) + }) + const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => { + if (subject !== agent) return + let changed = false + for (const message of messages) changed = pendingSteering.delete(message.id) || changed + if (changed) refreshStatus() + }) const disposeStatus = ctx.on('agent/status', (subject, status) => { if (subject !== agent) return // Leaving 'running' ends the turn's status line; clear any badge so the // next running turn starts from zero (and a cancellation, which discards // the queue without logging drains, cannot strand a stale count). - if (status !== 'running') pendingSteering.length = 0 + if (status !== 'running') pendingSteering.clear() setStatus(status) }) const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { @@ -3183,6 +3177,8 @@ export function createTuiChat( disposeCommandChanges() stopBannerReveal() disposeSessionEvents() + disposeDequeued() + disposeDiscarded() disposeStatus() disposeError() disposeAgent() diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6e0e0ebe4d..5517ba0ecb 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -23,6 +23,7 @@ interface FakeAgent extends Agent { sent: ContentBlock[][] sentOptions: (SendOptions | AliasSendOptions | undefined)[] steered: ContentBlock[][] + steeredIds: AgentMessageId[] steeredOptions: (AliasSendOptions | undefined)[] injected: ContentBlock[][] injectedOptions: (AliasSendOptions | undefined)[] @@ -161,6 +162,7 @@ export async function createTuiTestHarness { result.terminal.send('\r') } const drainSteering = (text: string): void => { + const id = result.agent.steeredIds.shift() + if (id !== undefined) { + result.ctx.emit('agent/inbox/dequeue', result.agent, { + id, + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }) + } result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' }) } @@ -1401,9 +1409,8 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).toContain('1 queued') - // A steering/message whose source matches no pending badge entry (here a - // plugin source with no tracked enqueue) pops nothing, so it cannot consume - // a pending user slot even when it drains first. + // A steering/message has no inbox identity and therefore cannot consume a + // pending slot by itself. result.terminal.output = '' result.session.append('steering/message', { turn: 1,