Merge remote-tracking branch 'origin/master' into feature/subagent-policy-inheritance
# Conflicts: # docs/cordis-catalog/services.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 3acf4d3828291d5f318306f2652e0d920c695675
|
||||
README.zh.md: 11ff8318813b1abd096e1a3549d389cbba88f12b
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
|
||||
README.md: 6ad982ffff7b73e16ee39f9c29da787e37547de4
|
||||
README.zh.md: c72df198774f0f8009cc5ab43932e69187757745
|
||||
|
||||
@@ -62,6 +62,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.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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -62,6 +62,8 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。成功的 `agent/step-result` 存储其转换后内容;被拒绝的结果会先记录空内容,再继续抛出原始失败。该锚点保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时保留用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败,以及带内的终止错误或中止结束原因,才进入 `agent/request-error`;中间件、结果处理、工具和 `agent/post-step` 仍属于普通轮次失败。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实和不可变的先前失败。重试会在新的编号步骤中根据持久日志重建;成功会清除连续失败历史;耗尽后只在 `turn/end` 上记录一次结构化失败。AgentLoop 私下拥有一个取消持有者,其显式信号覆盖提示词策略、组装、每个步骤、模型与工具工作、恢复、continuation 和终止停止;它会在发布 `turn/end` 前立即退役该持有者,而驱动器可以在持久性 flush 期间继续保持 `running`。有效的 `cancel()` 会先发出仅存在于运行时的类型化 `user | parent` 原因,再清除待处理工作,并以协作方式中止该持有者;通知失败无法 veto 取消,通知观察方排队的工作会被清除,之后由中止观察方排队的工作属于下一轮次,空闲取消则不发出任何内容。持久 `turn/end` 仍使用粗粒度的 `aborted`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。释放会在终止分类中胜出;忽略信号的工作必须先结算,系统才能完全停稳。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。终止 continuation 的停止决定在轮次关闭和持久性 flush 期间始终具有权威性。
|
||||
|
||||
在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用、drain 已启动的结果,然后在轮次通过普通中止路径关闭前,drain 已接纳的批次上下文。
|
||||
|
||||
@@ -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'
|
||||
@@ -661,19 +661,45 @@ 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
|
||||
let preparedCall: PreparedLlmCall | undefined
|
||||
try {
|
||||
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.
|
||||
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!
|
||||
@@ -691,6 +717,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 } : {},
|
||||
@@ -704,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: bbae91ff2f497208f1ce620e162c27968d666ac3
|
||||
README.zh.md: 95367b35a546c68491b9623daf45a54cd63f2731
|
||||
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
|
||||
README.md: a65c53b3e4edf2031f286d7d172e73357c66ff1e
|
||||
README.zh.md: 05da8a0a0d3ad2ed879b72e20eae1c4efaf711c8
|
||||
|
||||
@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
|
||||
|
||||
### Public API
|
||||
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
|
||||
- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`.
|
||||
|
||||
@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
|
||||
|
||||
### 公开 API
|
||||
|
||||
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型选择,并将该对同时应用到一个步骤的提示词变量与请求路由。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
|
||||
带作用域的注册表层:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber 释放。
|
||||
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
/**
|
||||
* Agent-scoped provider/model target snapshot shared by interactive front doors.
|
||||
* Agent-scoped LLM target snapshot shared by interactive front doors.
|
||||
* @module @deepseek-ai/dsh-agent/llm-target
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Complete provider/model route selected for one live agent. */
|
||||
/** Complete provider/model route and optional reasoning effort selected for one live agent. */
|
||||
export interface AgentLlmTarget {
|
||||
/** Registered provider route. */
|
||||
provider: string
|
||||
/** Provider-owned model id. */
|
||||
model: string
|
||||
/** Adapter-owned reasoning effort, or provider/default behavior when absent. */
|
||||
reasoningEffort?: ReasoningEffortId
|
||||
}
|
||||
|
||||
/** Mutable selection plus the target captured for the current step. */
|
||||
@@ -24,9 +26,11 @@ export interface AgentLlmTargetRef {
|
||||
|
||||
/**
|
||||
* Couple one mutable target to agent-scoped prompt assembly and request routing.
|
||||
* Prompt assembly snapshots the selected pair before delegating, then applies
|
||||
* both prompt variables and request config to that snapshot so a concurrent
|
||||
* switch takes effect on a later step instead of splitting the two surfaces.
|
||||
* Prompt assembly snapshots the selected target before delegating, then applies
|
||||
* its route to prompt variables and its route/effort to request config so a
|
||||
* concurrent switch takes effect on a later step instead of splitting the two
|
||||
* surfaces. An absent selected effort clears any inherited effort so a model
|
||||
* switch can restore that target's provider/default behavior.
|
||||
*
|
||||
* @param agentCtx - The target agent's scoped context.
|
||||
* @param target - Mutable selection owned by the calling front door.
|
||||
@@ -52,10 +56,15 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR
|
||||
async (_agent, _turn, _step, _config, _signal, next): Promise<LlmCallConfig> => {
|
||||
const resolved = await next()
|
||||
const selected = target.assembled
|
||||
return selected === undefined ? resolved : {
|
||||
...resolved,
|
||||
if (selected === undefined) return resolved
|
||||
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved
|
||||
return {
|
||||
...withoutInheritedEffort,
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
...selected.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: selected.reasoningEffort },
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type Agent,
|
||||
type AgentLlmTargetRef,
|
||||
} from '../src/index.ts'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
describe('installAgentLlmTarget()', () => {
|
||||
it('snapshots prompt variables and request routing together, then disposes both listeners', async () => {
|
||||
@@ -24,16 +24,31 @@ describe('installAgentLlmTarget()', () => {
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toBe(seed)
|
||||
|
||||
target.current = { provider: 'alpha', model: 'a1' }
|
||||
target.current = {
|
||||
provider: 'alpha',
|
||||
model: 'a1',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
}
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' })
|
||||
target.current = { provider: 'beta', model: 'b1' }
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed),
|
||||
)).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 })
|
||||
)).resolves.toEqual({
|
||||
provider: 'alpha',
|
||||
model: 'a1',
|
||||
reasoningEffort: ReasoningEffortId('high'),
|
||||
temperature: 0.2,
|
||||
})
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
const inherited: LlmCallConfig = {
|
||||
provider: 'alpha',
|
||||
model: 'a1',
|
||||
reasoningEffort: ReasoningEffortId('max'),
|
||||
temperature: 0.2,
|
||||
}
|
||||
await expect(agentEvents(ctx, agent).waterfall(
|
||||
'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed),
|
||||
'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited),
|
||||
)).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 })
|
||||
|
||||
dispose()
|
||||
|
||||
@@ -191,6 +191,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`)
|
||||
|
||||
@@ -176,7 +176,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 injected-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('user/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' })
|
||||
|
||||
Reference in New Issue
Block a user