feat: implement bounded LLM request recovery
This commit is contained in:
@@ -30,7 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, and replays user, assistant, and tool events |
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `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" |
|
||||
@@ -47,6 +47,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.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^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",
|
||||
@@ -50,6 +51,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:^",
|
||||
|
||||
@@ -44,6 +44,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 { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
@@ -481,7 +482,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))
|
||||
}
|
||||
@@ -1035,6 +1036,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
|
||||
@@ -1081,6 +1083,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
|
||||
@@ -1108,7 +1117,16 @@ 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') return
|
||||
const message = 'failure' in event.data.reason
|
||||
? event.data.reason.failure.message
|
||||
: event.data.reason.message
|
||||
const text = `\n\n[Model attempt failed; any partial output above is discarded: ${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
|
||||
|
||||
@@ -100,7 +100,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' } } },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,33 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('marks retry and terminal failure boundaries in the append-only update stream', () => {
|
||||
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',
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
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([{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user