Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md # docs/architecture.i18n.yaml # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # examples/headless-agent/tests/headless.snapshot.ts # packages/compact/compact-basic/tests/compact-loop-repro.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/loop.ts # packages/examples/agent-spine-demo/README.md # packages/llm/README.md # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm-retry/README.md # packages/llm/llm/README.md # packages/llm/llm/src/index.ts # packages/llm/llm/tests/service.spec.ts # packages/support/llm-replay/src/index.ts # packages/support/llm-replay/tests/llm-replay.spec.ts
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Helpers to write scripted responses tersely. */
|
||||
@@ -64,10 +64,25 @@ 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 resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||
})
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } 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'
|
||||
@@ -104,6 +104,168 @@ 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('keeps exact-model 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 async resolveModel(
|
||||
provider: string,
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
started.resolve(undefined)
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
reasoning: await 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 resolveModel(
|
||||
_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 exact-model 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 resolveModel(): 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 +463,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)
|
||||
|
||||
Reference in New Issue
Block a user