refactor(token-meter): make context occupancy durable projection state
Replace the transient `session/model-request` mux frame with ordinary durable session state. Occupancy now rides two last-wins projection fields instead of a non-replayable frame that needed removal tombstones and cross-stream fencing. The frame was the only non-replayable class on the mux stream. Because host and mux are independent SSE streams with no cross-stream order, a request emitted before a removal could arrive after `host/session-removed`, and a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. Fixing that needed a lifecycle generation on every frame; the frame itself was the problem. Removed: the `session/model-request` frame and schema, the `agent/model-request` core event, the ApiProxy measurement point, the client-side telemetry map and removal tombstone, and the synthetic `cancelled` open error used to signal reconnect through the error channel. Added: `request/context`, a log-only session event recording the registration-bound capacity of the route a request resolved to, appended beside `request/header` from the lookup that already prepared the call and skipped when the route is unchanged. Capacity stays out of `EpochHeader` because it is adapter metadata about a route, not an input the request was built from, so it must not join request reconstruction or header equality. The `contextPressure` projection pairs the newest provider-reported prompt size with the newest recorded capacity. The two are deliberately not one atomic request observation: switching models can pair a fresh capacity with the prior route's pressure until the next request reports usage. The figure is a user-facing reference, and this matches how the TUI status line has always computed occupancy.
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: ffdce143c0023bab22efc49fb1272377bf0bfdcf
|
||||
README.zh.md: 9dd982785590d1de6864ede8f071d3aa884a2827
|
||||
README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76
|
||||
README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91
|
||||
|
||||
@@ -63,7 +63,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, materialize its configured default, and retain available context metadata from that same exact-model lookup 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. Once the final provider/model is fixed and the outer `llm/stream` call returns a handle, the loop emits one contained live `agent/model-request` notification with turn, step, route, and optional capacity copied from that same prepared call. This is an observed Agent-loop attempt, not proof of provider I/O: preparation or a synchronous outer waterfall failure emits nothing, while a short-circuit handle or later lazy adapter construction, failure, or abortion still counts. 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 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 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.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ interface Config {
|
||||
|
||||
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
|
||||
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度、填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。最终提供方/模型确定且外层 `llm/stream` 调用返回句柄后,循环会发出一条实时 `agent/model-request` 通知,并收容该通知的失败;通知中包含轮次、步骤、路由,以及从同一次准备完成的调用中复制的可选容量。这是 AgentLoop 观察到的一次尝试,并不能证明提供方 I/O 已开始:准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知,而短路句柄或之后的惰性适配器构造、失败或中止仍会计入。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的推理(reasoning)强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID,并单独解析新模型。
|
||||
|
||||
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `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)规定生命周期与竞态契约。
|
||||
|
||||
@@ -90,7 +90,7 @@ interface Config {
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall 可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
每个步骤都会再次计入系统文本与 schema。逐 agent 作用域决定贡献,而权威组装 waterfall(瀑布式事件)可以改变最终请求,并使其监听器负责保持协议连贯。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -489,20 +489,6 @@ export class ReactLoopAgent implements Agent {
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
|
||||
emitAgentEvent(
|
||||
this.loopCtx,
|
||||
this,
|
||||
'agent/model-request',
|
||||
turn,
|
||||
step,
|
||||
{
|
||||
provider: request.provider,
|
||||
model: request.model,
|
||||
...preparedCall?.context === undefined
|
||||
? {}
|
||||
: { contextWindow: preparedCall.context.contextWindow },
|
||||
},
|
||||
)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
signal.throwIfAborted()
|
||||
@@ -640,6 +626,23 @@ export class ReactLoopAgent implements Agent {
|
||||
session.append('request/header', { header, reason: 'change' })
|
||||
}
|
||||
|
||||
// Capacity of the route this request resolved to, recorded from the same
|
||||
// registration-bound lookup that prepared the call (no second resolve).
|
||||
// Deduplicated against the last record: an unchanged route logs nothing.
|
||||
const contextWindow = preparedCall?.context?.contextWindow
|
||||
if (contextWindow !== undefined) {
|
||||
const previous = session.requestContext()
|
||||
if (previous?.provider !== config.provider
|
||||
|| previous.model !== config.model
|
||||
|| previous.contextWindow !== contextWindow) {
|
||||
session.append('request/context', {
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
contextWindow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const request = markAgentLoopRequest(deepFreeze({
|
||||
...header.config,
|
||||
messages: boundaryMessages,
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, 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'
|
||||
@@ -181,7 +179,6 @@ describe('request stability across the loop', () => {
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
context: { contextWindow: 64_000 },
|
||||
reasoning: await reasoning.promise,
|
||||
}
|
||||
}
|
||||
@@ -192,12 +189,6 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
|
||||
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
|
||||
const dispatched: number[] = []
|
||||
ctx.on('agent/model-request', (subject, _turn, _step, request) => {
|
||||
if (subject === agent && request.contextWindow !== undefined) {
|
||||
dispatched.push(request.contextWindow)
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await started.promise
|
||||
@@ -213,7 +204,6 @@ describe('request stability across the loop', () => {
|
||||
ReasoningEffortId('high'),
|
||||
])
|
||||
expect(second.requests).toHaveLength(0)
|
||||
expect(dispatched).toEqual([64_000])
|
||||
const headers = agent.session.events.filter(event => event.type === 'request/header')
|
||||
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
|
||||
})
|
||||
@@ -293,7 +283,6 @@ describe('request stability across the loop', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
let observed: GenerateOptions | undefined
|
||||
let observedRequest: { provider: string; model: string; contextWindow?: number } | undefined
|
||||
ctx.on('llm/stream', (options) => {
|
||||
observed = options
|
||||
return (async function* () {
|
||||
@@ -304,15 +293,11 @@ describe('request stability across the loop', () => {
|
||||
provider: 'listener',
|
||||
model: 'virtual',
|
||||
})
|
||||
ctx.on('agent/model-request', (subject, _turn, _step, request) => {
|
||||
if (subject === agent) observedRequest = { ...request }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(observed).toMatchObject({ provider: 'listener', model: 'virtual' })
|
||||
expect(observedRequest).toEqual({ provider: 'listener', model: 'virtual' })
|
||||
expect(agent.session.requestHeader()?.config).toEqual({
|
||||
provider: 'listener',
|
||||
model: 'virtual',
|
||||
@@ -323,129 +308,6 @@ describe('request stability across the loop', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies one contained request attempt after the outer stream handle returns', 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: [] })
|
||||
let resolutions = 0
|
||||
const adapter = new class extends LlmAdapter {
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
resolutions += 1
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...model === 'capacity'
|
||||
? { context: { contextWindow: 128_000 } }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
|
||||
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.model === 'lazy-sync-failure') {
|
||||
throw new LlmError('lazy construction failed', 'CONSTRUCTION')
|
||||
}
|
||||
if (options.model === 'async-failure') {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: () => Promise.reject(new LlmError('iteration failed', 'ITERATION')),
|
||||
}),
|
||||
}
|
||||
}
|
||||
return (async function* () {
|
||||
yield* textResponse(options.model)
|
||||
})()
|
||||
}
|
||||
}()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('model-request-live'), {
|
||||
provider: 'mock',
|
||||
model: 'capacity',
|
||||
})
|
||||
const returnedHandles = new Set<string>()
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
if (options.model === 'outer-failure') {
|
||||
throw new Error('outer waterfall failed before returning a handle')
|
||||
}
|
||||
if (options.model === 'lazy-sync-failure') {
|
||||
const stream = (async function* () {
|
||||
yield* next()
|
||||
})()
|
||||
returnedHandles.add(options.model)
|
||||
return stream
|
||||
}
|
||||
const stream = next()
|
||||
returnedHandles.add(options.model)
|
||||
return stream
|
||||
})
|
||||
const observed: {
|
||||
turn: number
|
||||
step: number
|
||||
provider: string
|
||||
model: string
|
||||
contextWindow?: number
|
||||
}[] = []
|
||||
const observedBeforeHandleReturn: string[] = []
|
||||
ctx.on('agent/model-request', (subject) => {
|
||||
if (subject === agent) throw new Error('observer failed')
|
||||
})
|
||||
ctx.on('agent/model-request', (subject, turn, step, request) => {
|
||||
if (subject !== agent) return
|
||||
if (!returnedHandles.has(request.model)) observedBeforeHandleReturn.push(request.model)
|
||||
observed.push({ turn, step, ...request })
|
||||
})
|
||||
ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({
|
||||
...await next(),
|
||||
model: [
|
||||
'capacity',
|
||||
'unknown',
|
||||
'async-failure',
|
||||
'lazy-sync-failure',
|
||||
'outer-failure',
|
||||
][turn - 1]!,
|
||||
}))
|
||||
|
||||
for (const prompt of ['one', 'two', 'three', 'four', 'five']) {
|
||||
send(agent, prompt)
|
||||
await waitForIdle(ctx, agent)
|
||||
}
|
||||
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'capacity',
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'unknown',
|
||||
},
|
||||
{
|
||||
turn: 3,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'async-failure',
|
||||
},
|
||||
{
|
||||
turn: 4,
|
||||
step: 1,
|
||||
provider: 'mock',
|
||||
model: 'lazy-sync-failure',
|
||||
},
|
||||
])
|
||||
expect(resolutions).toBe(5)
|
||||
expect(observedBeforeHandleReturn).toEqual([])
|
||||
expect(returnedHandles.has('outer-failure')).toBe(false)
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -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: afad797f1f21240610c8fe37aa6d2f55cf06fd26
|
||||
README.zh.md: 4e8d366a1e72071805073088b136a49a4a3703d9
|
||||
README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6
|
||||
README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
|
||||
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports the route and optional context capacity copied from the registration-bound prepared call whose outer stream handle returned. It is neither durable state nor proof of provider I/O. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
|
||||
|
||||
`PromptDecision.additionalContexts` is an array of identified, frozen `UserMessage` values so every context keeps its own identity and source. The admitted prompt and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; replacing admitted content preserves the prompt's identity.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
|
||||
|
||||
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
|
||||
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点;由绑定注册项的准备完成调用发起的外层流返回句柄后,失败受收容的 `agent/model-request` 通知会报告其路由,以及从该调用复制的可选上下文容量。它既不是持久状态,也不能证明提供方 I/O 已开始。`agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall:失败步骤关闭后,它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
|
||||
|
||||
`PromptDecision.additionalContexts` 是由带标识且冻结的 `UserMessage` 值组成的数组,因此每个上下文都保留自己的标识和来源。获准的提示词与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content` 与 `additionalContexts`,除非有意替换任一字段;替换获准内容时仍会保留提示词的标识。
|
||||
|
||||
|
||||
@@ -91,16 +91,6 @@ export type PromptDecision =
|
||||
/** Model-request failure with an optional machine-routable provider code. */
|
||||
export type RequestError = Error & { code?: string }
|
||||
|
||||
/** Live metadata for one model request whose outer stream handle was obtained. */
|
||||
export interface AgentModelRequest {
|
||||
/** Final request provider route; a short-circuit listener may own it. */
|
||||
readonly provider: string
|
||||
/** Final request model id; a short-circuit listener may own it. */
|
||||
readonly model: string
|
||||
/** Registration-bound context capacity when preparation exposed one. */
|
||||
readonly contextWindow?: number
|
||||
}
|
||||
|
||||
/** Action returned by a listener that owns model-request recovery. */
|
||||
export type RequestErrorAction = { kind: 'retry' } | undefined
|
||||
|
||||
@@ -347,20 +337,6 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* One model request obtained its outer `llm/stream` handle and is about to
|
||||
* iterate it. This observes an Agent-loop request attempt, not proof that
|
||||
* provider I/O began. The notification is live, contained, and not replayed.
|
||||
* Preparation or a synchronous outer waterfall failure emits nothing;
|
||||
* failures or abortion after the handle returns still count.
|
||||
* @param agent - the agent dispatching the model request.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the request's step number.
|
||||
* @param request - final route plus registration-bound context capacity.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/model-request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, request: AgentModelRequest): void
|
||||
/**
|
||||
* Handle a model-request failure after its failed step has closed but
|
||||
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
|
||||
|
||||
@@ -15,7 +15,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
|
||||
'agent/inbox/dequeue': args => args[0],
|
||||
'agent/inbox/discard': args => args[0],
|
||||
'agent/inbox/enqueue': args => args[0],
|
||||
'agent/model-request': args => args[0],
|
||||
'agent/prompt-submit': args => args[0],
|
||||
'agent/request': args => args[0],
|
||||
'agent/request-error': args => args[0],
|
||||
|
||||
@@ -56,7 +56,6 @@ describe('scoped-dispatch invariants', () => {
|
||||
'agent/step': [agent, 1, 1, signal],
|
||||
'agent/prompt-submit': [agent, message, signal, () => Promise.resolve({ kind: 'allow' })],
|
||||
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
|
||||
'agent/model-request': [agent, 1, 1, { provider: 'p', model: 'm', contextWindow: 128_000 }],
|
||||
'agent/request-error': [
|
||||
agent,
|
||||
1,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, RequestContext, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -559,6 +559,30 @@ export class Session {
|
||||
return this.headerFold
|
||||
}
|
||||
|
||||
/** Cached fold of the request-context events — see {@link requestContext}. */
|
||||
private contextFold: RequestContext | undefined
|
||||
/** Log position (events consumed) the context fold has reached. */
|
||||
private contextFoldSeq = 0
|
||||
|
||||
/**
|
||||
* The route capacity in force after the log's last `request/context` event —
|
||||
* what the NEXT request deduplicates against — or undefined before any such
|
||||
* record. Maintained incrementally like {@link requestHeader}, so a per-step
|
||||
* read costs O(new events).
|
||||
* @returns the folded capacity record, or undefined when none exists yet.
|
||||
*/
|
||||
requestContext(): RequestContext | undefined {
|
||||
if (this.contextFoldSeq < this.log.length) {
|
||||
for (const event of this.log.slice(this.contextFoldSeq)) {
|
||||
// Frozen for the same reason as the header fold: it is session state
|
||||
// exposed by reference and every later dedup compares against it.
|
||||
if (event.type === 'request/context') this.contextFold = deepFreeze({ ...event.data })
|
||||
}
|
||||
this.contextFoldSeq = this.log.length
|
||||
}
|
||||
return this.contextFold
|
||||
}
|
||||
|
||||
/** The derived-message cache: frozen projections, extended per unseen node. */
|
||||
private derived: Message[] = []
|
||||
/** Surface position (nodes projected) the cache has reached. */
|
||||
|
||||
@@ -146,6 +146,7 @@ function validateEvent(
|
||||
break
|
||||
case 'steering/message':
|
||||
case 'todo/write':
|
||||
case 'request/context':
|
||||
case 'request/header': {
|
||||
if (trace.openTurn === null) {
|
||||
fail(`${event.type} appended outside any open turn (core execution events must be turn-enclosed)`)
|
||||
|
||||
@@ -169,6 +169,20 @@ export interface EpochHeader {
|
||||
tools?: ToolSchema[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Registration-bound context capacity of one resolved model route. Adapter
|
||||
* metadata about a route rather than a request input, which is why it lives
|
||||
* outside {@link EpochHeader}.
|
||||
*/
|
||||
export interface RequestContext {
|
||||
/** Registered provider route the capacity was resolved through. */
|
||||
provider: string
|
||||
/** Provider-owned model id the capacity belongs to. */
|
||||
model: string
|
||||
/** Maximum combined request and response context in tokens. */
|
||||
contextWindow: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
|
||||
* header (a new conversation); `'resume'` — a loop instance's first request
|
||||
@@ -250,6 +264,16 @@ export interface SessionEventMap {
|
||||
* It is log-only; the latest snapshot reconstructs the request header.
|
||||
*/
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Registration-bound context capacity for the route a request resolved to,
|
||||
* appended inside its step beside `request/header` and only when the route
|
||||
* or capacity differs from the last record. It is log-only and deliberately
|
||||
* NOT part of {@link EpochHeader}: capacity is adapter metadata about a
|
||||
* route, not an input the request was built from, so it must not participate
|
||||
* in request reconstruction or header equality. Absent for a route whose
|
||||
* adapter advertises no capacity.
|
||||
*/
|
||||
'request/context': RequestContext
|
||||
}
|
||||
|
||||
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
|
||||
|
||||
Reference in New Issue
Block a user