Merge pinned master into status bar projection

This commit is contained in:
Hypatia May
2026-07-31 09:28:52 +08:00
831 changed files with 23503 additions and 4750 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 影响

View File

@@ -44,7 +44,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, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import type { AssistantMessage, EpochHeader, RequestContext, Session, SessionId, TurnEndReason, TurnTrigger, UserMessage } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
@@ -54,6 +54,15 @@ type StepOutcome =
| { kind: 'completed'; continueTurn: boolean; concluded: boolean; maxTokens: boolean }
| { kind: 'request-failed'; error: RequestError; failure: LlmFailure; retryPolicy: ResolvedRetryPolicy | undefined }
/** 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.
@@ -615,19 +624,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 },
@@ -657,6 +668,7 @@ export class ReactLoopAgent implements Agent {
const header = canonicalHeader({
config,
...preparedCall === undefined ? {} : { adapterDefaults: preparedCall.adapterDefaults },
...system ? { system } : {},
...tools.length > 0 ? { tools } : {},
})

View File

@@ -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 },
})
}

View File

@@ -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' } }))

View File

@@ -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)

View File

@@ -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

View File

@@ -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()`.

View File

@@ -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()`

View File

@@ -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: 3c1f5531a2b301825959acd7be5e6cd115898bf0
README.zh.md: 28552c45d1de17f87f93948e272b6bee364494c2
README.md: 9c7d41901e6fb0133fff0e210260e5310a025f75
README.zh.md: ca1292289901a09b83f9b0a794fa4edc9754b1da

View File

@@ -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).
`request/context` records registration-bound metadata for the route a request resolved to, appended inside its step beside `request/header` and only when the provider, model, or capacity differs from the previous record. `session.requestContext()` folds the latest one incrementally, mirroring `requestHeader()`. Capacity stays OUT of `EpochHeader` on purpose: it is adapter metadata describing a route, not an input the request was built from, so it must not enter request reconstruction or header equality — a capacity change is not a header `change`. A route whose adapter advertises no capacity is still recorded with `contextWindow` absent, clearing any older known capacity.

View File

@@ -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)。
`request/context` 记录请求所解析到的路由的、绑定注册项的元数据,在其所属步骤内紧随 `request/header` 追加,且仅在提供方、模型或容量与上一条记录不同时追加。`session.requestContext()` 以增量方式归并最新一条,与 `requestHeader()` 保持一致。容量刻意不进入 `EpochHeader`:它是描述路由的适配器元数据,不是构建该请求所依据的输入,因此绝不可进入请求重建或请求头相等性判断:容量变化不构成请求头 `change`。适配器不公布容量的路由仍会被记录,但 `contextWindow` 字段缺失,从而清除较早的已知容量。

View File

@@ -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']

View File

@@ -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))

View File

@@ -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. */

View File

@@ -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)

View File

@@ -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({

View File

@@ -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/tools/README.md
README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e
README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2
README.md: e7f395f8c1d6417db856e590f5267cf6887e4d12
README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e

View File

@@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E
Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names:
- Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`.
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary.

View File

@@ -108,7 +108,7 @@ ctx.tools.register(defineTool({
工具可以选择拥有纯 `presentCall()``presentResult()` 呈现意图,使 UI 无需特殊处理工具名称:
- 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }``{ card: 'terminal', title, description?, cwd? }``{ card: 'diff', title, diffs, locations? }`
- 结果视图为 `{ card: 'generic', title?, content? }``{ card: 'terminal', title?, output?, exitCode?, signal? }``{ card: 'diff', title?, diffs }`
- 结果视图为 `{ card: 'generic', title?, content? }``{ card: 'terminal', title?, output?, exitCode?, signal? }``{ card: 'diff', title?, diffs }``{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)
返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash``dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。

View File

@@ -82,6 +82,10 @@ export type {
GenericResultView,
TerminalResultView,
DiffResultView,
WebResultView,
WebSearchResultView,
WebFetchResultView,
WebSource,
} from './presentation.ts'
declare module 'cordis' {

View File

@@ -125,7 +125,7 @@ export interface DiffCallView {
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
/**
* The default completed card: an optional replacement title and reformatted
@@ -176,3 +176,84 @@ export interface DiffResultView {
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}
/**
* One citeable source in a completed {@link WebSearchResultView}, the faithful
* projection of one web-search source. The presentation projection of `dsh-web`'s
* `WebSearchSource`: that seam type is the authoritative shape (core cannot depend
* on the web seam, so the two are declared separately and MUST evolve together).
* A web tool projects this shape through `output.presentationMeta` because the
* render text cannot losslessly carry it (see the web-result-card Agent Note); its
* `presentResult` reads it back.
*/
export interface WebSource {
/** The source URL. */
url: string
/** The source title, when the provider returned one. */
title?: string
/** A short excerpt or summary, when the provider returned one. */
snippet?: string
/** Publication/crawl timestamp as a provider-supplied ISO-8601 string, when present. */
publishedAt?: string
}
/**
* A completed web retrieval rendered as a structured card by a capable UI. Set
* by a web tool whose call retrieves from the web (`web_search`, `web_fetch`).
* One `kind`-tagged union carries both shapes because both are web retrieval and
* a UI renders them with one component family; a UI switches on `kind`. An
* incapable UI falls back to the raw `tool/result` content (this view carries no
* `content` copy — see the web-result-card Agent Note). This is the result-time
* analogue of the `web_search`/`web_fetch` calls' generic call views
* (`kind: 'search'`/`'fetch'`); those tools keep their generic pending card and
* add only this completed card.
*
* The `kind` field here is this union's own discriminant, NOT a
* {@link ToolCallKind}: the two values deliberately match the tools' pending
* `ToolCallKind` (`'search'`/`'fetch'`) so a call and its result read as one
* category, but a new arm is a union edit plus a consumer branch, not any
* arbitrary `ToolCallKind` value.
*/
export type WebResultView = WebSearchResultView | WebFetchResultView
/**
* The completed state of a `web_search` call: the structured sources the model
* cited, an optional provider answer, and whether the source list was cut to the
* result cap. A capable UI renders the sources as a citation list; a UI without
* the `web` capability falls back to the raw `tool/result` content.
*/
export interface WebSearchResultView {
card: 'web'
kind: 'search'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The faithful, structured sources — the field render text cannot losslessly carry. */
sources: WebSource[]
/** The provider-generated answer text, when any. */
answer?: string
/** True when the seam cut the source list to honor the result cap. */
truncated: boolean
}
/**
* The completed state of a `web_fetch` call: the fetched URL, its HTTP status,
* and whether the content was cut. The body itself is already markdown in the
* raw `tool/result` content, so this card carries only the retrieval summary and
* a UI without the `web` capability falls back to that content.
*/
export interface WebFetchResultView {
card: 'web'
kind: 'fetch'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The final URL after allowed redirects. */
url: string
/** HTTP status code of the fetched response. */
statusCode: number
/**
* True when the provider capped the decoded body, or the output cap or a
* pre-conversion source cut trimmed the rendered text (the effective
* truncation the model-facing text also reflects).
*/
truncated: boolean
}