feat: implement bounded LLM request recovery

This commit is contained in:
Tianyi Cui
2026-07-20 03:34:19 +08:00
parent 7cf966fc0e
commit 3b0b0cefeb
115 changed files with 3311 additions and 366 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera
| `welcome` | `ready.` | Banner printed before the first prompt |
| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown |
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects.
The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While an initial exact identity is pending, it buffers nonblank input until `agent/session-start` and observes live `agent-loop/config-start-failed`; a matching failure drops queued lines, reports the loss, and lets piped EOF finish instead of hanging. When a composed retry policy closes a failed step, the append-only transcript inserts an explicit discarded-attempt marker before later chunks; terminal request failure marks any preceding partial output discarded. The composing app must mount this front door before its config-created agent. Disposal closes readline and unregisters every listener/provider through Cordis effects.
```yaml
- id: stdio
+2
View File
@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^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-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
@@ -42,6 +43,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7"
+20 -6
View File
@@ -16,6 +16,7 @@ import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-loop'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
UserInteractionError,
@@ -119,6 +120,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// append order keeps `inReasoning` transitions deterministic across chunk and
// boundary events.
let inReasoning = false
const resetReasoning = (): void => {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
}
ctx.on('session/event', (session, event) => {
if (event.type === 'assistant/chunk') {
const { chunk } = event.data
@@ -135,22 +140,31 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
} else if (event.type === 'turn/start') {
const label = target?.session === session ? 'main' : session.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'llm/retry') {
resetReasoning()
output.write(
`\n [previous model attempt discarded; retry ${event.data.retry}/${event.data.maxRetries}`
+ ` in ${event.data.delayMs}ms: ${event.data.failure.message}]\n `,
)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
resetReasoning()
if (event.data.reason.kind === 'error') {
const message = 'failure' in event.data.reason
? event.data.reason.failure.message
: event.data.reason.message
output.write(`\n [model attempt failed; any partial output above is discarded: ${message}]`)
}
output.write('\n> ')
} else if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
resetReasoning()
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)
} else if (event.type === 'todo/write') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
resetReasoning()
const glyph = (status: string): string =>
status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]'
const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n')
+45
View File
@@ -290,6 +290,51 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('marks failed partial output at retry and terminal failure boundaries', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'partial' }))
ctx.emit('session/event', session, {
type: 'llm/retry',
seq: 1,
time: 0,
data: {
turn: 1,
step: 1,
retry: 1,
maxRetries: 2,
delayMs: 500,
failure: { message: 'backend busy', code: 'SERVER' },
},
})
ctx.emit('session/event', session, {
type: 'turn/end',
seq: 3,
time: 0,
data: { turn: 2, reason: { kind: 'error', step: 1, message: 'loop defect' } },
})
ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'also partial' }))
ctx.emit('session/event', session, {
type: 'turn/end',
seq: 2,
time: 0,
data: {
turn: 1,
reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } },
},
})
expect(out.text()).toContain(
'\x1B[2mpartial\x1B[0m\n [previous model attempt discarded; retry 1/2 in 500ms: backend busy]',
)
expect(out.text()).toContain(
'also partial\n [model attempt failed; any partial output above is discarded: still busy]\n> ',
)
expect(out.text()).toContain(
'[model attempt failed; any partial output above is discarded: loop defect]\n> ',
)
})
it('drops the target object on agent/disposed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
+3
View File
@@ -26,6 +26,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../user-interaction"
}