feat(llm): add model-specific reasoning effort controls

This commit is contained in:
Yichen Jiang
2026-07-25 07:47:51 +08:00
parent 65d29da8a1
commit 8372340f9c
50 changed files with 1046 additions and 88 deletions

View File

@@ -60,6 +60,8 @@ 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.
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.
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.

View File

@@ -621,19 +621,43 @@ async function runStep(
// Seed the first request from agent options and later requests from the logged header;
// detach and freeze so listeners must return an attributable replacement.
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: { provider: options.provider ?? '', model: options.model ?? '' }))
const loggedConfig = session.requestHeader()?.config
const initialProvider = options.provider ?? ''
const initialModel = options.model ?? ''
const initialConfig: LlmCallConfig = {
provider: initialProvider,
model: initialModel,
...loggedConfig?.provider === initialProvider
&& loggedConfig.model === initialModel
&& loggedConfig.reasoningEffort !== undefined
? { reasoningEffort: loggedConfig.reasoningEffort }
: {},
}
const seedConfig: LlmCallConfig = deepFreeze(structuredClone(
transmission.loggedHeader
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log
? session.requestHeader()!.config
: initialConfig,
))
// Listener replacements are recorded in the request header before dispatch.
const config = await events.waterfall(
const proposedConfig = await events.waterfall(
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
)
interruptionCheckpoint(signal)
if (!config.provider || !config.model) {
if (!proposedConfig.provider || !proposedConfig.model) {
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
try {
config = await ctx.llm.resolveCallConfig(proposedConfig)
} 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.
if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error
config = proposedConfig
}
interruptionCheckpoint(signal)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
@@ -651,6 +675,9 @@ async function runStep(
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
...header.config.reasoningEffort !== undefined
? { reasoningEffort: header.config.reasoningEffort }
: {},
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},

View File

@@ -1,4 +1,4 @@
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
/** Helpers to write scripted responses tersely. */
@@ -64,10 +64,20 @@ export function toolCallResponse(rawCallId: string, name: string, args: object,
export class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) {
constructor(
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
private readonly reasoning?: LlmModelReasoningInfo,
) {
super()
}
override resolveModelReasoning(
_provider: string,
_model: string,
): Promise<LlmModelReasoningInfo | undefined> {
return Promise.resolve(this.reasoning)
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()

View File

@@ -7,7 +7,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -104,6 +104,81 @@ describe('request stability across the loop', () => {
expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!)
})
it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => {
const reasoning = {
efforts: [
{ id: ReasoningEffortId('high'), name: 'High' },
{ id: ReasoningEffortId('max'), name: 'Max' },
],
defaultEffort: ReasoningEffortId('high'),
}
const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning)
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => {
const config = await next()
return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config
})
send(agent, 'first')
await waitForIdle(ctx, agent)
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([
ReasoningEffortId('high'),
ReasoningEffortId('max'),
])
expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
const resumedAdapter = new MockAdapter([textResponse('three')], reasoning)
const resumedCtx = await harness(resumedAdapter)
const resumedHandle = await resumedCtx.agents.create({
sessionId: SessionId('effort-resumed'),
seed: structuredClone(agent.session.events),
agentOptions: { provider: 'mock', model: 'mock' },
})
send(resumedHandle.agent, 'third')
await waitForIdle(resumedCtx, resumedHandle.agent)
expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max'))
const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header')
expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max'))
expect(resumedHeaders.at(-1)?.data.reason).toBe('resume')
})
it.each(['plain error', 'LLM error'] as const)(
'does not swallow a %s from reasoning resolution',
async (kind) => {
const failure = kind === 'plain error'
? new Error('reasoning metadata failed')
: new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT')
const adapter = new class extends MockAdapter {
override resolveModelReasoning(): Promise<never> {
return Promise.reject(failure)
}
}([])
const ctx = await harness(adapter)
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), {
provider: 'mock',
model: 'mock',
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toContain(failure)
expect(adapter.requests).toHaveLength(0)
},
)
it('a compaction replace rewrites the resend, and the log explains it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
@@ -301,6 +376,7 @@ describe('request stability across the loop', () => {
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!
expect(request.model).toBe(header.config.model)
expect(request.reasoningEffort).toBe(header.config.reasoningEffort)
expect(request.system).toEqual(header.system)
expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? []))
expect(request.temperature).toBe(header.config.temperature)

View File

@@ -181,6 +181,11 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
const header = record['header']
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
if (reasoningEffort !== undefined
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
}
}
if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) {
throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`)

View File

@@ -156,7 +156,7 @@ export interface TodoItem {
* canonical empty optional fields are absent.
*/
export interface EpochHeader {
/** The conversation's call configuration (provider, model, and sampling scalars). */
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
/** Rendered system prompt text; absent for a system-less request. */
system?: string

View File

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
const CONFIG = { provider: 'mock', model: 'm' }
@@ -29,6 +30,10 @@ describe('headerEquals', () => {
it('compares every canonical field and preserves tool order', () => {
expect(headerEquals(base, structuredClone(base))).toBe(true)
expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false)
expect(headerEquals(base, {
...base,
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
})).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
displayPromptContent,
findLastMessageTurnEnd,
@@ -118,6 +118,11 @@ describe('Session', () => {
})
it('renders context and steering messages as plain user content', () => {
expect(displayPromptContent({
content: [{ type: 'text', text: 'plain prompt' }],
source: { kind: 'user' },
})).toEqual([{ type: 'text', text: 'plain prompt' }])
const session = new Session(SessionId('s2'))
session.append('context/message', {
content: [{ type: 'text', text: 'file changed: a.ts' }],
@@ -228,6 +233,35 @@ describe('Session', () => {
.toEqual([unrelatedPrimitiveData])
})
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
const valid = {
type: 'request/header',
seq: 0,
time: 1,
data: {
header: {
config: {
provider: 'mock',
model: 'model',
reasoningEffort: ReasoningEffortId('adapter-owned'),
},
},
reason: 'initial',
},
} as const
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
.toEqual(valid)
for (const reasoningEffort of ['', 1]) {
const invalid = structuredClone(valid) as unknown as SessionEvent
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
const config = invalid.data.header.config as unknown as Record<string, unknown>
config.reasoningEffort = reasoningEffort
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
}
})
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })