Merge branch 'codex/goal-commands' into codex/ralph-tool

# Conflicts:
#	docs/module-graph.md
This commit is contained in:
Tianyi Cui
2026-07-20 23:15:34 +08:00
122 changed files with 3640 additions and 394 deletions

View File

@@ -47,6 +47,7 @@
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",

View File

@@ -95,9 +95,10 @@ export class BasicCompactService extends CompactService {
}
})
ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => {
if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE
|| retryAttempt >= this.config.maxOverflowRetries
ctx.on('agent/request-error', async (agent, _turn, _step, _error, failure, priorFailures, signal, next) => {
const priorOverflowFailures = priorFailures.filter(item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE).length
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE
|| priorOverflowFailures >= this.config.maxOverflowRetries
|| signal.aborted) return next()
const generation = agent.session.surface.replaceGeneration

View File

@@ -141,14 +141,10 @@ export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
/** Map a terminal summarization finish to its fail-closed error. */
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'error': {
const error = new Error(finish.message) as Error & { code?: string }
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'error':
case 'aborted': {
const error = new Error('summarization stream aborted') as Error & { code?: string }
error.code = 'ABORTED'
const error = new Error(finish.failure.message) as Error & { code?: string }
error.code = finish.failure.code
return error
}
case 'max-tokens': {

View File

@@ -7,7 +7,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -872,9 +872,9 @@ describe('default one-shot summarizer', () => {
})
it.each([
[{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/],
[{ kind: 'error', message: 'opaque' }, undefined, /opaque/],
[{ kind: 'aborted' }, 'ABORTED', /aborted/],
[{ kind: 'error', failure: { message: 'provider failed', code: 'PROVIDER' } }, 'PROVIDER', /provider failed/],
[{ kind: 'error', failure: { message: 'opaque', code: 'UNKNOWN' } }, 'UNKNOWN', /opaque/],
[{ kind: 'aborted', failure: { message: 'summarization aborted', code: 'ABORTED' } }, 'ABORTED', /aborted/],
[{ kind: 'max-tokens' }, 'MAX_TOKENS', /token cap/],
] as Array<[(StreamChunk & { type: 'finish' })['reason'], string | undefined, RegExp]>) (
'rejects terminal finish %#',
@@ -912,7 +912,9 @@ describe('automatic listener and loader composition', () => {
signal = SIGNAL,
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
): Promise<{ action: 'fail' | 'retry' }> {
return ctx.waterfall('agent/request-error', owner, 1, 1, error, retryAttempt, signal, next)
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
return ctx.waterfall('agent/request-error', owner, 1, 1, error, failure, priorFailures, signal, next)
}
function overflow(message = 'provider overflow'): Error & { code: string } {

View File

@@ -11,6 +11,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
@@ -61,7 +62,10 @@ class OverflowRecoveryAdapter extends LlmAdapter {
readonly conversationRequests: GenerateOptions[] = []
readonly summaryRequests: GenerateOptions[] = []
constructor(private readonly delivery: 'thrown' | 'in-band') {
constructor(
private readonly delivery: 'thrown' | 'in-band',
private readonly transientAfterOverflow = false,
) {
super()
}
@@ -83,12 +87,17 @@ class OverflowRecoveryAdapter extends LlmAdapter {
type: 'finish',
reason: {
kind: 'error',
message: 'request too large for model context',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
failure: {
message: 'request too large for model context',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
},
},
}
return
}
if (this.transientAfterOverflow && this.conversationRequests.length === 2) {
throw new LlmError('temporary provider outage', 'SERVER')
}
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } }
yield { type: 'finish', reason: { kind: 'stop' } }
@@ -134,6 +143,29 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
})
}
function seedOverflowHistory(agent: Agent): void {
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
agent.session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
agent.session.append('user/message', {
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
agent.session.append('step/start', { turn, step: 1 })
agent.session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
}, { surfaceOp: 'append' })
agent.session.append('step/end', { turn, step: 1 })
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
}
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
@@ -241,26 +273,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
})
for (let turn = 1; turn <= 2; turn += 1) {
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
agent.session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
agent.session.append('user/message', {
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
agent.session.append('step/start', { turn, step: 1 })
agent.session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
}, { surfaceOp: 'append' })
agent.session.append('step/end', { turn, step: 1 })
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
@@ -299,4 +312,47 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
}
},
)
it('keeps context-overflow and transient retry budgets independent in one sequence', async () => {
const ctx = new Context()
const adapter = new OverflowRecoveryAdapter('thrown', true)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(LlmRetry, {
maxTransientRetries: 1,
initialDelayMs: 1,
maxDelayMs: 1,
jitterRatio: 0,
})
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
ctx.llm.registerAdapter(['mock'], adapter)
await ctx.plugin(BasicCompactService, {
thresholdRatio: 1,
retainTokens: 100,
maxTokens: 64,
compactionRetries: 0,
maxOverflowRetries: 1,
})
try {
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
seedOverflowHistory(agent)
agent.send([{ type: 'text', text: 'continue from history' }])
await agent.whenIdle()
expect(adapter.conversationRequests).toHaveLength(3)
expect(adapter.summaryRequests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
.toEqual([1, 2, 3])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
})
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -766,8 +766,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
{
name: 'agent/request-error',
mode: 'waterfall',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>',
jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param retryAttempt - zero-based number of prior recovery retries.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
signature: '\'agent/request-error\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>',
jsDoc: '/**\n * Recover a model-request failure after its failed step has closed. `retry`\n * opens a new numbered step; `fail` preserves the original request error.\n * Call `next()` to delegate to the next recovery listener or the default.\n * @param agent - the agent whose request failed.\n * @param turn - the open turn number.\n * @param step - the failed step number.\n * @param error - the original model-request failure.\n * @param failure - serializable facts normalized at the final adapter boundary.\n * @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.\n * @param signal - the turn abort signal.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode waterfall\n */',
summary: 'Recover a model-request failure after its failed step has closed.',
},
{
@@ -1246,7 +1246,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'FinishReasonMap',
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n };\n \'error\': {\n kind: \'error\';\n message: string;\n code?: string;\n };\n}',
declaration: 'export interface FinishReasonMap {\n \'stop\': {\n kind: \'stop\';\n };\n \'tool-calls\': {\n kind: \'tool-calls\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n \'aborted\': {\n kind: \'aborted\';\n failure: LlmFailure;\n };\n \'error\': {\n kind: \'error\';\n failure: LlmFailure;\n };\n}',
},
{
name: 'FsDirEntry',
@@ -1344,6 +1344,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'LlmCallConfig',
declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}',
},
{
name: 'LlmFailure',
declaration: 'export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n}',
},
{
name: 'LlmModelInfo',
declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}',
@@ -1380,6 +1384,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
},
{
name: 'ProviderRequestId',
declaration: 'export type ProviderRequestId = Branded<\'ProviderRequestId\'>;',
},
{
name: 'PrunedEntry',
declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}',
@@ -1734,7 +1742,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'TurnEndReasonMap',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n message: string;\n code?: string;\n };\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason?: string;\n };\n error: {\n kind: \'error\';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: \'disposed\';\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n rejected: {\n kind: \'rejected\';\n reason: string;\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
},
{
name: 'TurnTrigger',

View File

@@ -56,7 +56,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. Effective cancellation resolves its reason and emits `agent/cancel-requested` before clearing pending work or aborting the current step; notification failures are contained, queued work added by an observer is included in the same broad clear, and idle cancellation emits nothing. Undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
@@ -65,6 +65,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute``tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` on `agent/request-error`, with finite code-specific budgets and non-surface `llm/retry` status events
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
- Persistence: `session/event` + `session/flush`

View File

@@ -6,9 +6,9 @@
*/
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
@@ -28,24 +28,29 @@ function toError(error: unknown): RequestError {
/** Distinguishes final model-request failures from failures in later step processing. */
class TerminalModelRequestFailure extends Error {
constructor(readonly requestError: RequestError) {
super(requestError.message, { cause: requestError })
constructor(
readonly requestError: RequestError,
readonly failure: LlmFailure,
) {
super(failure.message, { cause: requestError })
this.name = 'TerminalModelRequestFailure'
}
}
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
function finishError(finish: FinishReason): RequestError | undefined {
function finishError(finish: FinishReason): { error: RequestError; failure: LlmFailure } | undefined {
switch (finish.kind) {
case 'error': {
const error: RequestError = new Error(finish.message)
if (finish.code !== undefined) error.code = finish.code
return error
}
case 'error':
case 'aborted': {
const error: RequestError = new Error('model stream aborted')
error.code = 'ABORTED'
return error
const facts = finish.failure
const error = new LlmError(facts.message, facts.code, {
...facts.status === undefined ? {} : { status: facts.status },
...facts.providerRetryAfterMs === undefined
? {}
: { providerRetryAfterMs: facts.providerRetryAfterMs },
...facts.requestId === undefined ? {} : { requestId: facts.requestId },
})
return { error, failure: error.failure }
}
// stop / tool-calls / max-tokens / plugin-added kinds → not a failure.
default:
@@ -64,6 +69,12 @@ function errorData(err: RequestError): { message: string; code?: string } {
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
}
/** Preserve cause diagnostics, falling back to adapter-normalized prose for a hostile Error. */
function durableFailure(err: RequestError, failure: LlmFailure): LlmFailure {
const message = errorChain(err)
return { ...failure, message: message === '<unrenderable value>' ? failure.message : message }
}
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
switch (finish.kind) {
@@ -208,7 +219,7 @@ async function runTurn(
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
let requestRetryAttempt = 0
let requestFailureHistory: readonly LlmFailure[] = Object.freeze([])
let stepOpen = false
let errorReported = false
let terminalStopped = false
@@ -221,10 +232,12 @@ async function runTurn(
}
// Record the durable turn failure once and contain the live error notification.
const failTurn = (err: RequestError): void => {
const failTurn = (err: RequestError, failure?: LlmFailure): void => {
if (errorReported) return
errorReported = true
reason = { kind: 'error', step, ...errorData(err) }
reason = failure === undefined
? { kind: 'error', step, ...errorData(err) }
: { kind: 'error', step, failure: durableFailure(err, failure) }
try {
events.emit('agent/error', turn, step, err)
} catch {
@@ -350,14 +363,14 @@ async function runTurn(
let stepOutcome:
| { hadToolCalls: boolean; finish: FinishReason }
| { requestError: RequestError }
| { requestError: RequestError; failure: LlmFailure }
| { error: RequestError }
try {
stepOutcome = await runStep(
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
} catch (error: unknown) {
if (error instanceof TerminalModelRequestFailure) {
stepOutcome = { requestError: error.requestError }
stepOutcome = { requestError: error.requestError, failure: error.failure }
} else {
stepOutcome = { error: toError(error) }
}
@@ -380,7 +393,7 @@ async function runTurn(
try {
recoveryDecision = await events.waterfall(
'agent/request-error', turn, step, stepOutcome.requestError,
requestRetryAttempt, abort.signal,
stepOutcome.failure, requestFailureHistory, abort.signal,
() => Promise.resolve(defaultDecision),
)
} catch (recoveryError: unknown) {
@@ -401,10 +414,10 @@ async function runTurn(
}
switch (recoveryDecision.action) {
case 'retry':
requestRetryAttempt += 1
requestFailureHistory = Object.freeze([...requestFailureHistory, stepOutcome.failure])
continue
case 'fail':
failTurn(stepOutcome.requestError)
failTurn(stepOutcome.requestError, stepOutcome.failure)
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
@@ -432,7 +445,7 @@ async function runTurn(
break
}
requestRetryAttempt = 0
requestFailureHistory = Object.freeze([])
// Preserve max-token completion unless a later disposal, abort, or error wins.
const stepReason = stepFinishReason(stepOutcome.finish)
@@ -632,13 +645,14 @@ async function runStep(
assembler.push(chunk)
}
} catch (error: unknown) {
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
const failure = llmFailureOf(stream, error)
if (failure !== undefined && error instanceof Error) throw new TerminalModelRequestFailure(error, failure)
throw error
}
// Normalize failure finish chunks into the same path as thrown stream errors.
const stepError = finishError(assembler.finish)
if (stepError) throw new TerminalModelRequestFailure(stepError)
if (stepError) throw new TerminalModelRequestFailure(stepError.error, stepError.failure)
const recordAssistantMessage = (
assembledContent: ContentBlock[],

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import LlmService, { CallId, ContentBlock, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
@@ -945,8 +945,15 @@ describe('discriminated SessionEvent narrows without casts', () => {
describe('a finish-error stream chunk ends the turn as error, not completed', () => {
it('translates finish {kind:error} into a turn error with a logged error event', async () => {
// A finish-error chunk must not produce a completed assistant turn.
const failure = {
message: 'provider 401',
code: 'AUTH',
status: 401,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('finish-request-1'),
}
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'provider 401', code: 'AUTH' } },
{ type: 'finish', reason: { kind: 'error', failure } },
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
@@ -958,20 +965,20 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
const events = [...agent.session.events]
// The durable failure lives on turn/end.reason (with the failing step), not
// a standalone error event.
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
it('translates finish {kind:aborted} into a turn error coded ABORTED', async () => {
const abortedStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'aborted' } },
{ type: 'finish', reason: { kind: 'aborted', failure: { message: 'model stream aborted', code: 'ABORTED' } } },
]
const adapter = new MockAdapter([abortedStream])
const ctx = await harness(adapter)
@@ -983,13 +990,13 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
})
it('handles a finish error without a code (code key omitted)', async () => {
const errorStream: StreamChunk[] = [
{ type: 'finish', reason: { kind: 'error', message: 'codeless failure' } },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'codeless failure', code: 'UNKNOWN' } } },
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
@@ -1001,7 +1008,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
})
})
@@ -1121,7 +1128,7 @@ describe('turn and step boundary recovery', () => {
})
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider failed', code: 'UNKNOWN' } } }]
const adapter = new MockAdapter([errorStream])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
@@ -1151,7 +1158,7 @@ describe('turn and step boundary recovery', () => {
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
kind: 'error',
message: 'provider failed',
failure: { message: 'provider failed', code: 'UNKNOWN' },
})
})
@@ -1187,7 +1194,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
// Listener failure cannot interrupt error finalization or the next turn.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
@@ -1203,7 +1210,11 @@ describe('turn and step boundary recovery', () => {
expect(c.turnStart).toBe(1)
expect(c.turnEnd).toBe(1)
expect(c.stepStart).toBe(c.stepEnd)
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' })
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
kind: 'error',
step: 1,
failure: { message: 'provider 500', code: 'SERVER' },
})
// loop survives: a second turn runs to completion (invariants oracle would
// throw on its turn/start if turn 1 had been left open).
@@ -1348,7 +1359,7 @@ describe('turn and step boundary recovery', () => {
it('a throwing step/end observer cannot interrupt error finalization', async () => {
// Observer failure after step/end commit cannot interrupt turn finalization.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', failure: { message: 'provider 500', code: 'SERVER' } } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })

View File

@@ -187,7 +187,9 @@ describe('toError normalization', () => {
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
&& ('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code))
.toBe('UNKNOWN')
})
})
@@ -218,7 +220,8 @@ describe('coded error data emission', () => {
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.code).toBe('RATE_LIMIT')
expect('failure' in turnEnd.data.reason ? turnEnd.data.reason.failure.code : turnEnd.data.reason.code)
.toBe('RATE_LIMIT')
}
})
})

View File

@@ -3,10 +3,12 @@ import { Context } from 'cordis'
import LlmService, {
CallId,
CONTEXT_WINDOW_EXCEEDED_CODE,
HarnessError,
LlmAdapter,
LlmError,
ProviderRequestId,
} from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmFailure, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
@@ -258,16 +260,17 @@ describe('agent post-step and request-error lifecycle', () => {
it.each([
['thrown', contextError()],
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
['in-band', [{ type: 'finish', reason: { kind: 'error', failure: { message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE, status: 400 } } }] satisfies StreamChunk[]],
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' })
const attempts: number[] = []
ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => {
ctx.on('agent/request-error', async (subject, turn, step, error, facts, history) => {
expect(subject).toBe(agent)
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
attempts.push(attempt)
expect(facts.code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
attempts.push(history.length)
subject.session.append('context/message', {
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
source: { kind: 'plugin', plugin: 'test-recovery' },
@@ -295,7 +298,7 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let recoveries = 0
install(ctx)
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -326,7 +329,7 @@ describe('agent post-step and request-error lifecycle', () => {
})
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -359,7 +362,7 @@ describe('agent post-step and request-error lifecycle', () => {
}
const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -387,7 +390,7 @@ describe('agent post-step and request-error lifecycle', () => {
}
const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' })
let recoveries = 0
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
recoveries += 1
return next()
})
@@ -406,7 +409,7 @@ describe('agent post-step and request-error lifecycle', () => {
const ctx = await harness(makeAdapter(original))
const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
let seen: Error | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error
return next()
})
@@ -417,12 +420,90 @@ describe('agent post-step and request-error lifecycle', () => {
expect(seen).toBe(original)
})
it('keeps an adapter error with a hostile message accessor on the recovery path', async () => {
const original = Object.defineProperty(new HarnessError('provider failed', 'SERVER'), 'message', {
get() { throw new Error('SDK message accessor trap') },
})
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('hostile-message-recovery'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
ctx.on('agent/request-error', async (_agent, _turn, _step, error, failure, _history, _signal, next) => {
seenError = error
seenFailure = failure
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({ message: 'LLM adapter failed', code: 'SERVER' })
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { message: 'LLM adapter failed', code: 'SERVER' } } },
})
})
it('passes structured facts beside the original Error and records its cause chain on exhaustion', async () => {
const original = new LlmError('provider busy', 'RATE_LIMIT', {
cause: new Error('upstream connection reset'),
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
Object.freeze(original)
const ctx = await harness(new SynchronousDispatchFailureAdapter(original))
const agent = ctx.agentLoop.create(SessionId('structured-request-failure'), { provider: 'mock', model: 'mock' })
let seenError: Error | undefined
let seenFailure: LlmFailure | undefined
let seenHistory: readonly LlmFailure[] | undefined
ctx.on('agent/request-error', async (
_agent, _turn, _step, error, failure, history, _signal, next,
) => {
seenError = error
seenFailure = failure
seenHistory = history
return next()
})
send(agent)
await waitForIdle(ctx, agent)
expect(seenError).toBe(original)
expect(seenFailure).toEqual({
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
})
expect(seenHistory).toEqual([])
expect(Object.isFrozen(seenHistory)).toBe(true)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: {
reason: {
kind: 'error',
step: 1,
failure: {
message: 'provider busy: upstream connection reset',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-9'),
},
},
},
})
})
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
for (const scenario of ['iterator', 'no-adapter'] as const) {
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' })
let seen = ''
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _failure, _history, _signal, next) => {
seen = error.code ?? ''
return next()
})
@@ -436,14 +517,17 @@ describe('agent post-step and request-error lifecycle', () => {
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
const cappedCtx = await harness(capped)
const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' })
const cappedAttempts: number[] = []
cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => {
cappedAttempts.push(attempt)
return attempt < 1 ? { action: 'retry' } : next()
const cappedHistories: string[][] = []
cappedCtx.on('agent/request-error', async (
_agent, _turn, _step, _error, _failure, history, _signal, next,
) => {
const codes = history.map(entry => entry.code)
cappedHistories.push(codes)
return codes.length < 1 ? { action: 'retry' } : next()
})
send(cappedAgent)
await waitForIdle(cappedCtx, cappedAgent)
expect(cappedAttempts).toEqual([0, 1])
expect(cappedHistories).toEqual([[], [CONTEXT_WINDOW_EXCEEDED_CODE]])
const reset = new FailureScriptAdapter([
contextError('first overflow'),
@@ -458,14 +542,16 @@ describe('agent post-step and request-error lifecycle', () => {
async execute() { return [{ type: 'text', text: 'worked' }] },
}))
const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
const resetAttempts: { step: number; attempt: number }[] = []
resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => {
resetAttempts.push({ step, attempt })
return resetAttempts.length === 1 ? { action: 'retry' } : next()
const resetHistories: { step: number; codes: string[] }[] = []
resetCtx.on('agent/request-error', async (
_agent, _turn, step, _error, _failure, history, _signal, next,
) => {
resetHistories.push({ step, codes: history.map(entry => entry.code) })
return resetHistories.length === 1 ? { action: 'retry' } : next()
})
send(resetAgent)
await waitForIdle(resetCtx, resetAgent)
expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }])
expect(resetHistories).toEqual([{ step: 1, codes: [] }, { step: 3, codes: [] }])
})
it('preserves the original provider error when recovery throws', async () => {
@@ -479,7 +565,7 @@ describe('agent post-step and request-error lifecycle', () => {
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
data: { reason: { kind: 'error', failure: { message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } } },
})
})
@@ -489,7 +575,7 @@ describe('agent post-step and request-error lifecycle', () => {
const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' })
let entered!: () => void
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => {
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, signal) => {
entered()
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })

View File

@@ -44,7 +44,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: a retry opens a new numbered step after the failed step closes. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` and `agent/post-step` are serial checkpoints around a step's durable work, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, immutable prior-retried facts, and signal after the failed step closes; a retry opens a new numbered step. `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved reason, then clears queues and aborts; notification failures are contained and cannot veto the stop. The full rationale for scoped dispatch and terminal settlement is in the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
`PromptDecision.additionalContexts` is an array so every injected context keeps its own source and metadata. A `ContinuationDecision` reason is narrower: it becomes a `steering/message`, not a `context/message`, and therefore carries only content and source.

View File

@@ -7,7 +7,7 @@
import type { Context } from 'cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
@@ -286,12 +286,13 @@ declare module 'cordis' {
* @param turn - the open turn number.
* @param step - the failed step number.
* @param error - the original model-request failure.
* @param retryAttempt - zero-based number of prior recovery retries.
* @param failure - serializable facts normalized at the final adapter boundary.
* @param priorFailures - immutable failures that already authorized another request in this consecutive sequence.
* @param signal - the turn abort signal.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, retryAttempt: number, signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, priorFailures: readonly LlmFailure[], signal: AbortSignal, next: () => Promise<RequestErrorDecision>): Promise<RequestErrorDecision>
/**
* Override whether the turn continues. The default continues after tool
* calls or steering and stops otherwise; a continue reason becomes steering.

View File

@@ -60,11 +60,11 @@ Durable values need one accepted representation, not a check followed by a secon
### Session event vocabulary (`types.ts`)
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token usage and provider/model/replay provenance ride on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.
Every `SessionEvent` carries two optional top-level fields (structural metadata):

View File

@@ -1,5 +1,5 @@
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Identifies one session in the store (and its persistence artifacts). */
@@ -106,9 +106,13 @@ export interface TurnEndReasonMap {
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). `code` is the error's code when one was attached.
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other turn failures retain their live Error message/code.
*/
error: { kind: 'error'; step: number; message: string; code?: string }
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
| { message: string; code?: string; failure?: never }
)
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }

