fix(llm): bind reasoning resolution to adapter lifecycle
This commit is contained in:
@@ -60,7 +60,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.
|
||||
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.resolveCallConfig()` to validate any adapter-owned reasoning effort and materialize its configured default. The effective config is logged in the full `request/header` before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
|
||||
|
||||
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`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor, AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -689,8 +689,10 @@ async function runStep(
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
}
|
||||
let config: LlmCallConfig
|
||||
let preparedCall: PreparedLlmCall | undefined
|
||||
try {
|
||||
config = await ctx.llm.resolveCallConfig(proposedConfig)
|
||||
preparedCall = await ctx.llm.prepareCall(proposedConfig, signal)
|
||||
config = preparedCall.config
|
||||
} catch (error: unknown) {
|
||||
// A waterfall listener may own and short-circuit a route with no adapter.
|
||||
// Terminal dispatch still raises NO_ADAPTER when no listener handles it.
|
||||
@@ -731,7 +733,7 @@ async function runStep(
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = ctx.llm.stream(request)
|
||||
const stream = preparedCall?.stream(request) ?? ctx.llm.stream(request)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -152,6 +152,88 @@ describe('request stability across the loop', () => {
|
||||
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
|
||||
})
|
||||
|
||||
it('keeps reasoning resolution, request logging, and dispatch on one adapter registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: 'stable base' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const reasoning = Promise.withResolvers<LlmModelReasoningInfo>()
|
||||
const first = new class extends MockAdapter {
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
_signal?: AbortSignal,
|
||||
): typeof reasoning.promise {
|
||||
started.resolve(undefined)
|
||||
return reasoning.promise
|
||||
}
|
||||
}([textResponse('first')])
|
||||
const second = new MockAdapter([textResponse('second')], {
|
||||
efforts: [{ id: ReasoningEffortId('max'), name: 'Max' }],
|
||||
defaultEffort: ReasoningEffortId('max'),
|
||||
})
|
||||
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
|
||||
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await started.promise
|
||||
disposeFirst()
|
||||
ctx.llm.registerAdapter(['mock'], second)
|
||||
reasoning.resolve({
|
||||
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
|
||||
defaultEffort: ReasoningEffortId('high'),
|
||||
})
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(first.requests.map(request => request.reasoningEffort)).toEqual([
|
||||
ReasoningEffortId('high'),
|
||||
])
|
||||
expect(second.requests).toHaveLength(0)
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
|
||||
})
|
||||
|
||||
it('aborts a blocked reasoning lookup before quiescent disposal completes', async () => {
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const adapter = new class extends MockAdapter {
|
||||
override resolveModelReasoning(
|
||||
_provider: string,
|
||||
_model: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<never> {
|
||||
if (signal === undefined) return Promise.reject(new Error('missing reasoning signal'))
|
||||
started.resolve(signal)
|
||||
return new Promise((_resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(signal.reason instanceof Error ? signal.reason : new Error('reasoning aborted'))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
}([])
|
||||
const ctx = await harness(adapter)
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId('reasoning-dispose'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
send(handle.agent, 'go')
|
||||
const signal = await started.promise
|
||||
await handle.dispose()
|
||||
|
||||
expect(signal.aborted).toBe(true)
|
||||
expect(handle.agent.status).toBe('disposed')
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(handle.agent.session.events.some(event => event.type === 'request/header')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['plain error', 'LLM error'] as const)(
|
||||
'does not swallow a %s from reasoning resolution',
|
||||
async (kind) => {
|
||||
|
||||
Reference in New Issue
Block a user