fix(tools): preserve canonical output boundaries

This commit is contained in:
Tianyi Cui
2026-07-21 18:03:01 +08:00
parent 8f3aca4128
commit e1633fbc3f
33 changed files with 269 additions and 103 deletions

View File

@@ -58,7 +58,7 @@ Durable values need one accepted representation, not a check followed by a secon
`context/message` renders its `content` verbatim as a user-role message, and may attach JSON `meta` for replayable plugin state; metadata remains durable but is excluded from `deriveMessages()`.
`tool/result` persists the model-facing content, canonical failure detail, and optional presentation metadata. A tool's successful canonical `value` is deliberately execution-local and never enters the session event, so replay reconstructs the Native/model presentation but cannot recover intermediate programmatic values. This does not change `SESSION_FORMAT_VERSION`: the persisted projection remains authoritative.
`tool/result` persists the model-facing content, optional internal failure identity, and optional presentation metadata. A tool's successful canonical `value` and human-readable canonical failure message remain execution-local; rendered error content is the replay-authoritative message. This preserves the existing event shape and does not change `SESSION_FORMAT_VERSION`.
### Session event vocabulary (`types.ts`)

View File

@@ -92,10 +92,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
callId,
content: [{ type: 'text', text: 'Tool call interrupted by a crash; no result was recorded.' }],
isError: true,
error: {
message: 'Tool call interrupted by a crash; no result was recorded.',
info: { name: 'InterruptedError', code: 'interrupted' },
},
error: { name: 'InterruptedError', code: 'interrupted' },
},
surfaceOp: 'append',
...callSeq !== undefined ? { sourceEventSeqs: [callSeq] } : {},

View File

@@ -243,12 +243,13 @@ export interface SessionEventMap {
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
/**
* A completed tool call's model-facing result, canonical failure detail, and
* optional tool-private `meta` presentation payload. `meta` is opaque to the
* core (the producing tool owns its shape and reads it back in `presentResult`)
* but MUST be JSON-serializable: `Session.append` runtime-validates all event
* data with `isJsonValue`, so a non-serializable `meta` is rejected at the
* source, and the durable log reproduces the identical card on replay. Absent
* A completed tool call's model-facing result, optional internal failure
* identity, and optional tool-private `meta` presentation payload. `meta` is
* opaque to the core (the producing tool owns its shape and reads it back in
* `presentResult`) but MUST be JSON-serializable: `Session.append`
* runtime-validates all event data with `isJsonValue`, so a non-serializable
* `meta` is rejected at the source, and the durable log reproduces the
* identical card on replay. Absent
* unless the tool attaches one (e.g. `dsh-tool-fs` carries its result-time
* contextual diff here).
*/
@@ -258,7 +259,7 @@ export interface SessionEventMap {
callId: CallId
content: ContentBlock[]
isError: boolean
error?: { message: string; info?: { name: string; code: string } }
error?: { name: string; code: string }
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */

View File

@@ -64,7 +64,7 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { info: { code: 'interrupted' } },
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: 'interrupted' },
})
})