View File

@@ -39,6 +39,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |

View File

@@ -64,6 +64,8 @@ export interface Config {
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
goals?: agentCore.GoalConfig | false
/** Bounded transient model-request retry policy forwarded through agent-core. */
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
}
// Each front door owns a complete, directly readable config schema; extracting
@@ -87,6 +89,7 @@ export const Config: z<Config> = z.object({
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
goals: z.union([z.const(false), agentCore.GoalConfigSchema]),
llmRetry: agentCore.LlmRetryConfigSchema,
})
/* jscpd:ignore-end */

View File

@@ -20,6 +20,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-goal optional persisted same-session goal domain
@deepseek-ai/dsh-tool-goal optional model-facing goal controls
@deepseek-ai/dsh-goal-session optional same-session goal-round driver
@deepseek-ai/dsh-llm-retry bounded transient request retry policy
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash schema
@@ -45,19 +46,21 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, llmRetry? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
## Why a code bundle, not a shared YAML include
A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse.
## Model Experience
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, and `dsh-tools`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own.
Indirectly, through `dsh-system-prompt`, `dsh-tool-skill`, `dsh-tool-bash`, `dsh-tools`, and `dsh-llm-retry`, plus `dsh-tool-goal` and goal-round prompts when `goals` is enabled. The bundle adds no model-bound wrapper content of its own.
#### KV Cache effect

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin with optional persisted goals",
"description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
@@ -53,6 +54,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",

View File

@@ -28,6 +28,7 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
import * as llmRetry from '@deepseek-ai/dsh-llm-retry'
import { resolveDshHome } from '@deepseek-ai/dsh-home'
export const name = 'agent-spine-demo'
@@ -60,8 +61,9 @@ export interface GoalConfig {
* order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, and `toolBash`/`toolTasks` to the model-facing tool
* plugins this bundle owns. `goals` opts into and configures the persisted goal
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
* `goals` opts into and configures the persisted goal
* domain plus its model tool and same-session driver. Owner schemas supply defaults for optional input;
* workspace context instead requires an explicit byte budget or `false` because
* it changes model-visible input. Producer opt-in stays producer-local:
@@ -91,6 +93,8 @@ export interface Config {
toolTasks?: toolTasks.Config | false
/** Opt-in persisted same-session goal stack; set false or omit to leave it unmounted. */
goals?: GoalConfig | false
/** Bounded transient model-request retry policy. */
llmRetry?: llmRetry.Config
}
/** The skill config schema exported for app packages that forward `skills`. */
@@ -113,6 +117,9 @@ export const GoalConfigSchema: z<GoalConfig> = z.object({
tool: toolGoal.Config,
})
/** The bounded LLM retry schema exported for app packages that forward `llmRetry`. */
export const LlmRetryConfigSchema: z<llmRetry.Config> = llmRetry.Config
/** Intersect the owners' schemas so validation + defaulting stay identical. */
export const Config = z.intersect([
AgentLoop.Config,
@@ -125,7 +132,8 @@ export const Config = z.intersect([
toolBash: ToolBashConfigSchema,
toolTasks: z.union([z.const(false), ToolTasksConfigSchema]),
goals: z.union([z.const(false), GoalConfigSchema]),
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals'>>,
llmRetry: LlmRetryConfigSchema,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'goals' | 'llmRetry'>>,
]) as unknown as z<Config>
/**
@@ -145,6 +153,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
...config.toolTasks !== undefined ? { toolTasks: config.toolTasks } : {},
...config.goals !== undefined ? { goals: config.goals } : {},
...config.llmRetry !== undefined ? { llmRetry: config.llmRetry } : {},
}
}
@@ -181,6 +190,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome }))
}
ctx.plugin(AgentRegistry)
ctx.plugin(llmRetry, config.llmRetry ?? {})
if (config.goals !== undefined && config.goals !== false) {
ctx.plugin(GoalService, config.goals.domain ?? {})
ctx.plugin(toolGoal, config.goals.tool ?? {})

View File

@@ -10,7 +10,7 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { CallId, type Message } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter, LlmError, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
declare module '@deepseek-ai/dsh-tasks' {
@@ -101,6 +101,16 @@ function messageText(message: Message | undefined): string {
return message?.content.map(block => block.type === 'text' ? block.text : '').join('\n') ?? ''
}
class TransientOnceAdapter extends LlmAdapter {
requests = 0
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests += 1
if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER')
yield* textResponse('recovered by bundled policy')
}
}
describe('dsh-agent-spine-demo bundle', () => {
it('brings up the full default spine', async () => {
const ctx = await mount({ workspaceContext: false })
@@ -146,6 +156,37 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('loads and configures bounded request recovery for every bundled front door', async () => {
const adapter = new TransientOnceAdapter()
const ctx = await mount({
workspaceContext: false,
llmRetry: {
maxTransientRetries: 1,
initialDelayMs: 1,
maxDelayMs: 1,
jitterRatio: 0,
},
})
ctx.llm.registerAdapter(['mock'], adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('bundled-retry-session'),
meta: { cwd: process.cwd() },
agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.send([{ type: 'text', text: 'recover' }])
await waitForIdle(ctx, handle.agent)
expect(adapter.requests).toBe(2)
const retryEvents = handle.agent.session.events.filter(event => event.type === 'llm/retry')
expect(retryEvents).toHaveLength(1)
expect(retryEvents[0]?.data.retry).toBe(1)
expect(retryEvents[0]?.data.maxRetries).toBe(1)
expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy')
await handle.dispose()
await ctx.fiber.dispose()
})
it('includes the skill registry, local provider, and skill tool without builtin skills', async () => {
const ctx = await mount({ workspaceContext: false })
@@ -416,6 +457,7 @@ describe('dsh-agent-spine-demo bundle', () => {
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
toolTasks: false as const,
llmRetry: { maxTransientRetries: 1, jitterRatio: 0 },
}
expect(agentCore.pickSpineConfig(appConfig)).toEqual({
@@ -427,6 +469,7 @@ describe('dsh-agent-spine-demo bundle', () => {
skills: appConfig.skills,
toolBash: appConfig.toolBash,
toolTasks: appConfig.toolTasks,
llmRetry: appConfig.llmRetry,
})
expect(agentCore.pickSpineConfig({ workspaceContext: false })).toEqual({ workspaceContext: false })
})

View File

@@ -56,6 +56,9 @@
{
"path": "../../core/agent-loop"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../support/invariants"
},

View File

@@ -18,6 +18,7 @@ The package mounts no console logger, interactive UI, user-interaction service,
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
| `toolTasks` | owner defaults | generic `task_output` wait bounds |
| `llmRetry` | owner defaults | bounded transient model-request retry policy |
| `persistenceRoot` | `./.sessions` | JSONL session root |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `workspaceContext` | required | workspace-instruction byte budget, or `false` to disable loading |
@@ -41,7 +42,7 @@ Loader configs with bare package specifiers require `node --expose-internals` or
### Output formats
- `text` writes the last assistant message containing text, followed by one newline.
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums every model step in the task turn.
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message.
- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.

View File

@@ -219,7 +219,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
let usage: TokenUsage | undefined
const usageByStep = new Map<number, TokenUsage>()
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
@@ -254,9 +254,14 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
targetTurn = event.data.turn
}
observe(session.id, event)
if (event.type === 'assistant/chunk'
&& event.data.turn === targetTurn
&& event.data.chunk.type === 'usage') {
usageByStep.set(event.data.step, event.data.chunk.usage)
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.data.usage !== undefined) usage = addUsage(usage, event.data.usage)
if (event.data.usage !== undefined) usageByStep.set(event.data.step, event.data.usage)
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
@@ -294,6 +299,7 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
await ctx.sessions.flush(agent.session)
if (outputError !== undefined) throw outputError
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
return {
type: 'result',
success: reason.kind === 'completed',
@@ -365,7 +371,7 @@ export function formatTurnFailure(reason: TurnEndReason): string {
switch (reason.kind) {
case 'completed': return 'completed'
case 'aborted': return reason.reason === undefined ? 'was aborted' : `was aborted: ${reason.reason}`
case 'error': return `failed at step ${reason.step}: ${reason.message}`
case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
case 'disposed': return 'was disposed'
case 'max-tokens': return 'reached the model output-token limit'
case 'rejected': return `was rejected: ${reason.reason}`

View File

@@ -47,6 +47,8 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task control-tool config forwarded through agent-spine-demo. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Bounded transient model-request retry policy forwarded through agent-spine-demo. */
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
}
@@ -68,6 +70,7 @@ export const Config: z<Config> = z.object({
tools: ToolRegistry.Config,
toolBash: agentCore.ToolBashConfigSchema,
toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]),
llmRetry: agentCore.LlmRetryConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
})
/* jscpd:ignore-end */

View File

