fix(agent-loop): rematerialize adapter defaults

This commit is contained in:
Yichen Jiang
2026-07-30 21:49:58 +08:00
parent 95824545a6
commit 5fd34f9109
47 changed files with 348 additions and 100 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/session/README.md
README.md: a9b6905dcf2b8ef1f75595e567273f7a3150a412
README.zh.md: f1a5e97e32d1ad1abcd6ad96e6c621af9972e989
README.md: a4944fd974fda5ee1bd6ecadf29ef5fa322e9dc3
README.zh.md: 3578ea3aa48382609e075518b0f8a7f8851761e4

View File

@@ -63,7 +63,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.

View File

@@ -63,7 +63,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`,无需运行模型。

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