Merge remote-tracking branch 'origin/master' into codex/sandbox-policy-context
# Conflicts: # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # packages/core/agent-loop/src/agent.ts
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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 packages/core/agent-loop/README.md
|
||||
README.md: a1617a1ef871f61157e0d70a06d055168170dced
|
||||
README.zh.md: 6ba945a41e700331929dabb557802c14256921fb
|
||||
README.md: afc00f1ecdd225f22da46b95827259a50c719766
|
||||
README.zh.md: 63aaab3b5af32b9bad70d74fbd57c8bd62c2ef7d
|
||||
|
||||
@@ -65,7 +65,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. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; 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.
|
||||
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults 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 header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. 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 applies the same provenance rule when resuming.
|
||||
|
||||
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 other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的分片溯源(流没有分片时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;循环用其错误关闭失败轮次,并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
|
||||
|
||||
@@ -92,7 +92,7 @@ interface Config {
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmCallConfig, LlmFailure, Message, PreparedLlmCall, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import type { AssistantMessage, EpochHeader, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
@@ -83,6 +83,15 @@ function materializeRuntimeContext(session: Session, current: string): void {
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Remove adapter-derived values before plugins propose the next request config. */
|
||||
function requestProposal(header: EpochHeader): LlmCallConfig {
|
||||
if (header.adapterDefaults === undefined) return header.config
|
||||
const proposal = { ...header.config }
|
||||
if (header.adapterDefaults.reasoningEffort === true) delete proposal.reasoningEffort
|
||||
if (header.adapterDefaults.maxTokens === true) delete proposal.maxTokens
|
||||
return proposal
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent}: each `run()` owns one turn and repeats model
|
||||
* steps while tools or steering require another request.
|
||||
@@ -647,19 +656,21 @@ export class ReactLoopAgent implements Agent {
|
||||
): Promise<{ request: GenerateOptions; preparedCall?: PreparedLlmCall }> {
|
||||
const { session } = this
|
||||
|
||||
// A loop instance starts from its declared route, restoring only an opaque
|
||||
// effort owned by that exact model. Later steps fold the config it logged.
|
||||
const persistedConfig = session.requestHeader()?.config
|
||||
// A loop instance starts from its declared route, restoring only an explicit
|
||||
// effort owned by that exact model. Later steps re-resolve marked defaults.
|
||||
const persistedHeader = session.requestHeader()
|
||||
const persistedConfig = persistedHeader?.config
|
||||
const route = { provider: this.options.provider ?? '', model: this.options.model ?? '' }
|
||||
const reasoningEffort = persistedConfig?.provider === route.provider
|
||||
&& persistedConfig.model === route.model
|
||||
&& persistedHeader?.adapterDefaults?.reasoningEffort !== true
|
||||
? persistedConfig.reasoningEffort
|
||||
: undefined
|
||||
const maxTokens = this.options.maxTokens
|
||||
const seedConfig = deepFreeze(structuredClone(
|
||||
this.requestHeaderLogged
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion -- the instance logged the header it now folds
|
||||
? persistedConfig!
|
||||
? requestProposal(persistedHeader!)
|
||||
: {
|
||||
...route,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
@@ -689,6 +700,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
const header = canonicalHeader({
|
||||
config,
|
||||
...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
|
||||
...system ? { system } : {},
|
||||
...tools.length > 0 ? { tools } : {},
|
||||
})
|
||||
|
||||
@@ -67,6 +67,7 @@ export class MockAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[],
|
||||
private readonly reasoning?: LlmModelReasoningInfo,
|
||||
private readonly defaultMaxTokens?: number,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -80,6 +81,7 @@ export class MockAdapter extends LlmAdapter {
|
||||
id: model,
|
||||
name: model,
|
||||
...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
|
||||
...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function waitForIdle(context: Context, agent: Agent): Promise<void> {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
|
||||
it('every request after the first hits the provider prefix cache', async () => {
|
||||
ctx = await loopHarness()
|
||||
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
|
||||
// Turn 1: forces a tool call → at least two steps (two model requests).
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }], source: { kind: 'user' } }))
|
||||
|
||||
@@ -18,6 +18,13 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
return harnessRoutes([['mock', adapter]], persona)
|
||||
}
|
||||
|
||||
async function harnessRoutes(
|
||||
adapters: readonly (readonly [provider: string, adapter: MockAdapter])[],
|
||||
persona = 'stable base',
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -25,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
for (const [provider, adapter] of adapters) ctx.llm.registerAdapter([provider], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -134,6 +141,10 @@ describe('request stability across the loop', () => {
|
||||
ReasoningEffortId('high'),
|
||||
ReasoningEffortId('max'),
|
||||
])
|
||||
expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([
|
||||
{ reasoningEffort: true },
|
||||
undefined,
|
||||
])
|
||||
expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change'])
|
||||
|
||||
for (const [model, effort] of [
|
||||
@@ -158,6 +169,88 @@ describe('request stability across the loop', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('logs an adapter-owned maxTokens default before dispatch', async () => {
|
||||
const adapter = new MockAdapter([textResponse('bounded')], undefined, 256_000)
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens'), {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
})
|
||||
|
||||
send(agent, 'use the adapter output limit')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests[0]?.maxTokens).toBe(256_000)
|
||||
const header = agent.session.events.find(event => event.type === 'request/header')
|
||||
expect(header?.type === 'request/header' && header.data.header.config.maxTokens).toBe(256_000)
|
||||
expect(header?.type === 'request/header' && header.data.header.adapterDefaults)
|
||||
.toEqual({ maxTokens: true })
|
||||
})
|
||||
|
||||
it('rematerializes the selected adapter maxTokens default after a provider switch', async () => {
|
||||
const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000)
|
||||
const other = new MockAdapter([textResponse('other')], undefined, 8_192)
|
||||
const ctx = await harnessRoutes([
|
||||
['deepseek', deepseek],
|
||||
['other', other],
|
||||
])
|
||||
const agent = ctx.agentLoop.create(SessionId('adapter-max-tokens-switch'), {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-model',
|
||||
})
|
||||
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => {
|
||||
const config = await next()
|
||||
return turn === 2
|
||||
? { ...config, provider: 'other', model: 'other-model' }
|
||||
: config
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(deepseek.requests[0]?.maxTokens).toBe(256_000)
|
||||
expect(other.requests[0]?.maxTokens).toBe(8_192)
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([256_000, 8_192])
|
||||
expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([
|
||||
{ maxTokens: true },
|
||||
{ maxTokens: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves an explicit agent maxTokens cap across a provider switch', async () => {
|
||||
const deepseek = new MockAdapter([textResponse('deepseek')], undefined, 256_000)
|
||||
const other = new MockAdapter([textResponse('other')], undefined, 8_192)
|
||||
const ctx = await harnessRoutes([
|
||||
['deepseek', deepseek],
|
||||
['other', other],
|
||||
])
|
||||
const agent = ctx.agentLoop.create(SessionId('explicit-max-tokens-switch'), {
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-model',
|
||||
maxTokens: 4_096,
|
||||
})
|
||||
ctx.on('agent/request', async (_agent, turn, _step, _signal, next) => {
|
||||
const config = await next()
|
||||
return turn === 2
|
||||
? { ...config, provider: 'other', model: 'other-model' }
|
||||
: config
|
||||
})
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(deepseek.requests[0]?.maxTokens).toBe(4_096)
|
||||
expect(other.requests[0]?.maxTokens).toBe(4_096)
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.map(event => event.data.header.config.maxTokens)).toEqual([4_096, 4_096])
|
||||
expect(headers.map(event => event.data.header.adapterDefaults)).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
it('keeps exact-model resolution, request logging, and dispatch on one adapter registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/core/agent/README.md
|
||||
README.md: 6bd5279ace93b6d2569833be6f102c854105c2eb
|
||||
README.zh.md: cdbb0c0b70124e0037a0f7a7a03ddedf1e3b78d3
|
||||
README.md: 0b55381ee484eb0044b1ebb88cfd137a987a9b3e
|
||||
README.zh.md: 9fb0100fb2c212627b8c14dad0aada0f73d11c10
|
||||
|
||||
@@ -14,7 +14,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
|
||||
|
||||
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.
|
||||
|
||||
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop records the cap in the request header and applies it to each conversation-model request; callers that omit it leave provider defaults in control.
|
||||
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control.
|
||||
|
||||
- `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()`.
|
||||
|
||||
@@ -14,7 +14,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
|
||||
|
||||
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent)。通过它注册工具/段/变量/监听器,只对该 agent 生效,并在 dispose(资源释放)时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方/模型/推理(reasoning)强度选择,将路由应用到提示词变量,并将完整目标应用到一个步骤的请求路由;如果没有选定推理强度,则会清除继承的推理强度,使该目标使用适配器/提供方默认值。`CreateAgentOptions.setup(agentCtx)` 和 `ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时,组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
|
||||
|
||||
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会把该上限记录到请求 header,并应用到每次对话模型请求;调用方省略时由提供方默认值控制。
|
||||
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header,并应用到每次对话模型请求;显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。
|
||||
|
||||
- `ctx.agents.register(agent: Agent): () => void`:记录一个 **已经构造完成** 的 agent。随调用 fiber dispose。
|
||||
- 高级有序生命周期:`enter(agent, owner): () => void` 强制 `agent.id === agent.session.id`,执行权威 ID 冲突检查,并在不通知的情况下插入;`owner` 显式记录实时创建方 agent 关系(根 agent 为 `undefined`),与持久会话谱系无关。`announce(agent)` 恰好发出一次 `agent/created`。创建监听器同步请求的 detach 会延后到该次分发结束;每次 detach 都会检查捕获的条目对象,因此陈旧能力无法删除后续使用同一 ID 的替代项。异步工厂使用这一拆分;普通插件使用 `register()`。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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 packages/core/session/README.md
|
||||
README.md: 9fa6cf5251d480be9d2388bdb32393fa2827168c
|
||||
README.zh.md: 5170d5d4b362a0f19ff6953e1636adef9aa7e8b8
|
||||
README.md: 132e627387aff54381d10e87a52dc1440d0a5b56
|
||||
README.zh.md: edf94f765b81a81dfe39662e967cd6dfdca140fa
|
||||
|
||||
@@ -64,7 +64,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
`request/header` records a full canonical snapshot of the non-history request envelope with reason `initial`, `resume`, or `change`. Its optional `adapterDefaults` map marks effective `reasoningEffort` or `maxTokens` values materialized by exact-model resolution, allowing the next request proposal to distinguish them from explicit conversation settings. `foldRequestHeader()` selects the latest snapshot; legacy delta events and the removed `fallback` reason are rejected. See the [reconstructable-requests Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
|
||||
|
||||
A `user/message` stores the complete `UserMessage` directly, including the identity created before routing or prompt admission. It renders its `content` verbatim whether it is a direct human prompt, a synthetic injection, or an admitted goal round; its typed `source` is the only channel that tells them apart and carries any domain-specific durable facts. `assistant/message`, `tool/result`, and `steering/message` likewise store complete message values. Turn execution remains enclosed by `turn/start` and `turn/end`, while an idle injection may append and flush a `user/message` between turns without running the model.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
### 请求头重建(`request-header.ts`)
|
||||
|
||||
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
`request/header` 记录非历史请求封装的完整规范快照,其原因为 `initial`、`resume` 或 `change`。其可选 `adapterDefaults` 映射会标记由精确模型解析填入的生效 `reasoningEffort` 或 `maxTokens` 值,使下一次请求提议能够将它们与显式对话设置区分开。`foldRequestHeader()` 选择最新快照;旧版增量事件和已移除的 `fallback` 原因会被拒绝。详见[可重建请求 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
|
||||
|
||||
`user/message` 会直接存储完整的 `UserMessage`,其中包括路由或提示词准入前创建的标识。无论它是直接人类提示词、合成注入,还是已准入的 Goal Round,都会原样呈现其 `content`;带类型的 `source` 是区分三者的唯一通道,并携带各领域专有的持久事实。`assistant/message`、`tool/result` 和 steering(中途引导)对应的 `steering/message` 也会存储完整的消息值。轮次执行仍由 `turn/start` 与 `turn/end` 包围,而空闲注入可以在轮次之间追加并刷新一条 `user/message`,无需运行模型。
|
||||
|
||||
|
||||
@@ -201,13 +201,18 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
: undefined
|
||||
if (event['type'] === 'request/header') {
|
||||
const header = record?.['header']
|
||||
const config = typeof header === 'object' && header !== null ? (header as Record<string, unknown>)['config'] : undefined
|
||||
const headerRecord = typeof header === 'object' && header !== null && !Array.isArray(header)
|
||||
? header as Record<string, unknown>
|
||||
: undefined
|
||||
const config = headerRecord?.['config']
|
||||
if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`)
|
||||
const reasoningEffort = (config as Record<string, unknown>)['reasoningEffort']
|
||||
const configRecord = config as Record<string, unknown>
|
||||
const reasoningEffort = configRecord['reasoningEffort']
|
||||
if (reasoningEffort !== undefined
|
||||
&& (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) {
|
||||
throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`)
|
||||
}
|
||||
assertAdapterDefaults(headerRecord?.['adapterDefaults'], configRecord, index)
|
||||
}
|
||||
const type = event['type']
|
||||
if (type !== 'user/message' && type !== 'assistant/message'
|
||||
@@ -215,6 +220,26 @@ function assertCurrentLlmShape(event: Record<string, unknown>, index: number): v
|
||||
assertMessageEventShape(event, `seed ${type} at index ${index}`)
|
||||
}
|
||||
|
||||
/** Validate adapter-default provenance imported from a durable request header. */
|
||||
function assertAdapterDefaults(
|
||||
value: unknown,
|
||||
config: Record<string, unknown>,
|
||||
index: number,
|
||||
): void {
|
||||
if (value === undefined) return
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
|
||||
}
|
||||
const defaults = value as Record<string, unknown>
|
||||
const allowed = new Set(['reasoningEffort', 'maxTokens'])
|
||||
if (Object.keys(defaults).some(key => !allowed.has(key))
|
||||
|| Object.values(defaults).some(marker => marker !== true)
|
||||
|| defaults['reasoningEffort'] === true && config['reasoningEffort'] === undefined
|
||||
|| defaults['maxTokens'] === true && config['maxTokens'] === undefined) {
|
||||
throw new Error(`seed request/header at index ${index} has invalid adapterDefaults`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate only the event-specific invariants needed to safely replay a message. */
|
||||
function assertMessageEventShape(event: Record<string, unknown>, subject: string): void {
|
||||
const type = event['type']
|
||||
|
||||
@@ -19,8 +19,12 @@ import type { EpochHeader, SessionEvent } from './types.ts'
|
||||
* @returns the canonical header.
|
||||
*/
|
||||
export function canonicalHeader(header: EpochHeader): EpochHeader {
|
||||
const adapterDefaults = header.adapterDefaults
|
||||
return {
|
||||
config: header.config,
|
||||
...adapterDefaults?.reasoningEffort === true || adapterDefaults?.maxTokens === true
|
||||
? { adapterDefaults }
|
||||
: {},
|
||||
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
||||
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
||||
}
|
||||
@@ -38,7 +42,12 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
|
||||
* @returns whether config, system, and tools all match.
|
||||
*/
|
||||
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
||||
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
||||
if (
|
||||
!callConfigEquals(a.config, b.config)
|
||||
|| a.adapterDefaults?.reasoningEffort !== b.adapterDefaults?.reasoningEffort
|
||||
|| a.adapterDefaults?.maxTokens !== b.adapterDefaults?.maxTokens
|
||||
|| a.system !== b.system
|
||||
) return false
|
||||
const at = a.tools ?? []
|
||||
const bt = b.tools ?? []
|
||||
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AssistantMessage,
|
||||
CallId,
|
||||
LlmCallConfig,
|
||||
LlmCallConfigAdapterDefaults,
|
||||
LlmFailure,
|
||||
MessageSource,
|
||||
StreamChunk,
|
||||
@@ -163,6 +164,8 @@ export interface TodoItem {
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
|
||||
config: LlmCallConfig
|
||||
/** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
|
||||
adapterDefaults?: LlmCallConfigAdapterDefaults
|
||||
/** Rendered system prompt text; absent for a system-less request. */
|
||||
system?: string
|
||||
/** Assembled tool schemas; absent for a tool-less request. */
|
||||
|
||||
@@ -14,9 +14,24 @@ function tool(name: string, description = 'd'): ToolSchema {
|
||||
|
||||
describe('canonicalHeader', () => {
|
||||
it('normalizes empty optional fields to absence and preserves populated fields', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
|
||||
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')] })
|
||||
expect(canonicalHeader({
|
||||
config: CONFIG,
|
||||
adapterDefaults: {},
|
||||
system: '',
|
||||
tools: [],
|
||||
})).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({
|
||||
config: { ...CONFIG, maxTokens: 256_000 },
|
||||
adapterDefaults: { maxTokens: true },
|
||||
system: 's',
|
||||
tools: [tool('a')],
|
||||
})
|
||||
expect(full).toEqual({
|
||||
config: { ...CONFIG, maxTokens: 256_000 },
|
||||
adapterDefaults: { maxTokens: true },
|
||||
system: 's',
|
||||
tools: [tool('a')],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +45,14 @@ describe('headerEquals', () => {
|
||||
...base,
|
||||
config: { ...base.config, reasoningEffort: ReasoningEffortId('high') },
|
||||
})).toBe(false)
|
||||
expect(headerEquals(
|
||||
{ ...base, config: { ...base.config, maxTokens: 256_000 } },
|
||||
{
|
||||
...base,
|
||||
config: { ...base.config, maxTokens: 256_000 },
|
||||
adapterDefaults: { maxTokens: true },
|
||||
},
|
||||
)).toBe(false)
|
||||
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
|
||||
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
|
||||
|
||||
@@ -403,6 +403,40 @@ describe('Session', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('round-trips adapter-default provenance and rejects invalid durable values', () => {
|
||||
const valid = {
|
||||
type: 'request/header',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
header: {
|
||||
config: {
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
maxTokens: 256_000,
|
||||
},
|
||||
adapterDefaults: { maxTokens: true },
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('adapter-defaults'), [valid]).events[0]).toEqual(valid)
|
||||
|
||||
for (const adapterDefaults of [
|
||||
null,
|
||||
[],
|
||||
{ unknown: true },
|
||||
{ maxTokens: false },
|
||||
{ reasoningEffort: true },
|
||||
]) {
|
||||
const invalid = structuredClone(valid) as unknown as SessionEvent
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
invalid.data.header.adapterDefaults = adapterDefaults as never
|
||||
expect(() => new Session(SessionId('invalid-adapter-defaults'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has invalid adapterDefaults')
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates the log from mutation through a derived message (append-only contract)', () => {
|
||||
const session = new Session(SessionId('s4'))
|
||||
session.append('user/message', createUserMessage({
|
||||
|
||||
Reference in New Issue
Block a user