@@ -70,6 +70,15 @@ function toolResponse(usage: TokenUsage): StreamChunk[] {
]
}
function failedResponse(usage: TokenUsage): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'discarded' },
{ type: 'usage', usage },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'temporary', code: 'SERVER' } } },
]
}
function reasoningResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'reasoning' },
@@ -98,6 +107,7 @@ async function harness(script: readonly ScriptEntry[]): Promise<Harness> {
persistenceRoot: root,
skills: { local: { dshHome: join(skillHome, '.dsh'), agentsHome: join(skillHome, '.agents') } },
workspaceContext: false,
llmRetry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
})
await new Promise(resolve => setTimeout(resolve, 80))
ctx.llm.registerAdapter(['mock'], new ScriptedAdapter(script))
@@ -323,6 +333,21 @@ describe('runOneShot and executeCli', () => {
})
})
it('counts a failed retry attempt once even though it has no assistant message', async () => {
const failed = { inputTokens: 11, outputTokens: 2, cacheReadTokens: 3 }
const recovered = { inputTokens: 7, outputTokens: 5, reasoningTokens: 4 }
const { ctx } = await harness([failedResponse(failed), textResponse('done', recovered)])
const result = await runOneShot(ctx, { task: 'task' })
expect(result.usage).toEqual({
inputTokens: 18,
outputTokens: 7,
cacheReadTokens: 3,
reasoningTokens: 4,
})
})
it('keeps the prior text when a later assistant message has no text blocks', async () => {
const { ctx } = await harness([
toolResponse({ inputTokens: 1, outputTokens: 1 }),
@@ -463,6 +488,7 @@ describe('formatTurnFailure', () => {
[{ kind: 'aborted' }, 'was aborted'],
[{ kind: 'aborted', reason: 'stop' }, 'was aborted: stop'],
[{ kind: 'error', step: 2, message: 'bad' }, 'failed at step 2: bad'],
[{ kind: 'error', step: 3, failure: { message: 'provider bad', code: 'SERVER' } }, 'failed at step 3: provider bad'],
[{ kind: 'disposed' }, 'was disposed'],
[{ kind: 'max-tokens' }, 'output-token limit'],
[{ kind: 'rejected', reason: 'policy' }, 'was rejected: policy'],

View File

@@ -32,7 +32,7 @@ The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, t
| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes |
| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
| `error` with `RATE_LIMIT` | `blocked` with code `usage-limited` | no |
| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no |
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no |
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
@@ -67,5 +67,5 @@ Append-only within an epoch: each admitted round extends the existing conversati
- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred.
- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer.
- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; `RATE_LIMIT` only maps an observed provider stop into the blocked reason code `usage-limited`.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`.
- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy.

View File

@@ -27,10 +27,13 @@ export function classifyGoalRound(reason: TurnEndReason, durable: boolean): Goal
return { kind: 'continue' }
case 'aborted':
return { kind: 'pause', reason: reason.reason ?? 'cancelled' }
case 'error':
return reason.code === 'RATE_LIMIT'
? { kind: 'blocked', code: 'usage-limited', message: reason.message }
: { kind: 'blocked', code: 'turn-error', message: reason.message }
case 'error': {
const code = reason.failure?.code ?? reason.code
const message = reason.failure?.message ?? reason.message ?? 'model request failed'
return code === 'RATE_LIMIT' || code === 'QUOTA'
? { kind: 'blocked', code: 'usage-limited', message }
: { kind: 'blocked', code: 'turn-error', message }
}
case 'max-tokens':
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
case 'rejected':

View File

@@ -6,7 +6,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalView } from '@deepseek-ai/dsh-goal'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -127,6 +127,10 @@ describe('goal-round outcome policy', () => {
[{ kind: 'aborted' }, true, { kind: 'pause', reason: 'cancelled' }],
[{ kind: 'error', step: 1, message: 'slow down', code: 'RATE_LIMIT' }, true,
{ kind: 'blocked', code: 'usage-limited', message: 'slow down' }],
[{ kind: 'error', step: 1, failure: { message: 'credits exhausted', code: 'QUOTA' } }, true,
{ kind: 'blocked', code: 'usage-limited', message: 'credits exhausted' }],
[{ kind: 'error', step: 1, failure: { message: 'provider failed', code: 'SERVER' } }, true,
{ kind: 'blocked', code: 'turn-error', message: 'provider failed' }],
[{ kind: 'error', step: 1, message: 'broken' }, true,
{ kind: 'blocked', code: 'turn-error', message: 'broken' }],
[{ kind: 'max-tokens' }, true,
@@ -228,7 +232,7 @@ describe('same-session goal driving', () => {
})
it.each([
['rate limit', Object.assign(new Error('slow down'), { code: 'RATE_LIMIT' }), 'usage-limited'],
['rate limit', new LlmError('slow down', 'RATE_LIMIT'), 'usage-limited'],
['request error', new Error('provider broke'), 'turn-error'],
['max tokens', maxTokensResponse('unfinished'), 'max-tokens'],
] as const)('stops after a %s without an automatic retry', async (_label, response, code) => {

View File

@@ -6,7 +6,8 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a
|---|---|---|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `token-meter/` | Replay-aware request and surface token measurement | `ctx.tokenMeter` |
| `llm-retry/` | Bounded transient request retry policy | (listens to `agent/request-error`) |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) |
The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership.
The interface lives at `llm/llm/`; adapters, retry policy, and reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership.

View File

@@ -16,6 +16,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
thinking: enabled # optional; provider default is enabled
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default
models: # optional; defaults to V4 Flash and V4 Pro
- id: deepseek-v4-flash
name: DeepSeek V4 Flash
@@ -29,6 +30,8 @@ The plugin registers the single provider route `deepseek`. A request selects it
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
## App attribution
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode.
@@ -42,11 +45,11 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
## Errors
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `RATE_LIMIT` (429), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. A transport failure before any response (DNS, refused connection, TLS, proxy) throws `NETWORK` naming the configured endpoint and chaining fetch's `TypeError: fetch failed` as `cause`, so `errorChain` renders the underlying diagnosis; an abort keeps its `DOMException` so the loop classifies it as cancellation. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', code: <REASON>}` chunks.
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks.
## Testing
Unit suites run against a local `node:http` mock SSE server (no network). Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
Unit suites run against a local `node:http` mock SSE server (no network), including structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback.
## Model Experience

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -5,8 +5,9 @@
* @module dsh-llm-deepseek/adapter
*/
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { serializeRequest } from './serialize.ts'
import type { RequestDefaults } from './serialize.ts'
import { parseSse } from './sse.ts'
@@ -33,6 +34,27 @@ export interface DeepSeekAdapterOptions {
defaults?: RequestDefaults
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
models?: readonly DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs?: number
}
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT'
function providerRetryAfterMs(value: string | null): number | undefined {
if (value === null) return undefined
if (/^\d+$/.test(value)) {
const delay = Number(value) * 1_000
return Number.isFinite(delay) && delay > 0 ? delay : undefined
}
const delay = Date.parse(value) - Date.now()
return Number.isFinite(delay) && delay > 0 ? delay : undefined
}
function requestId(headers: Headers): ReturnType<typeof ProviderRequestId> | undefined {
const value = headers.get('x-request-id') ?? headers.get('x-deepseek-request-id')
return value === null || value.length === 0 ? undefined : ProviderRequestId(value)
}
/**
@@ -43,9 +65,10 @@ export interface DeepSeekAdapterOptions {
*/
export function httpErrorCode(status: number, error?: WireError['error']): string {
if (status === 401 || status === 403) return 'AUTH'
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE
if (status === 429) return 'RATE_LIMIT'
if (status === 400) {
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ')
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE
return 'INVALID_REQUEST'
}
@@ -57,13 +80,22 @@ export function httpErrorCode(status: number, error?: WireError['error']): strin
* The first real `LlmAdapter`. One instance serves every model name it was
* registered under (the harness model name IS the wire model name).
*
* Abort: `options.signal` is handed to fetch — both the initial request and
* the body stream reject on abort, which surfaces to the loop as a rejected
* step (the loop already contains step errors).
* One stable signal reaches both initial fetch and body reads. Caller aborts
* map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
*/
export class DeepSeekAdapter extends LlmAdapter {
private readonly streamIdleTimeoutMs: number
constructor(private readonly options: DeepSeekAdapterOptions) {
super()
this.streamIdleTimeoutMs = options.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(this.streamIdleTimeoutMs)
|| this.streamIdleTimeoutMs <= 0
|| this.streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
}
override providerInfo(provider: string): LlmProviderInfo {
@@ -80,8 +112,50 @@ export class DeepSeekAdapter extends LlmAdapter {
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal])
using watchdog = idleWatchdog(upstream, this.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE)
const iterator = this.request(options, watchdog.signal)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
const result = await watchdog.next(iterator)
if (result.done) {
exhausted = true
return
}
yield result.value
}
} catch (error: unknown) {
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
throw new LlmError(
`DeepSeek stream idle timeout after ${this.streamIdleTimeoutMs}ms`,
'TIMEOUT',
{ cause: error },
)
}
if (options.signal?.aborted) {
throw new LlmError('DeepSeek request aborted by caller', 'ABORTED', { cause: error })
}
if (error instanceof LlmError) throw error
throw new LlmError(`DeepSeek API stream from ${this.options.baseURL} failed`, 'TRANSPORT', { cause: error })
} finally {
consumer.abort('DeepSeek stream consumer stopped')
if (!exhausted && iterator.return !== undefined) {
try {
await iterator.return()
} catch (_abortedTransportTeardown) {
// The consumer controller already owns termination; a return-time abort cannot add a second outcome.
}
}
}
}
private async * request(options: GenerateOptions, signal: AbortSignal): AsyncIterable<StreamChunk> {
const body = serializeRequest(options, this.options.defaults ?? {})
// Prepared outside the try so the NETWORK label below covers exactly the
// Prepared outside the try so the TRANSPORT label below covers exactly the
// transport boundary, never a serialization failure.
const payload = JSON.stringify(body)
const headers = {
@@ -102,19 +176,18 @@ export class DeepSeekAdapter extends LlmAdapter {
method: 'POST',
headers,
body: payload,
...options.signal ? { signal: options.signal } : {},
signal,
})
} catch (error: unknown) {
// An aborted request rethrows its original rejection (the signal's abort
// reason) so the loop classifies it as cancellation, not a provider failure.
if (options.signal?.aborted) throw error
// The outer stream distinguishes caller cancellation and watchdog expiry.
if (signal.aborted) throw error
// fetch wraps every transport failure (DNS, refused connection, TLS,
// proxy) in a bare `TypeError: fetch failed` whose actionable detail
// lives on `cause`. Wrapping with the endpoint and chaining the cause
// lets `errorChain` render the full diagnosis at every reporting seam.
throw new LlmError(
`DeepSeek API request to ${this.options.baseURL} failed`,
'NETWORK',
'TRANSPORT',
{ cause: error },
)
}
@@ -130,7 +203,13 @@ export class DeepSeekAdapter extends LlmAdapter {
// Only swallow error-body parsing: the HTTP status still identifies the
// failure, so malformed gateway JSON must not mask it.
}
throw new LlmError(message, httpErrorCode(response.status, providerError))
const delay = providerRetryAfterMs(response.headers.get('retry-after'))
const id = requestId(response.headers)
throw new LlmError(message, httpErrorCode(response.status, providerError), {
status: response.status,
...delay === undefined ? {} : { providerRetryAfterMs: delay },
...id === undefined ? {} : { requestId: id },
})
}
if (!response.body) {
throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE')

View File

@@ -8,7 +8,8 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import type {} from '@deepseek-ai/dsh-llm'
import { DeepSeekAdapter } from './adapter.ts'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
import type { DeepSeekCatalogModel } from './adapter.ts'
export { DeepSeekAdapter } from './adapter.ts'
@@ -41,6 +42,8 @@ export interface Config {
reasoningEffort?: 'high' | 'max'
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
streamIdleTimeoutMs?: number
}
const catalogModel: z<DeepSeekCatalogModel> = z.object({
@@ -55,6 +58,7 @@ export const Config: z<Config> = z.object({
thinking: z.union(['enabled', 'disabled']),
reasoningEffort: z.union(['high', 'max']),
models: z.array(catalogModel).default(DEFAULT_MODELS),
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
})
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
@@ -92,5 +96,6 @@ export function apply(ctx: Context, config: Config): void {
reasoningEffort: config.reasoningEffort,
},
models: resolveModels(config.models),
streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS,
}))
}

View File

@@ -35,7 +35,10 @@ export function mapFinishReason(reason: string): FinishReason {
case 'length': return { kind: 'max-tokens' }
default:
// content_filter, insufficient_system_resource, future additions.
return { kind: 'error', message: `model stopped: ${reason}`, code: reason.toUpperCase() }
return {
kind: 'error',
failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() },
}
}
}

View File

@@ -2,7 +2,15 @@ import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, errorChain, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import LlmService, {
CONTEXT_WINDOW_EXCEEDED_CODE,
errorChain,
LlmError,
ProviderRequestId,
QUOTA_EXCEEDED_CODE,
userAgent,
} from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
@@ -12,7 +20,7 @@ import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
| { kind: 'sse'; events: string[]; delayMs?: number }
| { kind: 'http-error'; status: number; body: string; contentType?: string }
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
| { kind: 'close-early'; events: string[] }
interface MockServer {
@@ -30,6 +38,7 @@ const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
vi.unstubAllEnvs()
vi.useRealTimers()
})
/** Local chat-completions stand-in: replays scripted behaviors per request. */
@@ -48,7 +57,10 @@ async function mockServer(script: Behavior[]): Promise<MockServer> {
return
}
if (behavior.kind === 'http-error') {
response.writeHead(behavior.status, { 'content-type': behavior.contentType ?? 'application/json' })
response.writeHead(behavior.status, {
'content-type': behavior.contentType ?? 'application/json',
...behavior.headers,
})
response.end(behavior.body)
return
}
@@ -202,6 +214,84 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(code).toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
})
it('retains status, Retry-After seconds, and provider request id as structured facts', async () => {
const server = await mockServer([{
kind: 'http-error',
status: 429,
body: JSON.stringify({ error: { message: 'slow down' } }),
headers: { 'retry-after': '2', 'x-request-id': 'req-429' },
}])
const ctx = await harness(server.url)
let thrown: unknown
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
thrown = error
}
expect(thrown).toBeInstanceOf(LlmError)
expect((thrown as LlmError).failure).toEqual({
message: 'slow down',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 2_000,
requestId: ProviderRequestId('req-429'),
})
})
it('parses a future Retry-After HTTP date and the DeepSeek request-id fallback', async () => {
const now = 1_800_000_000_000
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(now)
try {
const server = await mockServer([{
kind: 'http-error',
status: 503,
body: JSON.stringify({ error: { message: 'come back later' } }),
headers: {
'retry-after': new Date(now + 3_000).toUTCString(),
'x-deepseek-request-id': 'deepseek-503',
},
}])
const ctx = await harness(server.url)
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({
failure: {
message: 'come back later',
code: 'SERVER',
status: 503,
providerRetryAfterMs: 3_000,
requestId: ProviderRequestId('deepseek-503'),
},
})
} finally {
dateNow.mockRestore()
}
})
it('omits zero, non-finite, invalid, and past Retry-After values', async () => {
const values = [
'0',
'9'.repeat(400),
'not-a-date',
new Date(0).toUTCString(),
]
for (const value of values) {
const server = await mockServer([{
kind: 'http-error',
status: 429,
body: JSON.stringify({ error: { message: 'retry later' } }),
headers: { 'retry-after': value },
}])
const ctx = await harness(server.url)
let thrown: LlmError | undefined
try {
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
if (error instanceof LlmError) thrown = error
}
expect(thrown?.failure).toEqual({ message: 'retry later', code: 'RATE_LIMIT', status: 429 })
}
})
it('classifies only context-capacity HTTP 400 details as context overflow', () => {
expect(httpErrorCode(400, { message: 'request too large for model context' }))
.toBe(CONTEXT_WINDOW_EXCEEDED_CODE)
@@ -210,6 +300,12 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(httpErrorCode(413, { code: 'context_length_exceeded' })).toBe('HTTP_413')
})
it('distinguishes terminal quota exhaustion from transient HTTP 429 throttling', () => {
expect(httpErrorCode(429, { code: 'insufficient_quota', message: 'account credits exhausted' }))
.toBe(QUOTA_EXCEEDED_CODE)
expect(httpErrorCode(429, { message: 'request rate limit exceeded' })).toBe('RATE_LIMIT')
})
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
@@ -228,7 +324,7 @@ describe('DeepSeekAdapter against a mock server', () => {
expect(httpErrorCode(418)).toBe('HTTP_418')
})
it('wraps a transport failure in NETWORK with the fetch cause chain in the message', async () => {
it('wraps a transport failure in TRANSPORT with the fetch cause chain in the message', async () => {
// Port 1 is reserved/unbound: fetch rejects with `TypeError: fetch failed`
// whose actionable detail (ECONNREFUSED) lives on `cause`.
const ctx = await harness('http://127.0.0.1:1')
@@ -240,14 +336,14 @@ describe('DeepSeekAdapter against a mock server', () => {
}
expect(caught).toBeInstanceOf(LlmError)
const llmError = caught as LlmError
expect(llmError.code).toBe('NETWORK')
expect(llmError.code).toBe('TRANSPORT')
expect(llmError.message).toContain('http://127.0.0.1:1')
expect(llmError.cause).toBeInstanceOf(TypeError)
// The chain renderer reaches the transport diagnosis through the cause.
expect(errorChain(llmError)).toMatch(/ECONNREFUSED|EADDRNOTAVAIL|bad port/)
})
it('keeps an abort rejection unwrapped so the loop classifies it as cancellation', async () => {
it('classifies an aborted request without losing the transport rejection', async () => {
const controller = new AbortController()
controller.abort()
const ctx = await harness('http://127.0.0.1:1')
@@ -257,8 +353,9 @@ describe('DeepSeekAdapter against a mock server', () => {
} catch (error: unknown) {
caught = error
}
expect(caught).not.toBeInstanceOf(LlmError)
expect((caught as Error).name).toBe('AbortError')
expect(caught).toBeInstanceOf(LlmError)
expect(caught).toMatchObject({ code: 'ABORTED' })
expect((caught as LlmError).cause).toMatchObject({ name: 'AbortError' })
})
it('throws EMPTY_RESPONSE when the response has no body', async () => {
@@ -276,14 +373,20 @@ describe('DeepSeekAdapter against a mock server', () => {
}
})
it('rejects with STREAM_CLOSED when the server drops mid-stream', async () => {
it('classifies an abrupt body close as TRANSPORT and retains its cause', async () => {
const server = await mockServer([{
kind: 'close-early',
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
let caught: unknown
try {
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
} catch (error: unknown) {
caught = error
}
expect(caught).toMatchObject({ code: 'TRANSPORT' })
expect(errorChain(caught)).toMatch(/terminated|socket|without \[DONE\]/)
})
it('aborts mid-stream via the request signal', async () => {
@@ -305,7 +408,76 @@ describe('DeepSeekAdapter against a mock server', () => {
})()
setTimeout(() => { controller.abort() }, 30)
await expect(pending).rejects.toThrow()
await expect(pending).rejects.toMatchObject({ code: 'ABORTED' })
})
it('maps connection failures to TRANSPORT without losing the cause', async () => {
const cause = new TypeError('connection refused')
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
try {
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
}
await expect(drain()).rejects.toMatchObject({ code: 'TRANSPORT', cause })
} finally {
fetchSpy.mockRestore()
}
})
it('renders a non-Error transport rejection without losing its cause', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => {
const failed = Promise.withResolvers<Response>()
failed.reject('offline')
return failed.promise
})
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
try {
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
}
await expect(drain()).rejects.toMatchObject({
message: 'DeepSeek API request to https://example.invalid failed',
code: 'TRANSPORT',
cause: 'offline',
})
} finally {
fetchSpy.mockRestore()
}
})
it('aborts the underlying body when the stream stays idle past its watchdog', async () => {
vi.useFakeTimers()
let stopped = false
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) => {
const signal = init?.signal
const body = new ReadableStream<Uint8Array>({
start(controller) {
signal?.addEventListener('abort', () => {
stopped = true
controller.error(signal.reason)
}, { once: true })
},
})
return Promise.resolve(new Response(body, { status: 200 }))
})
const adapter = new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'https://example.invalid',
streamIdleTimeoutMs: 100,
})
try {
const drain = (async () => {
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
})()
const rejected = expect(drain).rejects.toMatchObject({ code: 'TIMEOUT' })
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(100)
await rejected
expect(stopped).toBe(true)
} finally {
fetchSpy.mockRestore()
}
})
})
@@ -452,4 +624,30 @@ describe('plugin registration and config', () => {
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
})
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => new DeepSeekAdapter({
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
})).toThrow(/streamIdleTimeoutMs.*no greater/)
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: 0,
})).rejects.toThrow(/streamIdleTimeoutMs/)
await expect(ctx.plugin(LlmDeepSeek, {
apiKey: 'k',
baseURL: 'http://127.0.0.1:1',
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
})).rejects.toThrow(/streamIdleTimeoutMs/)
})
})

