refactor(agent): trim obsolete loop surfaces
This commit is contained in:
@@ -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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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
|
### `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)
|
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/*`
|
## `agent-loop/*`
|
||||||
|
|
||||||
|
|||||||
@@ -1709,7 +1709,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi
|
|||||||
abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession
|
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`
|
## `ctx.userInteraction` — `UserInteractionService`
|
||||||
|
|
||||||
|
|||||||
@@ -451,21 +451,21 @@ type AgentCancelCause =
|
|||||||
| { readonly kind: 'parent' }
|
| { 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
|
```ts type-equiv
|
||||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||||
abstract class Agent {
|
interface Agent {
|
||||||
/** The single identity shared with {@link session}. */
|
/** The single identity shared with {@link session}. */
|
||||||
abstract readonly id: SessionId
|
readonly id: SessionId
|
||||||
/** The provider route and model this agent's requests use. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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.
|
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||||
@@ -484,7 +484,7 @@ abstract class Agent {
|
|||||||
* @param options - target queue, wakeup decision, and source.
|
* @param options - target queue, wakeup decision, and source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
* @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
|
* 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 cause - the stable caller intent carried by the current turn signal.
|
||||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
* @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. */
|
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||||
abstract whenIdle(): Promise<void>
|
whenIdle(): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue an ordinary follow-up turn and wake the driver — the
|
* Queue an ordinary follow-up turn and wake the driver — the
|
||||||
@@ -508,13 +508,7 @@ abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-turn',
|
|
||||||
wakeup: true,
|
|
||||||
source: options?.source ?? { kind: 'user' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||||
@@ -527,13 +521,7 @@ abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
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 running the model — the
|
* Append model-facing context without running the model — the
|
||||||
@@ -545,13 +533,7 @@ abstract class Agent {
|
|||||||
* @param options - context source.
|
* @param options - context source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-step',
|
|
||||||
wakeup: false,
|
|
||||||
source: options?.source ?? { kind: 'plugin', plugin: '' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-open a turn on the current session log without a new prompt — the
|
* 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.
|
* immediately. Repeated calls before the scheduled retry coalesce.
|
||||||
* @throws while other agent work is running.
|
* @throws while other agent work is running.
|
||||||
*/
|
*/
|
||||||
abstract retry(): void
|
retry(): void
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -453,21 +453,21 @@ type AgentCancelCause =
|
|||||||
| { readonly kind: 'parent' }
|
| { readonly kind: 'parent' }
|
||||||
```
|
```
|
||||||
|
|
||||||
`Agent` 是抽象类:具体驱动器实现抽象成员,而 `followup`/`steer`/`inject` 是共享的具体委托方法,它们都委托给覆盖(`target` × `wakeup`)矩阵的唯一抽象 `send`。
|
`Agent` 是公开 live-agent 契约的接口。具体驱动器实现 `followup`/`steer`/`inject` 别名,并通过 `send` 的(`target` × `wakeup`)矩阵完成路由。
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
||||||
abstract class Agent {
|
interface Agent {
|
||||||
/** The single identity shared with {@link session}. */
|
/** The single identity shared with {@link session}. */
|
||||||
abstract readonly id: SessionId
|
readonly id: SessionId
|
||||||
/** The provider route and model this agent's requests use. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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.
|
* The unified delivery primitive over the (`target` × `wakeup`) matrix.
|
||||||
@@ -486,7 +486,7 @@ abstract class Agent {
|
|||||||
* @param options - target queue, wakeup decision, and source.
|
* @param options - target queue, wakeup decision, and source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
* @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
|
* 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 cause - the stable caller intent carried by the current turn signal.
|
||||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
* @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. */
|
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||||
abstract whenIdle(): Promise<void>
|
whenIdle(): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue an ordinary follow-up turn and wake the driver — the
|
* Queue an ordinary follow-up turn and wake the driver — the
|
||||||
@@ -510,13 +510,7 @@ abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-turn',
|
|
||||||
wakeup: true,
|
|
||||||
source: options?.source ?? { kind: 'user' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||||
@@ -529,13 +523,7 @@ abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
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 running the model — the
|
* Append model-facing context without running the model — the
|
||||||
@@ -547,13 +535,7 @@ abstract class Agent {
|
|||||||
* @param options - context source.
|
* @param options - context source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-step',
|
|
||||||
wakeup: false,
|
|
||||||
source: options?.source ?? { kind: 'plugin', plugin: '' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-open a turn on the current session log without a new prompt — the
|
* 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.
|
* immediately. Repeated calls before the scheduled retry coalesce.
|
||||||
* @throws while other agent work is running.
|
* @throws while other agent work is running.
|
||||||
*/
|
*/
|
||||||
abstract retry(): void
|
retry(): void
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
|||||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
| 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-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/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:270`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
|
| `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: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/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: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/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: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/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:306`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent) |
|
| `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:318`](../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: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: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/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: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/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:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
|
| `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: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/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: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/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: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/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: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/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: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/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) |
|
| `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) |
|
| `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) |
|
| `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) |
|
| `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/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/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) |
|
| `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/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) |
|
| `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) |
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ describe('bash tool through the agent loop', () => {
|
|||||||
|
|
||||||
const result = findEvent(events(agent), 'tool/result')
|
const result = findEvent(events(agent), 'tool/result')
|
||||||
expect(resultText(result)).toBe(`${dshHome}\n1\nsession-env-id\n${location?.path}\nunset\nabsent\n`)
|
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 }
|
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' })
|
expect(header).toMatchObject({ type: 'session', id: 'session-env-id' })
|
||||||
await handle.dispose()
|
await handle.dispose()
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
|
|
||||||
private readonly warnedPressureConfigTargets = new Set<string>()
|
private readonly warnedPressureConfigTargets = new Set<string>()
|
||||||
private readonly overflowRetries = new WeakMap<Agent, number>()
|
private readonly overflowRetries = new WeakMap<Agent, number>()
|
||||||
|
private readonly overflowAgents = new WeakMap<Session, Agent>()
|
||||||
|
|
||||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||||
super(ctx)
|
super(ctx)
|
||||||
@@ -158,6 +159,14 @@ export class BasicCompactService extends CompactService {
|
|||||||
this.overflowRetries.delete(agent)
|
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 (
|
ctx.on('agent/request-error', async (
|
||||||
agent,
|
agent,
|
||||||
_turn,
|
_turn,
|
||||||
@@ -168,6 +177,7 @@ export class BasicCompactService extends CompactService {
|
|||||||
next,
|
next,
|
||||||
) => {
|
) => {
|
||||||
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)
|
||||||
const target = routedTarget(agent.session)
|
const target = routedTarget(agent.session)
|
||||||
if (target === undefined) return next()
|
if (target === undefined) return next()
|
||||||
const policy = resolveTargetPolicy(this.config, target)
|
const policy = resolveTargetPolicy(this.config, target)
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function buildSummarizationInput(
|
|||||||
return {
|
return {
|
||||||
...header?.system === undefined ? {} : { system: header.system },
|
...header?.system === undefined ? {} : { system: header.system },
|
||||||
...header?.tools === undefined ? {} : { tools: header.tools },
|
...header?.tools === undefined ? {} : { tools: header.tools },
|
||||||
messages: [...header?.messagePrefix ?? [], ...regionMessages],
|
messages: regionMessages,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,6 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
|||||||
agentOptions: { provider: 'mock', model: 'mock' },
|
agentOptions: { provider: 'mock', model: 'mock' },
|
||||||
})
|
})
|
||||||
agent.followup([{ type: 'text', text: 'continue from history' }])
|
agent.followup([{ type: 'text', text: 'continue from history' }])
|
||||||
await expect.poll(() => adapter.conversationRequests.length).toBe(3)
|
|
||||||
await agent.whenIdle()
|
await agent.whenIdle()
|
||||||
|
|
||||||
expect(adapter.conversationRequests).toHaveLength(3)
|
expect(adapter.conversationRequests).toHaveLength(3)
|
||||||
|
|||||||
@@ -1168,7 +1168,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Agent',
|
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<void>;\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<void>;\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',
|
name: 'AgentCancelCause',
|
||||||
|
|||||||
@@ -8,11 +8,13 @@
|
|||||||
|
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
import type { Context } from 'cordis'
|
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 { createScope } from '@deepseek-ai/dsh-scope'
|
||||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||||
import type {
|
import type {
|
||||||
AgentMessage,
|
AgentMessage,
|
||||||
|
Agent,
|
||||||
|
AliasSendOptions,
|
||||||
CancelOptions,
|
CancelOptions,
|
||||||
AgentInterruptReason,
|
AgentInterruptReason,
|
||||||
AgentOptions,
|
AgentOptions,
|
||||||
@@ -43,7 +45,7 @@ type StepOutcome =
|
|||||||
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
||||||
* steps while tools or steering require another request.
|
* steps while tools or steering require another request.
|
||||||
*/
|
*/
|
||||||
export class ReactLoopAgent extends Agent {
|
export class ReactLoopAgent implements Agent {
|
||||||
/** Prompts awaiting individual turns. */
|
/** Prompts awaiting individual turns. */
|
||||||
private queued: { message: AgentMessage; wakeup: boolean }[] = []
|
private queued: { message: AgentMessage; wakeup: boolean }[] = []
|
||||||
/** Input taken into the session log at step boundaries. */
|
/** Input taken into the session log at step boundaries. */
|
||||||
@@ -75,7 +77,6 @@ export class ReactLoopAgent extends Agent {
|
|||||||
public readonly options: AgentOptions,
|
public readonly options: AgentOptions,
|
||||||
public readonly session: Session,
|
public readonly session: Session,
|
||||||
) {
|
) {
|
||||||
super()
|
|
||||||
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
this.lastTurn = session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 0
|
||||||
this.scope = createScope(loopCtx, this)
|
this.scope = createScope(loopCtx, this)
|
||||||
this.ctx = this.scope.ctx.extend({ agent: this })
|
this.ctx = this.scope.ctx.extend({ agent: this })
|
||||||
@@ -118,6 +119,33 @@ export class ReactLoopAgent extends Agent {
|
|||||||
return id
|
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.
|
* 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
|
* The cause is signal payload for observers and the durable turn/end
|
||||||
|
|||||||
@@ -140,13 +140,9 @@ describe('config-driven session id', () => {
|
|||||||
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
||||||
source: { kind: 'plugin', plugin: 'test' },
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
})
|
})
|
||||||
await expect.poll(async () => {
|
await ctx.sessions.flush(first.session)
|
||||||
try {
|
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
|
||||||
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
|
.toContain('persist before replacement')
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}).toContain('persist before replacement')
|
|
||||||
|
|
||||||
const firstDisposal = firstLoop.dispose()
|
const firstDisposal = firstLoop.dispose()
|
||||||
await cleanupStarted.promise
|
await cleanupStarted.promise
|
||||||
@@ -190,13 +186,9 @@ describe('config-driven session id', () => {
|
|||||||
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
|
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
|
||||||
source: { kind: 'plugin', plugin: 'test' },
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
})
|
})
|
||||||
await expect.poll(async () => {
|
await ctx.sessions.flush(first.session)
|
||||||
try {
|
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
|
||||||
return JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events)
|
.toContain('persist before cancellation')
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}).toContain('persist before cancellation')
|
|
||||||
|
|
||||||
const firstDisposal = firstLoop.dispose()
|
const firstDisposal = firstLoop.dispose()
|
||||||
await cleanupStarted.promise
|
await cleanupStarted.promise
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ describe('disposal leaves the two-state status contract balanced', () => {
|
|||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
await driverDone(agent) // must not hang
|
await driverDone(agent) // must not hang
|
||||||
|
|
||||||
await expect.poll(() => ctx.agents.get(SessionId('scoped')) === undefined).toBe(true)
|
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,6 @@ describe('agent/prompt-submit', () => {
|
|||||||
expect(log.some(e => e.type === 'turn/end')).toBe(false)
|
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 === 'user/message')).toBe(false)
|
||||||
expect(log.some(e => e.type === 'step/start')).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([])
|
expect(reasons).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -189,7 +188,6 @@ describe('agent/prompt-submit', () => {
|
|||||||
expect(userMsgs).toHaveLength(1)
|
expect(userMsgs).toHaveLength(1)
|
||||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
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(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||||
expect(reasons).toEqual([{ kind: 'completed' }])
|
expect(reasons).toEqual([{ kind: 'completed' }])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1041,7 +1041,7 @@ describe('agent loop', () => {
|
|||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
await driverDone(agent)
|
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 () => {
|
it('creates agents from config on startup', async () => {
|
||||||
|
|||||||
@@ -146,17 +146,17 @@ export type AgentCancelCause =
|
|||||||
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
export type AgentInterruptReason = AgentCancelCause | { readonly kind: 'disposed' }
|
||||||
|
|
||||||
/** Public live-agent handle with aliases over the unified delivery primitive. */
|
/** 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}. */
|
/** The single identity shared with {@link session}. */
|
||||||
abstract readonly id: SessionId
|
readonly id: SessionId
|
||||||
/** The provider route and model this agent's requests use. */
|
/** 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. */
|
/** 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. */
|
/** 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. */
|
/** 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.
|
* 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.
|
* @param options - target queue, wakeup decision, and source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}, stable across its `agent/inbox/*` events.
|
* @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
|
* 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 cause - the stable caller intent carried by the current turn signal.
|
||||||
* @param options - cancellation options; `keepInbox` preserves pending work.
|
* @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. */
|
/** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */
|
||||||
abstract whenIdle(): Promise<void>
|
whenIdle(): Promise<void>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue an ordinary follow-up turn and wake the driver — the
|
* Queue an ordinary follow-up turn and wake the driver — the
|
||||||
@@ -199,13 +199,7 @@ export abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
followup(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-turn',
|
|
||||||
wakeup: true,
|
|
||||||
source: options?.source ?? { kind: 'user' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
* Submit steering into the running turn — the `next-step`/wakeup preset of
|
||||||
@@ -218,13 +212,7 @@ export abstract class Agent {
|
|||||||
* @param options - message source.
|
* @param options - message source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
steer(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
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 running the model — the
|
* Append model-facing context without running the model — the
|
||||||
@@ -236,13 +224,7 @@ export abstract class Agent {
|
|||||||
* @param options - context source.
|
* @param options - context source.
|
||||||
* @returns the accepted message's {@link AgentMessageId}.
|
* @returns the accepted message's {@link AgentMessageId}.
|
||||||
*/
|
*/
|
||||||
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId {
|
inject(content: ContentBlock[], options?: AliasSendOptions): AgentMessageId
|
||||||
return this.send(content, {
|
|
||||||
target: 'next-step',
|
|
||||||
wakeup: false,
|
|
||||||
source: options?.source ?? { kind: 'plugin', plugin: '' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-open a turn on the current session log without a new prompt — the
|
* 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.
|
* immediately. Repeated calls before the scheduled retry coalesce.
|
||||||
* @throws while other agent work is running.
|
* @throws while other agent work is running.
|
||||||
*/
|
*/
|
||||||
abstract retry(): void
|
retry(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
|
|||||||
@@ -3,60 +3,38 @@ import { Context, Service, symbols } from 'cordis'
|
|||||||
import type { Events } from 'cordis'
|
import type { Events } from 'cordis'
|
||||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import AgentRegistry, {
|
import AgentRegistry, {
|
||||||
Agent,
|
|
||||||
AgentMessageId,
|
AgentMessageId,
|
||||||
agentEvents,
|
agentEvents,
|
||||||
agentInterruptReasonOf,
|
agentInterruptReasonOf,
|
||||||
} from '@deepseek-ai/dsh-agent'
|
} from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
Agent,
|
||||||
AgentCancelCause,
|
AgentCancelCause,
|
||||||
AgentFactory,
|
AgentFactory,
|
||||||
CreateAgentOptions,
|
CreateAgentOptions,
|
||||||
ResumeAgentOptions,
|
ResumeAgentOptions,
|
||||||
SendOptions,
|
|
||||||
} from '@deepseek-ai/dsh-agent'
|
} from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||||
const id = SessionId(rawId)
|
const id = SessionId(rawId)
|
||||||
// Agent is an abstract class, so its alias methods live on the prototype and
|
return {
|
||||||
// object spread would drop them; build the full literal and merge overrides.
|
|
||||||
return Object.assign(Object.create(Agent.prototype) as Agent, {
|
|
||||||
id,
|
id,
|
||||||
options: {},
|
options: {},
|
||||||
session: new Session(id),
|
session: new Session(id),
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
|
followup: () => AgentMessageId('stub'),
|
||||||
|
steer: () => AgentMessageId('stub'),
|
||||||
|
inject: () => AgentMessageId('stub'),
|
||||||
cancel() {},
|
cancel() {},
|
||||||
retry() {},
|
retry() {},
|
||||||
whenIdle() { return Promise.resolve() },
|
whenIdle() { return Promise.resolve() },
|
||||||
...overrides,
|
...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', () => {
|
describe('AgentRegistry', () => {
|
||||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
|
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'
|
import type { EpochHeader, SessionEvent } from './types.ts'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize a header to canonical form: an empty system prompt, an empty tool
|
* Normalize a header to canonical form: an empty system prompt and empty tool
|
||||||
* list, and an empty session prefix become absent fields, matching how requests
|
* list become absent fields, matching how requests are built. Logging, folding,
|
||||||
* are built. Logging, folding, and comparison use this one representation.
|
* and comparison use this one representation.
|
||||||
* @param header - the header to normalize (not mutated).
|
* @param header - the header to normalize (not mutated).
|
||||||
* @returns the canonical header.
|
* @returns the canonical header.
|
||||||
*/
|
*/
|
||||||
@@ -23,7 +23,6 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
|
|||||||
config: header.config,
|
config: header.config,
|
||||||
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
||||||
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
...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)
|
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;
|
* Field-wise equality over canonical headers. Tool schemas compare in order.
|
||||||
* the session prefix compares as canonical JSON.
|
|
||||||
* @param a - one canonical header.
|
* @param a - one canonical header.
|
||||||
* @param b - the other.
|
* @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 {
|
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
||||||
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
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 at = a.tools ?? []
|
||||||
const bt = b.tools ?? []
|
const bt = b.tools ?? []
|
||||||
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
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'
|
import type { JsonValue } from './json.ts'
|
||||||
|
|
||||||
/** Identifies one session in the store (and its persistence artifacts). */
|
/** Identifies one session in the store (and its persistence artifacts). */
|
||||||
@@ -117,11 +117,6 @@ export interface TurnEndReasonMap {
|
|||||||
disposed: { kind: 'disposed' }
|
disposed: { kind: 'disposed' }
|
||||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||||
'max-tokens': { kind: 'max-tokens' }
|
'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
|
* 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.
|
* 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,
|
* Logged request state outside derived history: call config, system prompt, and
|
||||||
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
|
* tools. The latest full `request/header` snapshot reconstructs it; canonical
|
||||||
* canonical empty optional fields are absent.
|
* empty optional fields are absent.
|
||||||
*/
|
*/
|
||||||
export interface EpochHeader {
|
export interface EpochHeader {
|
||||||
/** The conversation's call configuration (provider, model, and sampling scalars). */
|
/** The conversation's call configuration (provider, model, and sampling scalars). */
|
||||||
@@ -162,14 +157,6 @@ export interface EpochHeader {
|
|||||||
system?: string
|
system?: string
|
||||||
/** Assembled tool schemas; absent for a tool-less request. */
|
/** Assembled tool schemas; absent for a tool-less request. */
|
||||||
tools?: ToolSchema[]
|
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.
|
* injection may append this event between turns without running the model.
|
||||||
*/
|
*/
|
||||||
'user/message': UserMessageData
|
'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. */
|
/** Raw stream chunk — token-level replay fidelity. */
|
||||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||||
import type { EpochHeader, SessionEvent } 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' }
|
const CONFIG = { provider: 'mock', model: 'm' }
|
||||||
|
|
||||||
@@ -11,33 +11,28 @@ function tool(name: string, description = 'd'): ToolSchema {
|
|||||||
return { name, description, parameters: { type: 'object' } }
|
return { name, description, parameters: { type: 'object' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function msg(text: string): Message {
|
|
||||||
return { role: 'user', content: [{ type: 'text', text }] }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('canonicalHeader', () => {
|
describe('canonicalHeader', () => {
|
||||||
it('normalizes empty optional fields to absence and preserves populated fields', () => {
|
it('normalizes empty optional fields to absence and preserves populated fields', () => {
|
||||||
expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG })
|
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
|
||||||
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
|
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
|
||||||
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
|
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('headerEquals', () => {
|
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', () => {
|
it('compares every canonical field and preserves tool order', () => {
|
||||||
expect(headerEquals(base, structuredClone(base))).toBe(true)
|
expect(headerEquals(base, structuredClone(base))).toBe(true)
|
||||||
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
|
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
|
||||||
expect(headerEquals(base, { ...base, system: '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: [] })).toBe(false)
|
||||||
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).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)
|
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', () => {
|
it('treats absent and empty tool arrays as equivalent canonical absence', () => {
|
||||||
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true)
|
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [] })).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ describe('dsh-agent-spine-demo bundle', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
handle.agent.followup([{ type: 'text', text: 'recover' }])
|
handle.agent.followup([{ type: 'text', text: 'recover' }])
|
||||||
await expect.poll(() => adapter.requests).toBe(2)
|
|
||||||
await waitForIdle(ctx, handle.agent)
|
await waitForIdle(ctx, handle.agent)
|
||||||
|
|
||||||
expect(adapter.requests).toBe(2)
|
expect(adapter.requests).toBe(2)
|
||||||
|
|||||||
@@ -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 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
|
||||||
case 'disposed': return 'was disposed'
|
case 'disposed': return 'was disposed'
|
||||||
case 'max-tokens': return 'reached the model output-token limit'
|
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'
|
case 'interrupted': return 'was interrupted during persistence recovery'
|
||||||
default: return `ended with ${JSON.stringify(reason)}`
|
default: return `ended with ${JSON.stringify(reason)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,7 +501,6 @@ describe('formatTurnFailure', () => {
|
|||||||
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
|
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
|
||||||
[{ kind: 'disposed' }, 'was disposed'],
|
[{ kind: 'disposed' }, 'was disposed'],
|
||||||
[{ kind: 'max-tokens' }, 'output-token limit'],
|
[{ kind: 'max-tokens' }, 'output-token limit'],
|
||||||
[{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'],
|
|
||||||
[{ kind: 'interrupted' }, 'persistence recovery'],
|
[{ kind: 'interrupted' }, 'persistence recovery'],
|
||||||
]
|
]
|
||||||
for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected)
|
for (const [reason, expected] of cases) expect(formatTurnFailure(reason)).toContain(expected)
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ interface RoundAttempt extends RoundIdentity {
|
|||||||
phase: 'queued' | 'admitted'
|
phase: 'queued' | 'admitted'
|
||||||
turn: number | undefined
|
turn: number | undefined
|
||||||
reason: TurnEndReason | undefined
|
reason: TurnEndReason | undefined
|
||||||
rejectedReason: string | undefined
|
|
||||||
stale: boolean
|
stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,9 +185,7 @@ export function apply(ctx: Context): void {
|
|||||||
const goal = currentGoal(state)
|
const goal = currentGoal(state)
|
||||||
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
|
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
|
||||||
&& goal.phase === 'active' && goal.activation === 'armed') {
|
&& goal.phase === 'active' && goal.activation === 'armed') {
|
||||||
const outcome = attempt.phase === 'queued' && attempt.rejectedReason !== undefined && !attempt.stale
|
const outcome = classifyGoalRound(attempt.reason, durable)
|
||||||
? { kind: 'blocked', code: 'prompt-rejected', message: attempt.rejectedReason } as const
|
|
||||||
: classifyGoalRound(attempt.reason, durable)
|
|
||||||
if (!attempt.stale) applyOutcome(state, goal, outcome)
|
if (!attempt.stale) applyOutcome(state, goal, outcome)
|
||||||
}
|
}
|
||||||
if (!readyToDrive(state)) return
|
if (!readyToDrive(state)) return
|
||||||
@@ -214,7 +211,6 @@ export function apply(ctx: Context): void {
|
|||||||
phase: 'queued',
|
phase: 'queued',
|
||||||
turn: undefined,
|
turn: undefined,
|
||||||
reason: undefined,
|
reason: undefined,
|
||||||
rejectedReason: undefined,
|
|
||||||
stale: false,
|
stale: false,
|
||||||
}
|
}
|
||||||
state.attempt = reservation
|
state.attempt = reservation
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type GoalRoundOutcome =
|
|||||||
| { readonly kind: 'pause'; readonly reason: string }
|
| { readonly kind: 'pause'; readonly reason: string }
|
||||||
| {
|
| {
|
||||||
readonly kind: 'blocked'
|
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 message: string
|
||||||
}
|
}
|
||||||
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
|
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
|
||||||
@@ -35,8 +35,6 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
|
|||||||
}
|
}
|
||||||
case 'max-tokens':
|
case 'max-tokens':
|
||||||
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached 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':
|
case 'disposed':
|
||||||
return { kind: 'disarm', reason: 'disposed' }
|
return { kind: 'disarm', reason: 'disposed' }
|
||||||
case 'interrupted':
|
case 'interrupted':
|
||||||
|
|||||||
@@ -141,8 +141,6 @@ describe('goal-round outcome policy', () => {
|
|||||||
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
|
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
|
||||||
[{ kind: 'max-tokens' }, true,
|
[{ kind: 'max-tokens' }, true,
|
||||||
{ kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }],
|
{ 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: 'disposed' }, true, { kind: 'disarm', reason: 'disposed' }],
|
||||||
[{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }],
|
[{ kind: 'interrupted' }, true, { kind: 'disarm', reason: 'interrupted' }],
|
||||||
[{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }],
|
[{ kind: 'completed' }, false, { kind: 'disarm', reason: 'durability-failed' }],
|
||||||
|
|||||||
@@ -176,6 +176,14 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
|
|||||||
retries.delete(agent)
|
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', (
|
const disposeListener = ctx.on('agent/request-error', (
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
turn: number,
|
turn: number,
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ function validateRetry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chainStart = retryChainStart(history, turn)
|
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')
|
.filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry')
|
||||||
if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) {
|
if (chainRetries.some(prior => prior.data.turn === turn && prior.data.step === step)) {
|
||||||
fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`)
|
fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`)
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ describe('real Loader composition', () => {
|
|||||||
loaded.llm.registerAdapter(['mock'], adapter)
|
loaded.llm.registerAdapter(['mock'], adapter)
|
||||||
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
|
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
|
||||||
agent.followup([{ type: 'text', text: 'recover' }])
|
agent.followup([{ type: 'text', text: 'recover' }])
|
||||||
await expect.poll(() => adapter.requests).toBe(2)
|
|
||||||
await agent.whenIdle()
|
await agent.whenIdle()
|
||||||
|
|
||||||
expect(adapter.requests).toBe(2)
|
expect(adapter.requests).toBe(2)
|
||||||
|
|||||||
@@ -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(
|
async function harness(
|
||||||
adapter: LlmAdapter,
|
adapter: LlmAdapter,
|
||||||
config: retry.Config = {},
|
config: retry.Config = {},
|
||||||
@@ -263,6 +273,43 @@ describe('bounded transient retry policy', () => {
|
|||||||
expect(adapter.requests).toHaveLength(4)
|
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 () => {
|
it('accepts the zero-delay lower jitter bound', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
const adapter = new ScriptedAdapter([
|
const adapter = new ScriptedAdapter([
|
||||||
|
|||||||
@@ -204,8 +204,7 @@ export interface GenerateOptions {
|
|||||||
/**
|
/**
|
||||||
* Ordered conversation messages, exactly as the provider sees them (after
|
* Ordered conversation messages, exactly as the provider sees them (after
|
||||||
* the `system` slot). A loop-built request assembles them as
|
* the `system` slot). A loop-built request assembles them as
|
||||||
* `EpochHeader.messagePrefix` + the derived history (dsh-agent-loop); a
|
* the derived history (dsh-agent-loop); a hand-built one-shot passes any list.
|
||||||
* hand-built one-shot passes any list.
|
|
||||||
*/
|
*/
|
||||||
messages: Message[]
|
messages: Message[]
|
||||||
/** System prompt text (adapters map to the provider's system slot). */
|
/** System prompt text (adapters map to the provider's system slot). */
|
||||||
|
|||||||
@@ -378,7 +378,6 @@ export class TokenMeterService extends Service {
|
|||||||
private _estimateHeader(header: EpochHeader | undefined): number {
|
private _estimateHeader(header: EpochHeader | undefined): number {
|
||||||
if (header === undefined) return 0
|
if (header === undefined) return 0
|
||||||
let tokens = 0
|
let tokens = 0
|
||||||
for (const message of header.messagePrefix ?? []) tokens += this.estimateMessage(message)
|
|
||||||
if (header.system !== undefined) {
|
if (header.system !== undefined) {
|
||||||
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
tokens += Math.ceil(header.system.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ export class LinkWorkspace {
|
|||||||
try {
|
try {
|
||||||
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
|
manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as PackageManifest
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
|
|
||||||
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
|
throw new Error(`cannot read linked package at ${directory}: ${String(error)}`)
|
||||||
}
|
}
|
||||||
if (!manifest.name || typeof manifest.name !== 'string') continue
|
if (!manifest.name || typeof manifest.name !== 'string') continue
|
||||||
|
|||||||
@@ -386,7 +386,7 @@ describe('package manager strategies', () => {
|
|||||||
temporary.push(unreadable)
|
temporary.push(unreadable)
|
||||||
await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
|
await mkdir(join(unreadable, 'vendor', 'bad'), { recursive: true })
|
||||||
await mkdir(join(unreadable, 'packages'), { 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-'))
|
const unnamed = await mkdtemp(join(tmpdir(), 'dsh-link-unnamed-'))
|
||||||
temporary.push(unnamed)
|
temporary.push(unnamed)
|
||||||
await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })
|
await mkdir(join(unnamed, 'vendor', 'unnamed'), { recursive: true })
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export function extractSessionEventText(event: SessionEvent): string {
|
|||||||
case 'assistant/message':
|
case 'assistant/message':
|
||||||
case 'steering/message':
|
case 'steering/message':
|
||||||
return contentText(event.data.content)
|
return contentText(event.data.content)
|
||||||
case 'prompt/blocked':
|
|
||||||
return joinText([contentText(event.data.content), event.data.reason])
|
|
||||||
case 'tool/call':
|
case 'tool/call':
|
||||||
return joinText([event.data.name, event.data.arguments])
|
return joinText([event.data.name, event.data.arguments])
|
||||||
case 'tool/result':
|
case 'tool/result':
|
||||||
@@ -51,8 +49,6 @@ function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string
|
|||||||
: joinText(['error', reason.message, reason.code ?? ''])
|
: joinText(['error', reason.message, reason.code ?? ''])
|
||||||
case 'aborted':
|
case 'aborted':
|
||||||
return 'aborted'
|
return 'aborted'
|
||||||
case 'rejected':
|
|
||||||
return joinText(['rejected', reason.reason])
|
|
||||||
case 'disposed':
|
case 'disposed':
|
||||||
case 'max-tokens':
|
case 'max-tokens':
|
||||||
case 'interrupted':
|
case 'interrupted':
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
|
|||||||
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
||||||
*
|
*
|
||||||
* `completed` and the defensive `error` case map to `end_turn`;
|
* `completed` and the defensive `error` case map to `end_turn`;
|
||||||
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
|
* `max-tokens` maps to `max_tokens`; `aborted` and `disposed` map to
|
||||||
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
|
* `cancelled`. The bridge rejects error turns before this mapping. Unknown
|
||||||
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
|
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
|
||||||
* the prompt RPC.
|
* the prompt RPC.
|
||||||
* @param reason - the harness turn-end reason to translate.
|
* @param reason - the harness turn-end reason to translate.
|
||||||
@@ -34,8 +34,6 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
|||||||
return 'cancelled'
|
return 'cancelled'
|
||||||
case 'disposed':
|
case 'disposed':
|
||||||
return 'cancelled'
|
return 'cancelled'
|
||||||
case 'rejected':
|
|
||||||
return 'cancelled'
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'end_turn'
|
return 'end_turn'
|
||||||
// Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire
|
// Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ describe('turnEndToStopReason', () => {
|
|||||||
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
||||||
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
|
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
|
||||||
expect(turnEndToStopReason({ kind: 'disposed' })).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')
|
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import z from 'schemastery'
|
|||||||
import {
|
import {
|
||||||
installAgentLlmTarget,
|
installAgentLlmTarget,
|
||||||
type Agent,
|
type Agent,
|
||||||
|
type AgentMessageId,
|
||||||
type AgentLlmTarget,
|
type AgentLlmTarget,
|
||||||
type AgentLlmTargetRef,
|
type AgentLlmTargetRef,
|
||||||
type AgentStatus,
|
type AgentStatus,
|
||||||
@@ -1299,7 +1300,6 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
|||||||
case 'error': return `turn ${event.data.turn}: error`
|
case 'error': return `turn ${event.data.turn}: error`
|
||||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
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`
|
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||||
default: return `turn ${event.data.turn}: unknown result`
|
default: return `turn ${event.data.turn}: unknown result`
|
||||||
}
|
}
|
||||||
@@ -1875,11 +1875,10 @@ export function createTuiChat(
|
|||||||
let toolsExpanded = false
|
let toolsExpanded = false
|
||||||
let streaming: StreamingAssistantComponent | undefined
|
let streaming: StreamingAssistantComponent | undefined
|
||||||
let runningStatus: RunningStatus | undefined
|
let runningStatus: RunningStatus | undefined
|
||||||
// TUI steering submissions that the loop has not yet drained, shown as a
|
// TUI steering submissions that the inbox has not yet claimed or discarded.
|
||||||
// badge on the status line. Each entry is the submitted message's serialized
|
// Correlation ids avoid guessing whether a running-state submission actually
|
||||||
// source, so an unrelated steering/message cannot consume its slot. Leaving
|
// joined steering or fell back to the queued-turn FIFO during turn close.
|
||||||
// `running` clears entries discarded by cancellation.
|
const pendingSteering = new Set<AgentMessageId>()
|
||||||
const pendingSteering: string[] = []
|
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let shuttingDown: Promise<void> | undefined
|
let shuttingDown: Promise<void> | undefined
|
||||||
// Optional: skills mount conditionally, so read the global service store
|
// Optional: skills mount conditionally, so read the global service store
|
||||||
@@ -2124,7 +2123,7 @@ export function createTuiChat(
|
|||||||
const renderStatus = (running: RunningStatus): void => {
|
const renderStatus = (running: RunningStatus): void => {
|
||||||
const at = now()
|
const at = now()
|
||||||
running.loader.setMessage(
|
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 phase = prior?.phase ?? 'waiting'
|
||||||
const phaseStartedAt = prior?.phaseStartedAt ?? at
|
const phaseStartedAt = prior?.phaseStartedAt ?? at
|
||||||
const stepStartedAt = prior?.stepStartedAt ?? 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)
|
const loader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), message)
|
||||||
statusContainer.addChild(loader)
|
statusContainer.addChild(loader)
|
||||||
const running: RunningStatus = {
|
const running: RunningStatus = {
|
||||||
@@ -2264,9 +2263,6 @@ export function createTuiChat(
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'prompt/blocked':
|
|
||||||
appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning')
|
|
||||||
break
|
|
||||||
case 'assistant/chunk':
|
case 'assistant/chunk':
|
||||||
if (options.renderChunks) {
|
if (options.renderChunks) {
|
||||||
if (streaming === undefined) {
|
if (streaming === undefined) {
|
||||||
@@ -2326,8 +2322,6 @@ export function createTuiChat(
|
|||||||
appendNotice('Turn cancelled.', 'warning')
|
appendNotice('Turn cancelled.', 'warning')
|
||||||
} else if (event.data.reason.kind === 'max-tokens') {
|
} else if (event.data.reason.kind === 'max-tokens') {
|
||||||
appendNotice('The model reached its output-token limit.', 'warning')
|
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') {
|
} else if (event.data.reason.kind === 'interrupted') {
|
||||||
appendNotice('The previous process ended during this turn.', 'warning')
|
appendNotice('The previous process ended during this turn.', 'warning')
|
||||||
}
|
}
|
||||||
@@ -2788,8 +2782,7 @@ export function createTuiChat(
|
|||||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||||
} else if (agent.status === 'running') {
|
} else if (agent.status === 'running') {
|
||||||
const source = { kind: 'user' } as const
|
const source = { kind: 'user' } as const
|
||||||
agent.steer(content, { source })
|
pendingSteering.add(agent.steer(content, { source }))
|
||||||
pendingSteering.push(JSON.stringify(source))
|
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
} else {
|
} else {
|
||||||
agent.followup(content, { source: { kind: 'user' } })
|
agent.followup(content, { source: { kind: 'user' } })
|
||||||
@@ -3137,17 +3130,6 @@ export function createTuiChat(
|
|||||||
if (event.type === 'tool/result') fileSearch.invalidate()
|
if (event.type === 'tool/result') fileSearch.invalidate()
|
||||||
recordEventUsage(tokens, event)
|
recordEventUsage(tokens, event)
|
||||||
advanceTurnPhase(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') {
|
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
|
||||||
rebuildTranscript(false)
|
rebuildTranscript(false)
|
||||||
return
|
return
|
||||||
@@ -3155,12 +3137,24 @@ export function createTuiChat(
|
|||||||
renderEvent(event, { addHistory: false, renderChunks: true })
|
renderEvent(event, { addHistory: false, renderChunks: true })
|
||||||
requestRender()
|
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) => {
|
const disposeStatus = ctx.on('agent/status', (subject, status) => {
|
||||||
if (subject !== agent) return
|
if (subject !== agent) return
|
||||||
// Leaving 'running' ends the turn's status line; clear any badge so the
|
// Leaving 'running' ends the turn's status line; clear any badge so the
|
||||||
// next running turn starts from zero (and a cancellation, which discards
|
// next running turn starts from zero (and a cancellation, which discards
|
||||||
// the queue without logging drains, cannot strand a stale count).
|
// the queue without logging drains, cannot strand a stale count).
|
||||||
if (status !== 'running') pendingSteering.length = 0
|
if (status !== 'running') pendingSteering.clear()
|
||||||
setStatus(status)
|
setStatus(status)
|
||||||
})
|
})
|
||||||
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
|
const disposeError = ctx.on('agent/error', (subject, turn, step, error) => {
|
||||||
@@ -3183,6 +3177,8 @@ export function createTuiChat(
|
|||||||
disposeCommandChanges()
|
disposeCommandChanges()
|
||||||
stopBannerReveal()
|
stopBannerReveal()
|
||||||
disposeSessionEvents()
|
disposeSessionEvents()
|
||||||
|
disposeDequeued()
|
||||||
|
disposeDiscarded()
|
||||||
disposeStatus()
|
disposeStatus()
|
||||||
disposeError()
|
disposeError()
|
||||||
disposeAgent()
|
disposeAgent()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface FakeAgent extends Agent {
|
|||||||
sent: ContentBlock[][]
|
sent: ContentBlock[][]
|
||||||
sentOptions: (SendOptions | AliasSendOptions | undefined)[]
|
sentOptions: (SendOptions | AliasSendOptions | undefined)[]
|
||||||
steered: ContentBlock[][]
|
steered: ContentBlock[][]
|
||||||
|
steeredIds: AgentMessageId[]
|
||||||
steeredOptions: (AliasSendOptions | undefined)[]
|
steeredOptions: (AliasSendOptions | undefined)[]
|
||||||
injected: ContentBlock[][]
|
injected: ContentBlock[][]
|
||||||
injectedOptions: (AliasSendOptions | undefined)[]
|
injectedOptions: (AliasSendOptions | undefined)[]
|
||||||
@@ -161,6 +162,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
options.beforeMount?.(session)
|
options.beforeMount?.(session)
|
||||||
const sent: ContentBlock[][] = []
|
const sent: ContentBlock[][] = []
|
||||||
const steered: ContentBlock[][] = []
|
const steered: ContentBlock[][] = []
|
||||||
|
const steeredIds: AgentMessageId[] = []
|
||||||
const sentOptions: (SendOptions | AliasSendOptions | undefined)[] = []
|
const sentOptions: (SendOptions | AliasSendOptions | undefined)[] = []
|
||||||
const steeredOptions: (AliasSendOptions | undefined)[] = []
|
const steeredOptions: (AliasSendOptions | undefined)[] = []
|
||||||
const injected: ContentBlock[][] = []
|
const injected: ContentBlock[][] = []
|
||||||
@@ -175,6 +177,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
sent,
|
sent,
|
||||||
sentOptions,
|
sentOptions,
|
||||||
steered,
|
steered,
|
||||||
|
steeredIds,
|
||||||
steeredOptions,
|
steeredOptions,
|
||||||
injected,
|
injected,
|
||||||
injectedOptions,
|
injectedOptions,
|
||||||
@@ -192,7 +195,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
steer(content, options) {
|
steer(content, options) {
|
||||||
steered.push(content)
|
steered.push(content)
|
||||||
steeredOptions.push(options)
|
steeredOptions.push(options)
|
||||||
return AgentMessageId('stub')
|
const id = AgentMessageId(`steering-${steeredIds.length + 1}`)
|
||||||
|
steeredIds.push(id)
|
||||||
|
return id
|
||||||
},
|
},
|
||||||
inject(content, options) {
|
inject(content, options) {
|
||||||
injected.push(content)
|
injected.push(content)
|
||||||
|
|||||||
@@ -1363,6 +1363,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
result.terminal.send('\r')
|
result.terminal.send('\r')
|
||||||
}
|
}
|
||||||
const drainSteering = (text: string): void => {
|
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' })
|
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()
|
await tick()
|
||||||
expect(result.terminal.output).toContain('1 queued')
|
expect(result.terminal.output).toContain('1 queued')
|
||||||
|
|
||||||
// A steering/message whose source matches no pending badge entry (here a
|
// A steering/message has no inbox identity and therefore cannot consume a
|
||||||
// plugin source with no tracked enqueue) pops nothing, so it cannot consume
|
// pending slot by itself.
|
||||||
// a pending user slot even when it drains first.
|
|
||||||
result.terminal.output = ''
|
result.terminal.output = ''
|
||||||
result.session.append('steering/message', {
|
result.session.append('steering/message', {
|
||||||
turn: 1,
|
turn: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user