View File

@@ -232,8 +232,7 @@ describe('mapFinishReason', () => {
(wire) => {
expect(mapFinishReason(wire)).toEqual({
kind: 'error',
message: `model stopped: ${wire}`,
code: wire.toUpperCase(),
failure: { message: `model stopped: ${wire}`, code: wire.toUpperCase() },
})
},
)

View File

@@ -19,6 +19,9 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -19,7 +19,7 @@ Configure credentials and deployment-specific transport settings per provider. O
reasoning: high
- provider: anthropic
apiKey: !!js process.env.ANTHROPIC_API_KEY
maxRetries: 2
streamIdleTimeoutMs: 300000
- provider: openrouter
apiKey: !!js process.env.OPENROUTER_API_KEY
headers:
@@ -30,7 +30,9 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name.
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
## Provider/model routing and replay
@@ -43,7 +45,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state
## Vocabulary differences
- pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output.
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted'}` chunks. Provider-specific error text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`.
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`.
- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map.
- `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers.
@@ -57,7 +59,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
## Testing
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`.
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. Real-API coverage remains key-gated under `pnpm run test:e2e`.
## Model Experience
@@ -95,3 +97,4 @@ Recorded response content appends to the next request and does not invalidate it
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
- **Retry policy is not an adapter option** — SDK retries are disabled so durable agent steps and `llm/retry` events own every visible attempt; direct `ctx.llm.stream()` calls remain single-attempt.

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
@@ -32,6 +33,7 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -16,7 +16,9 @@ import type {
} from '@earendil-works/pi-ai'
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { PiAiProviderProfile } from './config.ts'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
@@ -48,8 +50,8 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions {
...profile.transport === undefined ? {} : { transport: profile.transport },
...profile.timeoutMs === undefined ? {} : { timeoutMs: profile.timeoutMs },
...profile.websocketConnectTimeoutMs === undefined ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
...profile.maxRetries === undefined ? {} : { maxRetries: profile.maxRetries },
...profile.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: profile.maxRetryDelayMs },
// The agent recovery layer owns visible attempts; one adapter call is one SDK attempt.
maxRetries: 0,
}
}
@@ -68,11 +70,11 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
* request, so models need not be registered during the Cordis lifecycle.
*/
export class PiAiAdapter extends LlmAdapter {
private readonly profiles: ReadonlyMap<string, PiAiProviderProfile>
private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
constructor(options: PiAiAdapterOptions) {
super()
this.profiles = new Map(options.profiles.map(profile => [profile.provider, profile]))
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
@@ -97,12 +99,12 @@ export class PiAiAdapter extends LlmAdapter {
}
const model = resolveModel(profile, options.model)
// Pi-ai has no iterator-return cancellation hook. Chain an internal signal
// and abort it when this generator exits so early consumers stop the HTTP stream.
const controller = new AbortController()
const onCallerAbort = (): void => { controller.abort(options.signal?.reason) }
if (options.signal?.aborted) controller.abort(options.signal.reason)
else options.signal?.addEventListener('abort', onCallerAbort, { once: true })
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
: AbortSignal.any([options.signal, consumer.signal])
const streamIdleTimeoutMs = profile.streamIdleTimeoutMs
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
try {
const events = streamSimple(model, toPiContext(options), {
@@ -110,15 +112,44 @@ export class PiAiAdapter extends LlmAdapter {
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
signal: controller.signal,
signal: watchdog.signal,
// Profile headers are deployment-owned; attribution names are
// Harness-owned and therefore win collisions.
headers: requestHeaders(profile.headers),
})
yield* toStreamChunks(events, model.contextWindow)
const iterator = toStreamChunks(events, model.contextWindow)[Symbol.asyncIterator]()
let exhausted = false
try {
while (true) {
const result = await watchdog.next(iterator)
const timeout = timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')
if (timeout !== undefined) throw timeout
if (result.done) {
exhausted = true
return
}
yield result.value
}
} finally {
if (!exhausted) {
consumer.abort('pi-ai stream consumer stopped')
try {
await iterator.return(undefined)
} catch (_abortedSdkTeardown) {
// The stable signal already owns SDK termination; return-time abort cannot add an outcome.
}
}
}
} catch (error: unknown) {
if (timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT') !== undefined) {
throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error })
}
if (options.signal?.aborted) {
throw new LlmError('pi-ai request aborted by caller', 'ABORTED', { cause: error })
}
throw error
} finally {
options.signal?.removeEventListener('abort', onCallerAbort)
controller.abort('consumer stopped streaming')
consumer.abort('pi-ai stream consumer stopped')
}
}
}

View File

@@ -7,6 +7,10 @@
import { getProviders } from '@earendil-works/pi-ai'
import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Configuration for one pi-ai provider route. */
export interface PiAiProviderProfile {
@@ -30,10 +34,14 @@ export interface PiAiProviderProfile {
timeoutMs?: number
/** WebSocket connection timeout in milliseconds. */
websocketConnectTimeoutMs?: number
/** Provider SDK retry count. */
maxRetries?: number
/** Maximum provider-requested retry delay in milliseconds. */
maxRetryDelayMs?: number
/** Maximum provider idle time while one stream read is outstanding. */
streamIdleTimeoutMs?: number
}
/** Validated profile with every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends PiAiProviderProfile {
/** Positive finite provider-idle interval after defaulting. */
streamIdleTimeoutMs: number
}
/** Plugin configuration: the non-empty provider profiles this instance owns. */
@@ -60,8 +68,7 @@ const profile = z.object({
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
timeoutMs: z.natural(),
websocketConnectTimeoutMs: z.natural(),
maxRetries: z.natural(),
maxRetryDelayMs: z.natural(),
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
})
/** Runtime schema for {@link Config}. */
@@ -75,11 +82,18 @@ export const Config: z<Config> = z.object({
* @param profiles - configured provider profiles.
* @returns validated profiles in configuration order.
*/
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiProviderProfile[] {
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
const supported = new Set<string>(getProviders())
const seen = new Set<string>()
return profiles.map((source) => {
const legacy = source as PiAiProviderProfile & {
maxRetries?: unknown
maxRetryDelayMs?: unknown
}
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
}
if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`)
if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
@@ -89,9 +103,18 @@ export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): PiAiP
if (source.baseURL !== undefined && source.baseURL.length === 0) {
throw new Error(`llm-pi-ai: provider "${source.provider}" has an empty baseURL`)
}
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
seen.add(source.provider)
return {
...source,
streamIdleTimeoutMs,
...source.headers === undefined ? {} : { headers: { ...source.headers } },
...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } },
}

View File

@@ -8,7 +8,7 @@
* @module dsh-llm-pi-ai/stream
*/
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmError } from '@deepseek-ai/dsh-llm'
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import { isContextOverflow } from '@earendil-works/pi-ai'
import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai'
@@ -30,9 +30,15 @@ export function mapUsage(usage: PiUsage): TokenUsage {
function classifyPiAiError(message: string): string {
if (/\b(?:401|403)\b/.test(message)) return 'AUTH'
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE
if (/\b429\b|rate.?limit/i.test(message)) return 'RATE_LIMIT'
if (/\b400\b|invalid.?request/i.test(message)) return 'INVALID_REQUEST'
if (/\b5\d\d\b/.test(message)) return 'SERVER'
if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return 'TIMEOUT'
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)) {
return 'TRANSPORT'
}
return 'PI_AI_ERROR'
}
@@ -52,8 +58,10 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
if (piAiOverflow || harnessOverflow) {
return {
kind: 'error',
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
code: CONTEXT_WINDOW_EXCEEDED_CODE,
failure: {
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
code: CONTEXT_WINDOW_EXCEEDED_CODE,
},
}
}
@@ -61,10 +69,13 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
case 'stop': return { kind: 'stop' }
case 'length': return { kind: 'max-tokens' }
case 'toolUse': return { kind: 'tool-calls' }
case 'aborted': return { kind: 'aborted' }
case 'aborted': return {
kind: 'aborted',
failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' },
}
case 'error': {
const text = message.errorMessage ?? 'pi-ai stream error'
return { kind: 'error', message: text, code: classifyPiAiError(text) }
return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } }
}
}
}

View File

@@ -6,6 +6,7 @@ import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { getModels } from '@earendil-works/pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts'
@@ -14,6 +15,8 @@ interface MockServer {
paths: string[]
requests: unknown[]
headers: IncomingMessage['headers'][]
readonly closedResponses: number
responseClosed: Promise<void>
}
const servers: Server[] = []
@@ -23,11 +26,23 @@ afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
async function mockServer(script: { status?: number; events?: string[]; body?: string; delayMs?: number }[]): Promise<MockServer> {
async function mockServer(script: {
status?: number
events?: string[]
body?: string
delayMs?: number
headers?: Record<string, string>
}[]): Promise<MockServer> {
const paths: string[] = []
const requests: unknown[] = []
const headers: IncomingMessage['headers'][] = []
let closedResponses = 0
const responseClosed = Promise.withResolvers<undefined>()
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
response.on('close', () => {
closedResponses += 1
responseClosed.resolve(undefined)
})
let body = ''
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
request.on('end', () => {
@@ -36,7 +51,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s
headers.push(request.headers)
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
if (behavior.status !== undefined && behavior.status !== 200) {
response.writeHead(behavior.status, { 'content-type': 'application/json' })
response.writeHead(behavior.status, { 'content-type': 'application/json', ...behavior.headers })
response.end(behavior.body ?? '{}')
return
}
@@ -56,7 +71,14 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('no port')
return { url: `http://127.0.0.1:${address.port}`, paths, requests, headers }
return {
url: `http://127.0.0.1:${address.port}`,
paths,
requests,
headers,
responseClosed: responseClosed.promise,
get closedResponses() { return closedResponses },
}
}
const textEvents = [
@@ -107,8 +129,7 @@ describe('PiAiAdapter provider routing', () => {
transport: 'sse',
timeoutMs: 5000,
websocketConnectTimeoutMs: 3000,
maxRetries: 0,
maxRetryDelayMs: 10,
streamIdleTimeoutMs: 10_000,
thinkingBudgets: { high: 2048 },
})
await assemble(ctx, {
@@ -161,13 +182,35 @@ describe('PiAiAdapter provider routing', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1`, maxRetries: 0 }],
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish.kind).toBe('error')
expect(server.paths).toEqual(['/v1/responses'])
})
it('forces one wire request for an SDK-retryable provider failure', async () => {
const server = await mockServer([
{
status: 429,
headers: { 'retry-after-ms': '1' },
body: JSON.stringify({ error: { message: 'retryable provider failure' } }),
},
{ status: 500, body: JSON.stringify({ error: { message: 'hidden SDK retry' } }) },
{ status: 500, body: JSON.stringify({ error: { message: 'second hidden SDK retry' } }) },
])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmPiAi, {
providers: [{ provider: 'openai', apiKey: 'test-key', baseURL: `${server.url}/v1` }],
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error' })
expect(server.paths).toEqual(['/v1/responses'])
})
it('uses OpenAI Responses against an Azure project v1 path with its API key header', async () => {
const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }])
const ctx = new Context()
@@ -178,7 +221,6 @@ describe('PiAiAdapter provider routing', () => {
apiKey: 'test-key',
baseURL: `${server.url}/api/projects/openai/openai/v1`,
headers: { 'api-key': 'test-key', Authorization: '' },
maxRetries: 0,
}],
})
const result = await assemble(ctx, { provider: 'openai', model: 'gpt-5.5', messages: [] })
@@ -195,9 +237,10 @@ describe('PiAiAdapter provider routing', () => {
[500, 'SERVER'],
] as const)('maps HTTP %s failures to %s', async (status, code) => {
const server = await mockServer([{ status, body: JSON.stringify({ error: { message: `provider ${status}` } }) }])
const ctx = await harness(server.url, { maxRetries: 0 })
const ctx = await harness(server.url)
const result = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', code })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code } })
expect(server.paths).toEqual(['/chat/completions'])
})
it('uses the resolved catalog context window for usage-based overflow detection', async () => {
@@ -218,10 +261,29 @@ describe('PiAiAdapter provider routing', () => {
expect(result.finish).toEqual({
kind: 'error',
message: `pi-ai detected context overflow for model "${model.id}"`,
code: CONTEXT_WINDOW_EXCEEDED_CODE,
failure: {
message: `pi-ai detected context overflow for model "${model.id}"`,
code: CONTEXT_WINDOW_EXCEEDED_CODE,
},
})
})
it('stops the SDK request when the adapter idle watchdog expires', async () => {
const server = await mockServer([{ events: textEvents, delayMs: 200 }])
const ctx = await harness(server.url, { streamIdleTimeoutMs: 20 })
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
.rejects.toMatchObject({ code: 'TIMEOUT' })
await Promise.race([
server.responseClosed,
new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('SDK request did not close after idle timeout')) }, 100)
}),
])
expect(server.paths).toEqual(['/chat/completions'])
expect(server.closedResponses).toBe(1)
})
})
describe('provider profile lifecycle', () => {
@@ -280,16 +342,31 @@ describe('provider profile lifecycle', () => {
expect(() => resolveProfiles([{ provider: 'openai', baseURL: '' }])).toThrow(/empty baseURL/)
})
it('rejects negative or fractional stream tunables at schema validation', () => {
it.each(['maxRetries', 'maxRetryDelayMs'] as const)(
'rejects removed profile field %s instead of silently restoring hidden SDK retries',
async (field) => {
const legacy = { provider: 'openai', [field]: 2 }
expect(() => resolveProfiles([legacy as never])).toThrow(/removed.*agent recovery/i)
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [legacy as never] }))
.rejects.toThrow(/removed.*agent recovery/i)
},
)
it('rejects invalid stream tunables at plugin load', async () => {
const invalid = [
{ timeoutMs: -1 },
{ websocketConnectTimeoutMs: -1 },
{ maxRetries: -1 },
{ maxRetries: 0.5 },
{ maxRetryDelayMs: -1 },
{ streamIdleTimeoutMs: 0 },
{ streamIdleTimeoutMs: Number.NaN },
{ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 },
]
for (const entry of invalid) {
expect(() => new LlmPiAi.Config({ providers: [{ provider: 'openai', ...entry }] })).toThrow()
const ctx = new Context()
await ctx.plugin(LlmService)
await expect(ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai', ...entry }] }))
.rejects.toThrow()
}
})
@@ -301,11 +378,59 @@ describe('provider profile lifecycle', () => {
})()).rejects.toMatchObject({ code: 'NO_ADAPTER' })
expect(new LlmError('x', 'X')).toBeInstanceOf(Error)
})
it('validates direct-constructor profiles at the embedding boundary', () => {
expect(() => new PiAiAdapter({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: 0 }],
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
expect(() => new PiAiAdapter({
profiles: [{ provider: 'openai', streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }],
})).toThrow(/streamIdleTimeoutMs.*no greater/)
})
})
describe('abort wiring', () => {
it('preserves an unknown pre-dispatch adapter Error exactly', async () => {
const original = new Error('SDK context conversion exploded')
const message = Object.defineProperty({}, 'role', {
get() { throw original },
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [message as never],
})) { /* drain */ }
}
await expect(drain()).rejects.toBe(original)
})
it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => {
const controller = new AbortController()
const original = new Error('conversion lost its caller')
const message = Object.defineProperty({}, 'role', {
get() {
controller.abort('caller cancelled during conversion')
throw original
},
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [message as never],
signal: controller.signal,
})) { /* drain */ }
}
await expect(drain()).rejects.toMatchObject({ code: 'ABORTED', cause: original })
})
it('resolves catalog endpoints without an override before honoring pre-abort', async () => {
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key', maxRetries: 0 }] })
const adapter = new PiAiAdapter({ profiles: [{ provider: 'deepseek', apiKey: 'test-key' }] })
const controller = new AbortController()
controller.abort('already stopped')
const chunks = []

View File

@@ -485,20 +485,32 @@ describe('toStreamChunks', () => {
)))
expect(chunks).toEqual([
{ type: 'usage', usage: { inputTokens: 1, outputTokens: 0 } },
{ type: 'finish', reason: { kind: 'error', message: 'boom', code: 'PI_AI_ERROR' } },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'PI_AI_ERROR' } } },
])
})
it('maps aborted error events to aborted finish', async () => {
const error = assistant({ stopReason: 'aborted' })
const chunks = await collect(toStreamChunks(feed({ type: 'error', reason: 'aborted', error })))
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'aborted' } })
expect(chunks.at(-1)).toEqual({
type: 'finish',
reason: { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } },
})
})
it('rejects a stream that ends without done or error', async () => {
await expect(collect(toStreamChunks(feed({ type: 'start', partial: assistant() }))))
.rejects.toThrow(/without done\/error/)
})
it('preserves an unknown SDK iterator Error exactly', async () => {
const original = Object.assign(new Error('SDK transport exploded'), { code: 'ECONNRESET' })
async function* failedSdkStream(): AsyncGenerator<AssistantMessageEvent> {
throw original
}
await expect(collect(toStreamChunks(failedSdkStream()))).rejects.toBe(original)
})
})
describe('mapStopReason / mapUsage', () => {
@@ -506,46 +518,65 @@ describe('mapStopReason / mapUsage', () => {
['stop', { kind: 'stop' }],
['length', { kind: 'max-tokens' }],
['toolUse', { kind: 'tool-calls' }],
['aborted', { kind: 'aborted' }],
['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }],
] as const)('maps %s', (stopReason, expected) => {
expect(mapStopReason(assistant({ stopReason }))).toEqual(expected)
})
it('defaults the error message when pi-ai omits it', () => {
expect(mapStopReason(assistant({ stopReason: 'error' })))
.toEqual({ kind: 'error', message: 'pi-ai stream error', code: 'PI_AI_ERROR' })
.toEqual({ kind: 'error', failure: { message: 'pi-ai stream error', code: 'PI_AI_ERROR' } })
})
it('maps routable HTTP-ish error messages to stable codes', () => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 401: bad key' })))
.toMatchObject({ kind: 'error', code: 'AUTH' })
.toMatchObject({ kind: 'error', failure: { code: 'AUTH' } })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: rate limit' })))
.toMatchObject({ kind: 'error', code: 'RATE_LIMIT' })
.toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 429: insufficient_quota' })))
.toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } })
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.',
}))).toMatchObject({ kind: 'error', failure: { code: 'QUOTA' } })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'HTTP 500: backend down' })))
.toMatchObject({ kind: 'error', code: 'SERVER' })
.toMatchObject({ kind: 'error', failure: { code: 'SERVER' } })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'provider timed out' })))
.toMatchObject({ kind: 'error', failure: { code: 'TIMEOUT' } })
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage: 'ECONNRESET socket closed' })))
.toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'HTTP 400: input exceeds the model context window limit',
}))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE })
}))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'HTTP 400: request too large for model context',
}))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE })
}))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'HTTP 400: invalid input: temperature exceeds maximum allowed value',
}))).toMatchObject({ kind: 'error', code: 'INVALID_REQUEST' })
}))).toMatchObject({ kind: 'error', failure: { code: 'INVALID_REQUEST' } })
})
it.each([
'other side closed',
'HTTP2 request did not get a response',
'WebSocket closed unexpectedly',
])('maps pi-ai transport wording %j', (errorMessage) => {
expect(mapStopReason(assistant({ stopReason: 'error', errorMessage })))
.toMatchObject({ kind: 'error', failure: { code: 'TRANSPORT' } })
})
it('uses pi-ai provider-specific overflow classification without losing rate-limit exclusions', () => {
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'prompt is too long: 213462 tokens > 200000 maximum',
}))).toMatchObject({ kind: 'error', code: CONTEXT_WINDOW_EXCEEDED_CODE })
}))).toMatchObject({ kind: 'error', failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE } })
expect(mapStopReason(assistant({
stopReason: 'error',
errorMessage: 'ThrottlingException: Too many tokens, rate limit reached',
}))).toMatchObject({ kind: 'error', code: 'RATE_LIMIT' })
}))).toMatchObject({ kind: 'error', failure: { code: 'RATE_LIMIT' } })
})
it('uses the resolved context window for silent and length-stop overflows', () => {
@@ -553,15 +584,17 @@ describe('mapStopReason / mapUsage', () => {
expect(mapStopReason(silent)).toEqual({ kind: 'stop' })
expect(mapStopReason(silent, 100)).toEqual({
kind: 'error',
message: 'pi-ai detected context overflow for model "deepseek-v4-flash"',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
failure: {
message: 'pi-ai detected context overflow for model "deepseek-v4-flash"',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
},
})
const truncated = assistant({ stopReason: 'length', usage: usage(80, 0, 19) })
expect(mapStopReason(truncated)).toEqual({ kind: 'max-tokens' })
expect(mapStopReason(truncated, 100)).toMatchObject({
kind: 'error',
code: CONTEXT_WINDOW_EXCEEDED_CODE,
failure: { code: CONTEXT_WINDOW_EXCEEDED_CODE },
})
})

View File

@@ -70,7 +70,7 @@ function textOf(result: AssembledResult): string {
function expectFinish(result: AssembledResult, expected: 'stop' | 'tool-calls'): void {
if (result.finish.kind === 'error') {
throw new Error(`provider request failed (${result.finish.code ?? 'unknown'}): ${result.finish.message}`)
throw new Error(`provider request failed (${result.finish.failure.code}): ${result.finish.failure.message}`)
}
expect(result.finish.kind).toBe(expected)
}

View File

@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const streamSimple = vi.hoisted(() => vi.fn())
vi.mock('@earendil-works/pi-ai', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai')>()
return { ...actual, streamSimple }
})
import { PiAiAdapter } from '../src/adapter.ts'
afterEach(() => { streamSimple.mockReset() })
describe('pi-ai SDK retry boundary', () => {
it('pins one SDK attempt even when the installed provider currently defaults to zero retries', async () => {
const failure = new Error('mock SDK boundary')
streamSimple.mockReturnValue({
async * [Symbol.asyncIterator](): AsyncGenerator<never> {
throw failure
},
})
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai', apiKey: 'test-key' }] })
const drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'openai',
model: 'gpt-4.1',
messages: [],
})) { /* drain */ }
}
await expect(drain()).rejects.toBe(failure)
expect(streamSimple).toHaveBeenCalledOnce()
expect(streamSimple.mock.calls[0]?.[2]).toMatchObject({ maxRetries: 0 })
})
})

View File

@@ -19,6 +19,9 @@
},
{
"path": "../../llm/llm"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -0,0 +1,39 @@
# `@deepseek-ai/dsh-llm-retry`
Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step.
The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead.
Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward.
```yaml
- name: '@deepseek-ai/dsh-llm-retry'
config:
maxTransientRetries: 2
initialDelayMs: 500
maxDelayMs: 10000
jitterRatio: 0.1
retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
```
## Model Experience
### Transient request recovery
#### What the model sees
No retry event, delay, or failure prose is model-visible. After a retry, the next numbered step reconstructs the same explicit provider/model request from durable session history; failed chunks never enter derived messages.
#### Token effect
Each retry is a new provider request and may repeat input-token billing. The finite budget caps attempts; `llm/retry` itself contributes no tokens.
#### KV Cache effect
The reconstructed request preserves the prior prefix and is eligible for provider cache reuse under that provider's rules. The non-surface status event does not change cache identity.
## Known Limitations and Deferred Work
- **Agent steps are the only retry boundary** — direct `ctx.llm.stream()` consumers remain single-attempt because a raw stream cannot separate already-emitted chunks durably.
- **Finite plugin budgets add** — this policy counts only configured transient codes; context-overflow compaction counts only its own code. A future policy with overlapping codes must document and test registration-order behavior.
- **`llm/retry` records scheduling, not completion** — later step and turn events establish success, exhaustion, or cancellation.

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-llm-retry",
"description": "Bounded transient LLM request retry policy for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,213 @@
/**
* Bounded transient model-request retry policy on the agent loop's closed-step
* recovery seam. Each scheduled retry is durable before its cancellable wait.
*
* @module @deepseek-ai/dsh-llm-retry
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import type { LlmFailure } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-session'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Durable, non-surface record of one transient retry scheduled after a closed failed step. */
'llm/retry': {
turn: number
step: number
retry: number
maxRetries: number
delayMs: number
failure: LlmFailure
}
}
}
export const name = 'llm-retry'
export const inject = ['agents']
const DEFAULT_MAX_TRANSIENT_RETRIES = 2
const DEFAULT_INITIAL_DELAY_MS = 500
const DEFAULT_MAX_DELAY_MS = 10_000
const DEFAULT_JITTER_RATIO = 0.1
const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
/** Deployment-owned limits and classification for transient request recovery. */
export interface Config {
/** Maximum transient retries after the first request (default 2). */
maxTransientRetries?: number
/** Initial local exponential-backoff delay in milliseconds (default 500). */
initialDelayMs?: number
/** Maximum accepted or locally scheduled delay in milliseconds (default 10000). */
maxDelayMs?: number
/** Symmetric random multiplier range around one (default 0.1). */
jitterRatio?: number
/** Stable failure codes eligible for this policy. */
retryableCodes?: string[]
}
/** Runtime schema for {@link Config}. */
export const Config: z<Config> = z.object({
maxTransientRetries: z.number().step(1).min(0).default(DEFAULT_MAX_TRANSIENT_RETRIES),
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
})
interface ResolvedConfig {
readonly maxTransientRetries: number
readonly initialDelayMs: number
readonly maxDelayMs: number
readonly jitterRatio: number
readonly retryableCodes: ReadonlySet<string>
}
function resolveConfig(config: Config): ResolvedConfig {
const maxTransientRetries = config.maxTransientRetries ?? DEFAULT_MAX_TRANSIENT_RETRIES
const initialDelayMs = config.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS
const maxDelayMs = config.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
const jitterRatio = config.jitterRatio ?? DEFAULT_JITTER_RATIO
const codes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES]
if (!Number.isInteger(maxTransientRetries) || maxTransientRetries < 0) {
throw new Error('llm-retry: maxTransientRetries must be a non-negative integer')
}
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
throw new Error(`llm-retry: initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
throw new Error(`llm-retry: maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
if (initialDelayMs > maxDelayMs) {
throw new Error('llm-retry: initialDelayMs must be less than or equal to maxDelayMs')
}
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) {
throw new Error('llm-retry: jitterRatio must be between 0 and 1')
}
if (codes.length === 0) {
throw new Error('llm-retry: retryableCodes must not be empty')
}
if (codes.some(code => code.length === 0)) {
throw new Error('llm-retry: retryableCodes must contain only non-empty strings')
}
if (new Set(codes).size !== codes.length) {
throw new Error('llm-retry: retryableCodes must not contain duplicates')
}
return Object.freeze({
maxTransientRetries,
initialDelayMs,
maxDelayMs,
jitterRatio,
retryableCodes: new Set(codes),
})
}
/** Non-serializable seams used to make timing policy deterministic in tests. */
export interface RetryInternals {
/** Random sample in the inclusive zero-to-one range used for jitter. */
random?: () => number
}
function localDelay(config: ResolvedConfig, retry: number, random: () => number): number {
const exponent = Math.min(retry - 1, 1024)
const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs)
const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random()
return Math.min(exponential * jitter, config.maxDelayMs)
}
function cancellableDelay(delayMs: number, signal: AbortSignal): Promise<boolean> {
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
}, delayMs)
function onAbort(): void {
clearTimeout(timer)
resolve(false)
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
/**
* Install bounded transient request recovery.
* @param ctx - plugin context that owns the listener and active waits.
* @param config - retry budget, delay bounds, jitter, and eligible codes.
* @param internals - non-serializable deterministic seams for tests.
*/
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
const resolved = resolveConfig(config)
const random = internals.random ?? Math.random
const lifetime = new AbortController()
const active = new Set<Promise<RequestErrorDecision>>()
async function backoff(
agent: Agent,
turn: number,
step: number,
failure: LlmFailure,
retry: number,
delayMs: number,
signal: AbortSignal,
): Promise<RequestErrorDecision> {
const fusedSignal = AbortSignal.any([signal, lifetime.signal])
if (fusedSignal.aborted) return { action: 'fail' }
agent.session.append('llm/retry', {
turn,
step,
retry,
maxRetries: resolved.maxTransientRetries,
delayMs,
failure,
})
if (!await cancellableDelay(delayMs, fusedSignal)) return { action: 'fail' }
return { action: 'retry' }
}
const disposeListener = ctx.on('agent/request-error', (
agent: Agent,
turn: number,
step: number,
_error: RequestError,
failure: LlmFailure,
priorFailures: readonly LlmFailure[],
signal: AbortSignal,
next: () => Promise<RequestErrorDecision>,
) => {
// A waterfall may have captured this callback before its registration was
// removed. Lifetime cancellation must prevent that stale callback from
// entering a downstream policy after disposal.
if (lifetime.signal.aborted) return Promise.resolve<RequestErrorDecision>({ action: 'fail' })
if (!resolved.retryableCodes.has(failure.code)) return next()
const priorTransientFailures = priorFailures.filter(item => resolved.retryableCodes.has(item.code)).length
if (priorTransientFailures >= resolved.maxTransientRetries) return next()
const retry = priorTransientFailures + 1
let delayMs: number
if (failure.providerRetryAfterMs !== undefined
&& Number.isFinite(failure.providerRetryAfterMs)
&& failure.providerRetryAfterMs > 0) {
if (failure.providerRetryAfterMs > resolved.maxDelayMs) return next()
delayMs = failure.providerRetryAfterMs
} else {
delayMs = localDelay(resolved, retry, random)
}
const tracked = backoff(agent, turn, step, failure, retry, delayMs, signal)
.finally(() => active.delete(tracked))
active.add(tracked)
return tracked
})
ctx.effect(() => async () => {
disposeListener()
lifetime.abort(new Error('llm-retry plugin disposed'))
await Promise.allSettled([...active])
}, 'llm-retry: abort and drain backoffs')
}

View File

@@ -0,0 +1,124 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as retry from '../src/index.ts'
let root: string | undefined
let context: Context | undefined
class TransientOnceAdapter extends LlmAdapter {
requests = 0
async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests += 1
if (this.requests === 1) throw new LlmError('temporary outage', 'SERVER')
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'recovered' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'recovered' } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
async function loadYaml(lines: readonly string[]): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [...lines, ''].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRegistry],
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-llm-retry', retry],
['@deepseek-ai/dsh-agent-loop', AgentLoop],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
describe('real Loader composition', () => {
it('loads the flat policy and records recovery through the shipping loop', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-llm-retry'",
' config:',
' maxTransientRetries: 1',
' initialDelayMs: 1',
' maxDelayMs: 1',
' jitterRatio: 0',
' retryableCodes: [RATE_LIMIT, SERVER]',
"- name: '@deepseek-ai/dsh-agent-loop'",
])
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(loaded.agents).toBeInstanceOf(AgentRegistry)
const adapter = new TransientOnceAdapter()
loaded.llm.registerAdapter(['mock'], adapter)
const agent = loaded.agentLoop.create(SessionId('loader-retry'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(loaded, agent)
agent.send([{ type: 'text', text: 'recover' }])
await idle
expect(adapter.requests).toBe(2)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1)
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'recovered' }],
})
})
})

View File

@@ -0,0 +1,57 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
import type {} from '../src/index.ts'
const dirs: string[] = []
afterEach(async () => {
for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true })
})
async function backend(kind: 'jsonl' | 'sqlite'): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
if (kind === 'jsonl') {
const root = await mkdtemp(join(tmpdir(), 'dsh-llm-retry-jsonl-'))
dirs.push(root)
await ctx.plugin(SessionPersistenceJsonl, { root })
} else {
await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
}
return ctx
}
describe.each(['jsonl', 'sqlite'] as const)('%s retry-event persistence', (kind) => {
it('round-trips the event losslessly without adding a model message', async () => {
const ctx = await backend(kind)
try {
const session = ctx.sessions.create(SessionId(`retry-${kind}`))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('step/end', { turn: 1, step: 1 })
const event = session.append('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 750,
failure: { message: 'provider busy', code: 'RATE_LIMIT', status: 429 },
})
session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled in backoff' } })
expect(session.deriveMessages()).toEqual([])
await ctx.sessions.flush(session)
const loaded = await ctx.sessionPersistence.load(session.id)
expect(loaded.events.find(item => item.type === 'llm/retry')).toEqual(event)
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -0,0 +1,453 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Fiber } from 'cordis'
import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import * as retry from '../src/index.ts'
type ScriptEntry = Error | Iterable<StreamChunk> | AsyncIterable<StreamChunk>
class ScriptedAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly entries: ScriptEntry[]) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.entries.shift()
if (entry === undefined) throw new Error('retry test script exhausted')
if (entry instanceof Error) throw entry
yield* entry
}
}
async function* partialToolFailure(error: Error): AsyncGenerator<StreamChunk> {
const id = CallId('discarded-call')
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'discarded partial output' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'discarded partial output' } }
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 1, id, name: 'danger', argumentsDelta: '{}' }
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id, name: 'danger', arguments: '{}' } }
throw error
}
function textResponse(text: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
async function harness(
adapter: LlmAdapter,
config: retry.Config = {},
beforeRetry?: (ctx: Context) => void,
internals: retry.RetryInternals = {},
): Promise<{ ctx: Context; retryFiber: Fiber }> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
beforeRetry?.(ctx)
const resolvedConfig = Object.assign({
maxTransientRetries: 2,
initialDelayMs: 500,
maxDelayMs: 10_000,
jitterRatio: 0,
}, config)
const retryFiber = await ctx.plugin(Object.assign((inner: Context) => {
retry.apply(inner, resolvedConfig, internals)
}, { inject: retry.inject }))
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return { ctx, retryFiber }
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function waitForRetry(ctx: Context, agent: Agent, retryNumber: number): Promise<Extract<SessionEvent, { type: 'llm/retry' }>> {
return new Promise((resolve) => {
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'llm/retry' && event.data.retry === retryNumber) {
dispose()
resolve(event)
}
})
})
}
let context: Context | undefined
afterEach(async () => {
vi.useRealTimers()
await context?.fiber.dispose()
context = undefined
})
describe('bounded transient retry policy', () => {
it('records the scheduled delay before opening a fresh request attempt', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('busy', 'RATE_LIMIT', { status: 429 }),
textResponse('done'),
])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-success'), {
provider: 'mock',
model: 'mock',
})
const scheduled = new Promise<Extract<(typeof agent.session.events)[number], { type: 'llm/retry' }>>((resolve) => {
const dispose = context?.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'llm/retry') {
dispose?.()
resolve(event)
}
})
})
agent.send([{ type: 'text', text: 'go' }])
const event = await scheduled
expect(event.data).toEqual({
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'busy', code: 'RATE_LIMIT', status: 429 },
})
expect(adapter.requests).toHaveLength(1)
await vi.advanceTimersByTimeAsync(499)
expect(adapter.requests).toHaveLength(1)
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(1)
await idle
expect(adapter.requests).toHaveLength(2)
expect(agent.session.events.filter(item => item.type === 'step/start').map(item => item.data.step))
.toEqual([1, 2])
expect(agent.session.deriveMessages().at(-1)).toEqual({
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
provenance: { provider: 'mock', model: 'mock' },
})
})
it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
partialToolFailure(new LlmError('stream interrupted', 'TRANSPORT')),
textResponse('recovered'),
])
;({ ctx: context } = await harness(adapter))
let toolExecutions = 0
context.tools.register(defineTool({
name: 'danger',
description: 'must not run for a failed provider attempt',
parameters: {},
async execute() {
toolExecutions += 1
return [{ type: 'text', text: 'unexpected' }]
},
}))
const agent = context.agentLoop.create(SessionId('retry-partial'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }])
await scheduled
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(500)
await idle
const failedChunks = agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
)
expect(failedChunks).toHaveLength(6)
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.some(event => event.type === 'tool/call')).toBe(false)
expect(toolExecutions).toBe(0)
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
role: 'assistant',
content: [{ type: 'text', text: 'recovered' }],
provenance: { provider: 'mock', model: 'mock' },
})
})
it('applies bounded exponential jitter and stops after the configured budget', async () => {
vi.useFakeTimers()
const samples = [0, 1]
const adapter = new ScriptedAdapter([
new LlmError('busy one', 'SERVER'),
new LlmError('busy two', 'SERVER'),
new LlmError('busy three', 'SERVER'),
])
;({ ctx: context } = await harness(adapter, { jitterRatio: 0.1 }, undefined, {
random: () => samples.shift() ?? 0.5,
}))
const agent = context.agentLoop.create(SessionId('retry-exhausted'), { provider: 'mock', model: 'mock' })
const first = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }])
expect((await first).data.delayMs).toBe(450)
const second = waitForRetry(context, agent, 2)
await vi.advanceTimersByTimeAsync(450)
expect((await second).data.delayMs).toBe(1_100)
const idle = waitForIdle(context, agent)
await vi.advanceTimersByTimeAsync(1_100)
await idle
expect(adapter.requests).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { message: 'busy three', code: 'SERVER' } } },
})
})
it('uses a bounded provider Retry-After verbatim and delegates an over-cap instruction', async () => {
vi.useFakeTimers()
const accepted = new ScriptedAdapter([
new LlmError('wait', 'RATE_LIMIT', { providerRetryAfterMs: 2_000 }),
textResponse('done'),
])
;({ ctx: context } = await harness(accepted, { jitterRatio: 1 }))
const acceptedAgent = context.agentLoop.create(SessionId('retry-after-accepted'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, acceptedAgent, 1)
acceptedAgent.send([{ type: 'text', text: 'go' }])
expect((await scheduled).data.delayMs).toBe(2_000)
const acceptedIdle = waitForIdle(context, acceptedAgent)
await vi.advanceTimersByTimeAsync(2_000)
await acceptedIdle
expect(accepted.requests).toHaveLength(2)
await context.fiber.dispose()
const rejected = new ScriptedAdapter([
new LlmError('wait too long', 'RATE_LIMIT', { providerRetryAfterMs: 10_001 }),
])
;({ ctx: context } = await harness(rejected))
const rejectedAgent = context.agentLoop.create(SessionId('retry-after-rejected'), { provider: 'mock', model: 'mock' })
const rejectedIdle = waitForIdle(context, rejectedAgent)
rejectedAgent.send([{ type: 'text', text: 'go' }])
await rejectedIdle
expect(rejected.requests).toHaveLength(1)
expect(rejectedAgent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
})
it('delegates non-transient failures without scheduling a timer', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-auth'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }])
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('aborts and drains a captured backoff before plugin disposal completes', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'TRANSPORT'),
textResponse('must not run'),
])
const mounted = await harness(adapter)
context = mounted.ctx
const agent = context.agentLoop.create(SessionId('retry-hmr'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }])
await scheduled
const idle = waitForIdle(context, agent)
await mounted.retryFiber.dispose()
await idle
await vi.advanceTimersByTimeAsync(60_000)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
})
it('does not make plugin disposal wait for a delegated recovery policy', async () => {
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
const mounted = await harness(adapter)
context = mounted.ctx
const downstream = Promise.withResolvers<RequestErrorDecision>()
const entered = Promise.withResolvers<undefined>()
context.on('agent/request-error', () => {
entered.resolve(undefined)
return downstream.promise
})
const agent = context.agentLoop.create(SessionId('retry-delegated-disposal'), {
provider: 'mock',
model: 'mock',
})
const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }])
await entered.promise
const disposing = mounted.retryFiber.dispose()
let timer: ReturnType<typeof setTimeout> | undefined
const outcome = await Promise.race([
disposing.then(() => 'disposed' as const),
new Promise<'blocked'>((resolve) => { timer = setTimeout(() => { resolve('blocked') }, 100) }),
])
if (timer !== undefined) clearTimeout(timer)
downstream.resolve({ action: 'fail' })
await disposing
await idle
expect(outcome).toBe('disposed')
expect(adapter.requests).toHaveLength(1)
})
it('fails a captured callback after disposal without entering downstream policy', async () => {
const adapter = new ScriptedAdapter([new LlmError('bad key', 'AUTH')])
const captured = Promise.withResolvers<undefined>()
let invokeCaptured: (() => Promise<void>) | undefined
const mounted = await harness(adapter, {}, (ctx) => {
ctx.on('agent/request-error', (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
return new Promise<RequestErrorDecision>((resolve) => {
invokeCaptured = async () => { resolve(await next()) }
captured.resolve(undefined)
})
})
})
context = mounted.ctx
let downstreamCalls = 0
context.on('agent/request-error', async (_agent, _turn, _step, _error, _failure, _history, _signal, next) => {
downstreamCalls += 1
return next()
})
const agent = context.agentLoop.create(SessionId('retry-captured-disposal'), {
provider: 'mock',
model: 'mock',
})
const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }])
await captured.promise
await mounted.retryFiber.dispose()
if (invokeCaptured === undefined) throw new Error('request-error waterfall did not capture retry callback')
await invokeCaptured()
await idle
expect(downstreamCalls).toBe(0)
expect(adapter.requests).toHaveLength(1)
})
it('lets turn cancellation win during backoff without opening another step', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'TIMEOUT'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-cancel'), { provider: 'mock', model: 'mock' })
const scheduled = waitForRetry(context, agent, 1)
agent.send([{ type: 'text', text: 'go' }])
await scheduled
const idle = waitForIdle(context, agent)
agent.cancel('user cancelled during retry')
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted', reason: 'user cancelled during retry' } },
})
expect(vi.getTimerCount()).toBe(0)
})
it('lets an earlier recovery listener cancel before retry policy runs', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'SERVER'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter, {}, (ctx) => {
ctx.on('agent/request-error', async (agent, _turn, _step, _error, _failure, _history, _signal, next) => {
agent.cancel('cancelled by earlier recovery policy')
return next()
})
}))
const agent = context.agentLoop.create(SessionId('retry-pre-cancel'), { provider: 'mock', model: 'mock' })
const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }])
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'aborted', reason: 'cancelled by earlier recovery policy' } },
})
})
it('handles synchronous cancellation from the retry status event', async () => {
vi.useFakeTimers()
const adapter = new ScriptedAdapter([
new LlmError('temporary', 'SERVER'),
textResponse('must not run'),
])
;({ ctx: context } = await harness(adapter))
const agent = context.agentLoop.create(SessionId('retry-event-cancel'), { provider: 'mock', model: 'mock' })
context.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'llm/retry') agent.cancel('cancelled by retry observer')
})
const idle = waitForIdle(context, agent)
agent.send([{ type: 'text', text: 'go' }])
await idle
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(1)
expect(vi.getTimerCount()).toBe(0)
})
it.each([
[{ maxTransientRetries: -1 }, /maxTransientRetries/],
[{ maxTransientRetries: 1.5 }, /maxTransientRetries/],
[{ initialDelayMs: 0 }, /initialDelayMs/],
[{ maxDelayMs: Number.POSITIVE_INFINITY }, /maxDelayMs/],
[{ initialDelayMs: MAX_TIMER_DELAY_MS + 1 }, /initialDelayMs/],
[{ maxDelayMs: MAX_TIMER_DELAY_MS + 1 }, /maxDelayMs/],
[{ initialDelayMs: 20, maxDelayMs: 10 }, /less than or equal/],
[{ jitterRatio: 1.1 }, /jitterRatio/],
[{ retryableCodes: [] }, /must not be empty/],
[{ retryableCodes: ['SERVER', 'SERVER'] }, /duplicates/],
[{ retryableCodes: [''] }, /non-empty strings/],
] as const)('fails direct composition for invalid config %#', (config, message) => {
expect(() => { retry.apply(new Context(), config as retry.Config) }).toThrow(message)
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/agent"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -13,7 +13,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
@@ -21,12 +21,12 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re
| Event | Mode | Purpose |
|---|---|---|
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
| `llm/stream` | waterfall | Intercept/wrap every streaming model call for caching, logging, or routing |
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead.
### Content-block vocabulary (`types.ts`)
@@ -47,9 +47,10 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract.
- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) matches its frozen serializable `failure.code`. The payload may also retain validated status, `Retry-After`, and branded provider request id facts; policy remains outside the error.
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
### Real adapters
@@ -65,7 +66,7 @@ Pass-through; the registry preserves the assembled request prefix, while the sel
## Known Limitations and Deferred Work
- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains the call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure.
- **No default retry/caching/rate-limit policy ships in this service** — `llm/stream` remains a single-attempt call-wrapper seam; the agent loop separately offers proven model-request failures to `agent/request-error`, whose default preserves the original failure. `@deepseek-ai/dsh-llm-retry` is an optional policy plugin loaded by the shared example spine.
- **`GenerateOptions` sampling is `temperature`/`maxTokens`/`stop` only** — no `tool_choice`, `top_p`, or penalty fields; the vocabulary grows when a producer lands ([dropped inert knobs](../../../.agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md)).
- **Producer-gated variants stay out until produced** — `prefill`, per-tool `strict`, block `cache` hints, and the `agent` message-source variant were pruned as producerless ([Agent Note](../../../.agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)).
- **`BlockAssembler` handles core block kinds only** — a plugin-added block type whose stream is never closed by `block-end` makes `blocks()` throw.

View File

@@ -5,10 +5,10 @@
*/
import { HarnessError } from './error.ts'
import type { StreamChunk } from './types.ts'
import type { LlmFailure, StreamChunk } from './types.ts'
/** Errors proven to originate in one model call's final adapter boundary. */
export type AdapterFailureScope = WeakSet<Error>
/** Errors and normalized facts proven to originate in one model call's final adapter boundary. */
export type AdapterFailureScope = WeakMap<Error, LlmFailure>
/** Call-local failure scopes keyed by the exact stream handle returned to a consumer. */
const adapterFailureScopes = new WeakMap<AsyncIterable<StreamChunk>, AdapterFailureScope>()
@@ -47,10 +47,71 @@ export function markLlmAdapterFailure(
const error = value instanceof Error
? value as Error & { code?: string }
: new HarnessError(String(value), 'UNKNOWN', { cause: value })
failures.add(error)
const carried = error instanceof HarnessError ? ownFailureSnapshot(error) : undefined
const failure = carried !== undefined && carried.code === error.code ? carried : Object.freeze({
message: errorMessage(error),
code: harnessErrorCode(error),
})
failures.set(error, failure)
return error
}
/** Snapshot an own data property without invoking an SDK-defined accessor. */
function ownFailureSnapshot(error: Error): LlmFailure | undefined {
try {
const descriptor = Object.getOwnPropertyDescriptor(error, 'failure')
return descriptor !== undefined && 'value' in descriptor
? failureSnapshot(descriptor.value)
: undefined
} catch (_sdkPropertyTrap) {
return undefined
}
}
/** Validate and detach an arbitrary serializable failure payload. */
function failureSnapshot(value: unknown): LlmFailure | undefined {
if (typeof value !== 'object' || value === null) return undefined
try {
const candidate = value as Partial<LlmFailure>
const message = candidate.message
const code = candidate.code
const status = candidate.status
const providerRetryAfterMs = candidate.providerRetryAfterMs
const requestId = candidate.requestId
if (typeof message !== 'string' || message.length === 0
|| typeof code !== 'string' || code.length === 0
|| (status !== undefined && (!Number.isInteger(status) || status < 100 || status > 599))
|| (providerRetryAfterMs !== undefined
&& (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0))
|| (requestId !== undefined && (typeof requestId !== 'string' || requestId.length === 0))) return undefined
return Object.freeze({
message,
code,
...status === undefined ? {} : { status },
...providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs },
...requestId === undefined ? {} : { requestId },
})
} catch (_sdkFailureGetter) {
return undefined
}
}
/** Read an SDK error message without letting an accessor replace the primary failure. */
function errorMessage(error: Error): string {
try {
const message: unknown = error.message
if (typeof message === 'string' && message.length > 0) return message
} catch (_sdkMessageGetter) {
// The fallback below preserves a serializable failure beside the original Error.
}
return 'LLM adapter failed'
}
/** Trust only Harness-owned codes; third-party SDK codes are not our taxonomy. */
function harnessErrorCode(error: Error): string {
return error instanceof HarnessError ? error.code : 'UNKNOWN'
}
/**
* Whether a failure came from final adapter dispatch, iterator construction,
* or iteration for the call represented by the exact returned stream handle.
@@ -65,3 +126,18 @@ export function isLlmAdapterFailure(
const failures = adapterFailureScopes.get(stream)
return value instanceof Error && failures !== undefined && failures.has(value)
}
/**
* Retrieve normalized provider facts only for an Error tagged by this exact
* model call's final adapter boundary.
* @param stream - the exact stream returned to the consumer.
* @param value - the caught failure.
* @returns the immutable facts for that call, or `undefined` for middleware, nested, or consumer failures.
*/
export function llmFailureOf(
stream: AsyncIterable<StreamChunk>,
value: unknown,
): LlmFailure | undefined {
const failures = adapterFailureScopes.get(stream)
return value instanceof Error ? failures?.get(value) : undefined
}

View File

@@ -1,5 +1,6 @@
/**
* dsh-llm's owned branded id: `CallId` (tool-call correlation).
* dsh-llm's owned branded ids: tool-call correlation and provider request
* diagnostics.
*
* The `Branded<B>` primitive itself lives in `@deepseek-ai/dsh-brand` (a
* zero-dependency type-only package) so every owner of a cross-boundary id can
@@ -25,3 +26,15 @@ export type CallId = Branded<'CallId'>
export function CallId(id: string): CallId {
return id as CallId
}
/** Provider-issued request identifier retained for diagnostics across package boundaries. */
export type ProviderRequestId = Branded<'ProviderRequestId'>
/**
* Brand a provider-issued request identifier.
* @param id - the opaque provider-issued string.
* @returns the same string, branded; no validation is performed.
*/
export function ProviderRequestId(id: string): ProviderRequestId {
return id as ProviderRequestId
}

View File

@@ -24,6 +24,9 @@ export class HarnessError extends Error {
/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */
export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED'
/** Canonical provider-neutral code for an exhausted account quota or balance. */
export const QUOTA_EXCEEDED_CODE = 'QUOTA'
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`
@@ -62,6 +65,20 @@ export function isContextWindowExceededError(detail: string): boolean {
|| EXCEEDS_MODEL_CONTEXT.test(detail)
}
/**
* Recognize provider wording that identifies an exhausted account quota rather
* than a transient request-rate limit.
* @param detail - provider error code/type/message text joined into one string.
* @returns true only for terminal quota, balance, credit, budget, or usage-limit wording.
*/
export function isQuotaExceededError(detail: string): boolean {
return /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i.test(detail)
|| /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i.test(detail)
|| /\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i.test(detail)
|| /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted)\b/i.test(detail)
|| /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail)
}
/**
* Render a thrown value with its full `cause` chain and AggregateError
* members, so transport wrappers like undici's `TypeError: fetch failed`

View File

@@ -7,7 +7,8 @@
*/
import { Context, Service } from 'cordis'
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
import type { GenerateOptions, LlmFailure, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
import type { ProviderRequestId } from './brand.ts'
import { deepFreeze } from './call-config.ts'
import { HarnessError } from './error.ts'
import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts'
@@ -21,7 +22,7 @@ export * from './types.ts'
export { BlockAssembler } from './assembler.ts'
export { callConfigEquals, deepFreeze } from './call-config.ts'
export type { LlmCallConfig } from './call-config.ts'
export { isLlmAdapterFailure } from './adapter-failure.ts'
export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts'
declare module 'cordis' {
interface Context {
@@ -44,14 +45,53 @@ declare module 'cordis' {
}
}
/** Structured provider facts and cause accepted by {@link LlmError}. */
export interface LlmErrorOptions extends ErrorOptions {
/** Valid HTTP status observed at the provider boundary. */
status?: number
/** Positive finite provider-requested delay in milliseconds. */
providerRetryAfterMs?: number
/** Non-empty opaque provider request id. */
requestId?: ProviderRequestId
}
/**
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.
*/
export class LlmError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
/** Serializable facts retained beside this live Error. */
readonly failure: LlmFailure
/**
* @param message - non-empty human-readable failure summary.
* @param code - non-empty stable provider-neutral machine code.
* @param options - optional cause and validated serializable provider facts.
*/
constructor(message: string, code: string, options?: LlmErrorOptions) {
if (typeof message !== 'string' || message.length === 0) throw new Error('LlmError message must be a non-empty string')
if (typeof code !== 'string' || code.length === 0) throw new Error('LlmError code must be a non-empty string')
if (options?.status !== undefined
&& (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) {
throw new Error('LlmError status must be an integer from 100 through 599')
}
if (options?.providerRetryAfterMs !== undefined
&& (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) {
throw new Error('LlmError providerRetryAfterMs must be a positive finite number')
}
if (options?.requestId !== undefined
&& (typeof options.requestId !== 'string' || options.requestId.length === 0)) {
throw new Error('LlmError requestId must be a non-empty string')
}
super(message, code, options)
this.name = 'LlmError'
this.failure = Object.freeze({
message,
code,
...options?.status === undefined ? {} : { status: options.status },
...options?.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: options.providerRetryAfterMs },
...options?.requestId === undefined ? {} : { requestId: options.requestId },
})
}
}
@@ -262,7 +302,7 @@ export class LlmService extends Service {
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const failures: AdapterFailureScope = new WeakSet<Error>()
const failures: AdapterFailureScope = new WeakMap<Error, LlmFailure>()
const stream = this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(options, failures))
return bindAdapterFailureScope(stream, failures)
}

View File

@@ -5,7 +5,21 @@
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId } from './brand.ts'
import type { CallId, ProviderRequestId } from './brand.ts'
/** Serializable provider-boundary facts; policy decides whether they are retryable. */
export interface LlmFailure {
/** Human-readable provider or transport failure. */
readonly message: string
/** Stable provider-neutral machine-routing code. */
readonly code: string
/** HTTP status observed at the provider boundary, when available. */
readonly status?: number
/** Provider-requested delay in milliseconds, when valid and available. */
readonly providerRetryAfterMs?: number
/** Opaque provider-issued request identifier for diagnostics. */
readonly requestId?: ProviderRequestId
}
/** Plain text visible to the end user. */
export interface TextBlock {
@@ -98,8 +112,8 @@ export interface FinishReasonMap {
'stop': { kind: 'stop' }
'tool-calls': { kind: 'tool-calls' }
'max-tokens': { kind: 'max-tokens' }
'aborted': { kind: 'aborted' }
'error': { kind: 'error'; message: string; code?: string }
'aborted': { kind: 'aborted'; failure: LlmFailure }
'error': { kind: 'error'; failure: LlmFailure }
}
/** Any known finish reason, derived from {@link FinishReasonMap}; switch on `kind` and fall through unknowns (merge-extensible). */

View File

@@ -41,7 +41,10 @@ const chunkArb: fc.Arbitrary<StreamChunk> = indexArb.chain(index => fc.oneof(
fc.constant<StreamChunk>({ type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } }),
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'stop' } }),
fc.constant<StreamChunk>({ type: 'finish', reason: { kind: 'tool-calls' } }),
fc.string().map((message): StreamChunk => ({ type: 'finish', reason: { kind: 'error', message } })),
fc.string({ minLength: 1 }).map((message): StreamChunk => ({
type: 'finish',
reason: { kind: 'error', failure: { message, code: 'UNKNOWN' } },
})),
))
/** A stream is an arbitrary list of chunks (we do NOT force a terminal finish). */

View File

@@ -5,9 +5,12 @@ import LlmService, {
GenerateOptions,
HarnessError,
isContextWindowExceededError,
isQuotaExceededError,
isLlmAdapterFailure,
LlmAdapter,
LlmError,
llmFailureOf,
ProviderRequestId,
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
@@ -81,6 +84,18 @@ describe('LlmService', () => {
expect(isContextWindowExceededError('context window size must be positive')).toBe(false)
})
it('distinguishes exhausted account quota from transient rate limiting', () => {
for (const detail of [
'insufficient_quota',
'account balance depleted',
'usage-limit-exceeded',
'out of credits',
'OpenAI API error (429): You exceeded your current quota, please check your plan and billing details.',
]) expect(isQuotaExceededError(detail)).toBe(true)
expect(isQuotaExceededError('HTTP 429: rate limit reached')).toBe(false)
expect(isQuotaExceededError('quota resets in one minute')).toBe(false)
})
it('errorChain renders the full cause chain of a wrapped transport failure', () => {
const chain = new TypeError('fetch failed', { cause: new Error('connect ECONNREFUSED 127.0.0.1:443') })
expect(errorChain(chain)).toBe('fetch failed: connect ECONNREFUSED 127.0.0.1:443')
@@ -213,6 +228,151 @@ describe('LlmService', () => {
expect(caught).toBe(original)
expect(isLlmAdapterFailure(stream, caught)).toBe(true)
expect(llmFailureOf(stream, caught)).toEqual({
message: `${boundary} failed`,
code: 'BOUNDARY_FAILED',
})
})
it('keeps structured provider facts beside a frozen third-party Error', async () => {
const original = new LlmError('provider busy', 'RATE_LIMIT', {
status: 429,
providerRetryAfterMs: 1_500,
requestId: ProviderRequestId('req-7'),
})
Object.freeze(original)
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
let caught: unknown
try {
for await (const _chunk of stream) { /* drain */ }
} catch (error: unknown) {
caught = error
}
expect(caught).toBe(original)
expect(llmFailureOf(stream, caught)).toEqual({
message: 'provider busy',
code: 'RATE_LIMIT',
status: 429,
providerRetryAfterMs: 1_500,
requestId: ProviderRequestId('req-7'),
})
})
it('does not trust retry facts carried by an unknown third-party Error', async () => {
const carried = { message: 'busy', code: 'SERVER', status: 503 }
const original = Object.assign(new Error('busy'), { failure: carried })
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
const facts = llmFailureOf(stream, original)
carried.status = 500
expect(facts).toEqual({ message: 'busy', code: 'UNKNOWN' })
expect(Object.isFrozen(facts)).toBe(true)
expect(facts).not.toBe(carried)
})
it('keeps an unknown SDK Error exact without trusting its private code or accessors', async () => {
const original = Object.assign(new Error('socket closed'), { code: 'ECONNRESET' })
Object.defineProperty(original, 'failure', {
get() { throw new Error('SDK failure accessor must not run') },
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(original.code).toBe('ECONNRESET')
expect(llmFailureOf(stream, original)).toEqual({ message: 'socket closed', code: 'UNKNOWN' })
})
it('keeps an SDK Error exact when its message accessor is hostile', async () => {
const original = Object.defineProperty(new Error(), 'message', {
get() { throw new Error('SDK message accessor trap') },
})
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({ message: 'LLM adapter failed', code: 'UNKNOWN' })
})
it('falls back safely when SDK objects trap failure inspection or expose malformed facts', async () => {
const propertyTrap = new Proxy(new HarnessError('descriptor trapped', 'SERVER'), {
getOwnPropertyDescriptor(target, property) {
if (property === 'failure') throw new Error('SDK descriptor trap')
return Reflect.getOwnPropertyDescriptor(target, property)
},
})
const throwingFacts = Object.create(null) as Record<string, unknown>
Object.defineProperty(throwingFacts, 'message', {
get() { throw new Error('SDK fact getter trap') },
})
const carrying = (message: string, failure: unknown): HarnessError => Object.defineProperty(
new HarnessError(message, 'SERVER'),
'failure',
{ value: failure },
)
const factGetter = carrying('fact getter failed', throwingFacts)
const malformed = carrying('malformed facts', { message: 'provider busy', code: 'SERVER', requestId: 1 })
const primitive = carrying('primitive facts', 1)
const nullFacts = carrying('null facts', null)
const mismatched = carrying('mismatched facts', { message: 'busy', code: 'RATE_LIMIT' })
for (const [original, expectedMessage] of [
[propertyTrap, 'descriptor trapped'],
[factGetter, 'fact getter failed'],
[malformed, 'malformed facts'],
[primitive, 'primitive facts'],
[nullFacts, 'null facts'],
[mismatched, 'mismatched facts'],
] as const) {
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({ message: expectedMessage, code: 'SERVER' })
}
})
it('retains a stable code from a HarnessError without requiring LlmError facts', async () => {
const original = new HarnessError('stable adapter failure', 'ADAPTER_STABLE')
const ctx = new Context()
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['test-provider'], new ThrowingAdapter(original))
const stream = ctx.llm.stream({ provider: 'test-provider', model: 'test-model', messages: [] })
await expect((async () => {
for await (const _chunk of stream) { /* drain */ }
})()).rejects.toBe(original)
expect(llmFailureOf(stream, original)).toEqual({
message: 'stable adapter failure',
code: 'ADAPTER_STABLE',
})
expect(llmFailureOf(stream, 'not an Error')).toBeUndefined()
expect(llmFailureOf({ [Symbol.asyncIterator]: () => stream[Symbol.asyncIterator]() }, original)).toBeUndefined()
})
it('keeps a nested adapter failure scoped to the nested model call', async () => {
@@ -631,6 +791,16 @@ describe('LlmService', () => {
expect(err.code).toBe('CUSTOM_CODE')
})
it('rejects non-serializable structured failure facts at construction', () => {
expect(() => new LlmError('busy', 'RATE_LIMIT', { status: 42 })).toThrow(/status/)
expect(() => new LlmError('busy', 'RATE_LIMIT', { providerRetryAfterMs: Number.NaN }))
.toThrow(/providerRetryAfterMs/)
expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: ProviderRequestId('') })).toThrow(/requestId/)
expect(() => new LlmError(1 as never, 'RATE_LIMIT')).toThrow(/message/)
expect(() => new LlmError('busy', 1 as never)).toThrow(/code/)
expect(() => new LlmError('busy', 'RATE_LIMIT', { requestId: 1 as never })).toThrow(/requestId/)
})
it('LlmError extends the shared HarnessError base', async () => {
const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm')
const cause = new Error('root cause')

View File

@@ -142,7 +142,7 @@ describe('deriveReplayScript', () => {
it('keeps a finish-error chunk in the derived entry (replays naturally)', () => {
const errChunks: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'finish', reason: { kind: 'error', message: 'boom', code: 'X' } },
{ type: 'finish', reason: { kind: 'error', failure: { message: 'boom', code: 'X' } } },
]
const events = errChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c))
expect(deriveReplayScript(events)).toEqual([{ kind: 'chunks', chunks: errChunks }])

View File

@@ -28,9 +28,9 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, and tool events, and re-advertises commands |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session |
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, and tool render intents |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, and tool render intents |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
@@ -53,6 +53,8 @@ When `ctx.permission` is composed, the bridge also advertises a `permission` sel
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events.
## Per-session cwd
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.

View File

@@ -31,6 +31,7 @@
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
@@ -52,6 +53,7 @@
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -46,6 +46,7 @@ import {
} from '@agentclientprotocol/sdk'
import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-commands'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -564,7 +565,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
reason: TurnEndReason,
): void => {
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${reason.message}`))
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
} else {
inflight.resolve(turnEndToStopReason(reason))
}
@@ -1176,6 +1177,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* identical update stream from the same event log.
*
* - `assistant/chunk` text-delta/reasoning-delta → message/thought chunks
* - `llm/retry` and terminal model failure → visible discarded-attempt markers
* - `user/message` → `user_message_chunk` during load replay only — so a
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
@@ -1223,6 +1225,13 @@ export function streamSessionEventUpdate(
}
return
}
case 'llm/retry': {
const text = '\n\n[Previous model attempt discarded; retrying '
+ `${event.data.retry}/${event.data.maxRetries} in ${event.data.delayMs}ms: `
+ `${event.data.failure.message}]\n\n`
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
case 'user/message': {
if (!includeUserMessages) return
// Replay the user's prompt so a loaded session shows both sides of each
@@ -1254,7 +1263,13 @@ export function streamSessionEventUpdate(
notify({ sessionId, update: { sessionUpdate: 'plan', ...todosToPlan(event.data.todos) } })
return
}
// turn/step boundaries, context/message, steering,
case 'turn/end': {
if (event.data.reason.kind !== 'error' || !('failure' in event.data.reason)) return
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${event.data.reason.failure.message}]\n\n`
notify({ sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } })
return
}
// non-error turn/step boundaries, context/message, steering,
// assistant/message — no direct ACP client update.
default:
return

View File

@@ -101,7 +101,7 @@ export function errorResponse(message: string): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'partial' },
{ type: 'finish', reason: { kind: 'error', message, code: 'PROVIDER_ERROR' } },
{ type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } },
]
}

View File

@@ -65,6 +65,37 @@ describe('streamSessionEventUpdate', () => {
.toEqual([])
})
it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => {
expect(updatesFor(evt('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'backend busy', code: 'SERVER' },
}))).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n',
},
}])
expect(updatesFor(evt('turn/end', {
turn: 1,
reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } },
}))).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n',
},
}])
expect(updatesFor(evt('turn/end', {
turn: 1,
reason: { kind: 'error', step: 2, message: 'post-step failed' },
}))).toEqual([])
})
it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => {
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
expect(updates).toEqual([{

View File

@@ -49,6 +49,15 @@ describe('acp bridge — turn outcomes', () => {
.rejects.toThrow(/turn failed: provider boom/)
})
it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: plugin pre-step failed/)
})
it('streams a tool call as tool_call then tool_call_update', async () => {
harness = await makeBridgeHarness({
storageDir,

View File

@@ -20,6 +20,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../core/session"
},

View File

@@ -4,9 +4,11 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@ear
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear.
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.

View File

@@ -26,6 +26,7 @@
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -41,6 +42,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",

View File

@@ -37,7 +37,8 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-commands'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type Session, type SessionEvent, type TodoItem } from '@deepseek-ai/dsh-session'
import type {
FileDiff,
@@ -605,15 +606,38 @@ function formatCwd(cwd: string | undefined): string {
return displayText(cwd)
}
function sessionTokens(session: Session): { input: number; output: number } {
let input = 0
let output = 0
for (const event of session.events) {
if (event.type !== 'assistant/message' || event.data.usage === undefined) continue
input += event.data.usage.inputTokens
output += event.data.usage.outputTokens
interface SessionTokenTotals {
input: number
output: number
readonly byStep: Map<string, TokenUsage>
}
function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
const key = `${turn}:${step}`
const previous = totals.byStep.get(key)
if (previous !== undefined) {
totals.input -= previous.inputTokens
totals.output -= previous.outputTokens
}
return { input, output }
totals.byStep.set(key, usage)
totals.input += usage.inputTokens
totals.output += usage.outputTokens
}
function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
}
}
function sessionTokens(session: Session): SessionTokenTotals {
const totals: SessionTokenTotals = { input: 0, output: 0, byStep: new Map() }
for (const event of session.events) {
recordEventUsage(totals, event)
}
return totals
}
class FooterComponent implements Component {
@@ -893,6 +917,14 @@ export function createTuiChat(
return card
}
const clearStreaming = (): void => {
if (streaming === undefined) return
const index = chat.children.indexOf(streaming)
/* v8 ignore next -- streaming is assigned only after the same component is added, and every removal clears it. */
if (index >= 0) chat.children.splice(index, 1)
streaming = undefined
}
const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => {
switch (event.type) {
case 'user/message': {
@@ -935,15 +967,19 @@ export function createTuiChat(
}
break
case 'assistant/message': {
if (streaming !== undefined) {
const index = chat.children.indexOf(streaming)
if (index >= 0) chat.children.splice(index, 1)
streaming = undefined
}
clearStreaming()
const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme)
if (component.children.length > 0) chat.addChild(component)
break
}
case 'llm/retry': {
clearStreaming()
appendNotice(
`Retrying model request (${event.data.retry}/${event.data.maxRetries}) in ${event.data.delayMs}ms: ${event.data.failure.message}`,
'warning',
)
break
}
case 'tool/call':
chat.addChild(new Spacer(1))
chat.addChild(parsedTool(event))
@@ -964,9 +1000,13 @@ export function createTuiChat(
todo.update(event.data.todos)
break
case 'turn/end':
clearStreaming()
if (event.data.reason.kind === 'error') {
const key = `${event.data.turn}:${event.data.reason.step}`
if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error')
const message = 'failure' in event.data.reason
? event.data.reason.failure.message
: event.data.reason.message
if (!liveErrors.delete(key)) appendNotice(message, 'error')
} else if (event.data.reason.kind === 'aborted') {
appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning')
} else if (event.data.reason.kind === 'max-tokens') {
@@ -1286,10 +1326,7 @@ export function createTuiChat(
const disposeSessionEvents = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (event.type === 'assistant/message' && event.data.usage !== undefined) {
tokens.input += event.data.usage.inputTokens
tokens.output += event.data.usage.outputTokens
}
recordEventUsage(tokens, event)
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
rebuildTranscript(false)
return

View File

@@ -122,10 +122,11 @@ export function appendAssistant(
session: Session,
content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number },
position: { turn: number; step: number } = { turn: 1, step: 0 },
): void {
session.append('assistant/message', {
turn: 1,
step: 0,
turn: position.turn,
step: position.step,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content,
...usage === undefined ? {} : { usage },

View File

@@ -0,0 +1,48 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=15 bufferRow=15
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Start then cancel. "
style 0-0 fg=bright-blue
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 1000ms: temporary transport failure "
style 1-67 fg=yellow
12| <blank>
13| " cancelled during retry delay "
style 1-28 fg=yellow
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
18-35| <blank>

View File

@@ -0,0 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Let the bounded policy exhaust. "
style 0-0 fg=bright-blue
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " provider still unavailable "
style 1-26 fg=red
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
16-35| <blank>

View File

@@ -0,0 +1,49 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=16 bufferRow=16
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Recover this request. "
style 0-0 fg=bright-blue
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
12| <blank>
13| " Assistant "
style 1-9 fg=bright-magenta bold
14| " Recovered on the next bounded attempt. "
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
19-35| <blank>

View File

@@ -0,0 +1,45 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Recover this request. "
style 0-0 fg=bright-blue
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
style 0-24 dim
style 63-95 dim
16-35| <blank>

View File

@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
@@ -24,6 +25,10 @@ const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh'
const CHECKPOINTS = [
'conversation-streaming',
'retry-scheduled',
'retry-recovered',
'retry-cancelled',
'retry-exhausted',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -222,6 +227,82 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins failed-stream retraction, scheduled retry, and eventual success', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
appendUser(harness.session, 'Recover this request.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'discarded partial output' },
})
harness.session.append('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'provider rate limit', code: 'RATE_LIMIT', status: 429 },
})
})
await checkpoint('retry-scheduled', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => {
harness.session.append('assistant/message', {
turn: 1,
step: 2,
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
content: [{ type: 'text', text: 'Recovered on the next bounded attempt.' }],
}, { surfaceOp: 'append' })
harness.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
await checkpoint('retry-recovered', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('pins cancellation during a scheduled retry delay', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
appendUser(harness.session, 'Start then cancel.')
harness.session.append('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 1_000,
failure: { message: 'temporary transport failure', code: 'TRANSPORT' },
})
harness.session.append('turn/end', {
turn: 1,
reason: { kind: 'aborted', reason: 'cancelled during retry delay' },
})
})
await checkpoint('retry-cancelled', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('pins terminal exhaustion after retracting a failed partial stream', async () => {
const harness = await setupSnapshot()
await renderAfter(harness, () => {
appendUser(harness.session, 'Let the bounded policy exhaust.')
harness.session.append('assistant/chunk', {
turn: 1,
step: 3,
chunk: { type: 'text-delta', index: 0, text: 'discarded terminal partial output' },
})
harness.session.append('turn/end', {
turn: 1,
reason: {
kind: 'error',
step: 3,
failure: { message: 'provider still unavailable', code: 'SERVER', status: 503 },
},
})
})
await checkpoint('retry-exhausted', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('pins Code Mode run_code with its production presenter', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const call = {

View File

@@ -8,6 +8,7 @@ import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-command
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
mountTui,
@@ -245,7 +246,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('live thought')
result.terminal.send('\x12')
await tick()
appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 })
appendAssistant(
result.session,
[{ type: 'text', text: 'final live answer' }],
{ inputTokens: 500, outputTokens: 8 },
{ turn: 2, step: 0 },
)
await tick()
expect(result.terminal.output).toContain('Working')
@@ -277,6 +283,68 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
})
it('counts failed and recovered request usage once per step', async () => {
const result = await setup()
result.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 2 } },
})
result.session.append('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'temporary', code: 'SERVER' },
})
result.session.append('assistant/chunk', {
turn: 1,
step: 2,
chunk: { type: 'usage', usage: { inputTokens: 7, outputTokens: 3 } },
})
appendAssistant(
result.session,
[{ type: 'text', text: 'recovered' }],
{ inputTokens: 7, outputTokens: 3 },
{ turn: 1, step: 2 },
)
await tick()
expect(result.terminal.output).toContain('↑17 ↓5')
await dispose(result)
})
it('retracts a failed live stream and renders its durable retry status', async () => {
const result = await setup()
result.session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' },
})
result.session.append('llm/retry', {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 },
})
result.session.append('llm/retry', {
turn: 1,
step: 2,
retry: 2,
maxRetries: 2,
delayMs: 1_000,
failure: { message: 'failed before chunks', code: 'SERVER', status: 503 },
})
await tick()
expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited')
expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks')
await dispose(result)
})
it('renders the ANSI palette and every markdown/content style', async () => {
const result = await setup({
config: { color: true },
@@ -556,10 +624,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
events.session.append('turn/end', {
turn: 9,
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
})
events.ctx.emit('agent/disposed', events.agent)
await tick()
expect(events.terminal.output).toContain('live failure')
expect(events.terminal.output).toContain('durable failure')
expect(events.terminal.output).toContain('structured provider failure')
expect(events.terminal.output).toContain('stopped')
expect(events.terminal.output).toContain('output-token limit')
expect(events.terminal.output).toContain('Turn rejected')

View File

@@ -26,6 +26,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../core/tools"
},

View File

@@ -9,13 +9,15 @@ It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds
## Surface
```ts
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
```
| Export | Role |
|---|---|
| `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. |
| `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. |
| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. |
| `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. |
| `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). |
| `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. |
@@ -44,6 +46,8 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si
Pass your own `code` to `timeoutOf` so classification composes under nesting: when the `upstream` you were handed is *itself* a deadline signal (a future `tools/execute` middleware arming a per-call deadline), `AbortSignal.any` preserves the outer `TimeoutReason` if the outer timer fires first. Scoping to your `code` makes a foreign timeout read as an ordinary upstream cancel — the correct classification from your capability's view — instead of your own timeout firing when your local timer never expired.
For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request.
## What does NOT get a timeout
Local file `read`/`write`/`edit` take no `timeoutMs`: a syscall is best-effort-abortable at most, a timeout could not force `fsync`/`rename` to stop, and adding one would be an implicit default that violates explicit-over-implicit. See [`fs/`](../../fs/README.md).
@@ -61,3 +65,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Notification only** — a deadline cannot stop work that ignores its signal; every capability still needs its own socket/process/task termination path.
- **`timeoutMs <= 0` is internal vocabulary** — it disables the local timer only after an owning backend has resolved policy, never as a public model/plugin knob.
- **The first abort reason wins classification** — when an upstream cancellation beats the local timer, this layer cannot later report that its own timeout would also have elapsed.
- **An idle watchdog is not a total deadline** — it rearms per outstanding iterator demand and deliberately excludes consumer think time.

View File

@@ -21,6 +21,15 @@ export class TimeoutReason extends Error {
}
}
/** Largest delay Node schedules without clamping it to one millisecond. */
export const MAX_TIMER_DELAY_MS = 2_147_483_647
function assertTimerDelay(timeoutMs: number, name: string): void {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(`${name} must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/**
* Validate a caller's optional timeout hint, use the backend default, then cap
* it. Supplied values must be positive and finite; zero is not a public
@@ -53,6 +62,20 @@ export interface Deadline {
[Symbol.dispose](): void
}
/** Rearmable timeout around one outstanding async-iterator demand. */
export interface IdleWatchdog {
/** Stable signal aborted by upstream cancellation or this watchdog's timeout. */
readonly signal: AbortSignal
/**
* Await one iterator demand while the idle timer is armed.
* @param iterator - iterator whose next value represents provider progress.
* @returns the iterator's next result.
*/
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
/** Clear an armed timer; safe to call once at the owning stream's exit. */
[Symbol.dispose](): void
}
/**
* Fuse upstream cancellation with an identifiable timeout. `timeoutMs <= 0` is
* the internal no-timer sentinel; the returned disposer clears an armed timer.
@@ -74,6 +97,8 @@ export function deadline(
return { signal: upstream ?? new AbortController().signal, [Symbol.dispose]() {} }
}
assertTimerDelay(timeoutMs, 'deadline timeoutMs')
const timer = new AbortController()
const id = setTimeout(() => { timer.abort(new TimeoutReason(code, timeoutMs)) }, timeoutMs)
return {
@@ -85,6 +110,57 @@ export function deadline(
}
}
/**
* Create a rearmable idle watchdog for an async iterator. The timer exists only
* while {@link IdleWatchdog.next} is outstanding, so consumer think time does
* not count as provider idle time. The returned signal is stable for the whole
* call and only notifies; the iterator must observe it to terminate its work.
*
* @param upstream - caller cancellation fused into the stable signal.
* @param timeoutMs - positive finite idle interval in milliseconds.
* @param code - capability-owned code carried by the timeout reason.
* @returns a stable signal, guarded next operation, and timer disposer.
*/
export function idleWatchdog(
upstream: AbortSignal | undefined,
timeoutMs: number,
code: string,
): IdleWatchdog {
assertTimerDelay(timeoutMs, 'idleWatchdog timeoutMs')
const timeout = new AbortController()
const signal = upstream === undefined
? timeout.signal
: AbortSignal.any([upstream, timeout.signal])
let timer: ReturnType<typeof setTimeout> | undefined
let outstanding = false
let disposed = false
return {
signal,
async next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>> {
if (disposed) throw new Error('idleWatchdog is disposed')
if (outstanding) throw new Error('idleWatchdog next is already outstanding')
outstanding = true
timer = setTimeout(() => {
timeout.abort(new TimeoutReason(code, timeoutMs))
}, timeoutMs)
try {
return await iterator.next()
} finally {
clearTimeout(timer)
timer = undefined
outstanding = false
}
},
[Symbol.dispose](): void {
if (disposed) return
disposed = true
if (timer !== undefined) clearTimeout(timer)
timer = undefined
},
}
}
/**
* Recover a timeout reason from a reason-bearing object. Supplying `code`
* distinguishes this deadline from a nested upstream deadline; a foreign code

View File

@@ -1,5 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
import {
clampTimeout,
deadline,
idleWatchdog,
MAX_TIMER_DELAY_MS,
timeoutOf,
TimeoutReason,
} from '@deepseek-ai/dsh-timeout'
describe('TimeoutReason', () => {
it('is an Error carrying the code and elapsed ms', () => {
@@ -67,6 +74,13 @@ describe('deadline — timeout arm', () => {
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('rejects delays that Node would clamp to one millisecond', () => {
expect(() => deadline(undefined, MAX_TIMER_DELAY_MS + 1, 'BASH_TIMEOUT'))
.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
expect(() => deadline(undefined, Number.POSITIVE_INFINITY, 'BASH_TIMEOUT'))
.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
})
})
describe('deadline — fuse with upstream', () => {
@@ -182,3 +196,74 @@ describe('deadline — nested deadlines', () => {
expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
})
})
describe('idleWatchdog', () => {
afterEach(() => { vi.useRealTimers() })
it('arms only while next is outstanding and rearms the same signal for later demand', async () => {
vi.useFakeTimers()
const first = Promise.withResolvers<IteratorResult<number>>()
const second = Promise.withResolvers<IteratorResult<number>>()
const iterator: AsyncIterator<number> = {
next: vi.fn()
.mockImplementationOnce(() => first.promise)
.mockImplementationOnce(() => second.promise),
}
using watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
const stableSignal = watchdog.signal
const firstNext = watchdog.next(iterator)
await vi.advanceTimersByTimeAsync(99)
expect(stableSignal.aborted).toBe(false)
first.resolve({ done: false, value: 1 })
await expect(firstNext).resolves.toEqual({ done: false, value: 1 })
await vi.advanceTimersByTimeAsync(10_000)
expect(stableSignal.aborted).toBe(false)
expect(watchdog.signal).toBe(stableSignal)
const secondNext = watchdog.next(iterator)
await vi.advanceTimersByTimeAsync(100)
expect(timeoutOf(stableSignal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 })
second.reject(stableSignal.reason)
await expect(secondNext).rejects.toBe(stableSignal.reason)
})
it('keeps an earlier upstream abort distinct from its own timeout', async () => {
vi.useFakeTimers()
const upstream = new AbortController()
using watchdog = idleWatchdog(upstream.signal, 100, 'LLM_STREAM_IDLE_TIMEOUT')
upstream.abort('caller cancelled')
expect(watchdog.signal.aborted).toBe(true)
expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toBeUndefined()
await vi.advanceTimersByTimeAsync(1_000)
expect(watchdog.signal.reason).toBe('caller cancelled')
})
it('clears an outstanding arm on disposal', async () => {
vi.useFakeTimers()
const pending = Promise.withResolvers<IteratorResult<number>>()
const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT')
void watchdog.next({ next: () => pending.promise })
watchdog[Symbol.dispose]()
await vi.advanceTimersByTimeAsync(1_000)
expect(watchdog.signal.aborted).toBe(false)
pending.resolve({ done: true, value: undefined })
await expect(watchdog.next({ next: () => Promise.resolve({ done: true, value: undefined }) }))
.rejects.toThrow(/disposed/)
watchdog[Symbol.dispose]()
})
it('rejects invalid bounds and concurrent iterator demand', async () => {
expect(() => idleWatchdog(undefined, 0, 'IDLE')).toThrow(/positive finite/)
expect(() => idleWatchdog(undefined, Number.NaN, 'IDLE')).toThrow(/positive finite/)
expect(() => idleWatchdog(undefined, MAX_TIMER_DELAY_MS + 1, 'IDLE'))
.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
const pending = Promise.withResolvers<IteratorResult<number>>()
using watchdog = idleWatchdog(undefined, 100, 'IDLE')
const iterator = { next: () => pending.promise }
void watchdog.next(iterator)
await expect(watchdog.next(iterator)).rejects.toThrow(/already outstanding/)
pending.resolve({ done: true, value: undefined })
})
})