Merge remote-tracking branch 'origin/master' into fix/subagent-depth-budget

# Conflicts:
#	website/zh-CN/api/harness/agents.md
#	website/zh-CN/api/harness/events.md
#	website/zh-CN/api/harness/sessions.md
#	website/zh-CN/api/harness/subagents.md
This commit is contained in:
Tianyi Cui
2026-07-20 18:00:40 +08:00
163 changed files with 5505 additions and 6957 deletions

View File

@@ -4,7 +4,7 @@ Runtime event-contract assertions intended for development diagnostics. This pur
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates complete provenance and positional replacement, restricts `tool/result` replacement to one current result's `content`, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
@@ -31,8 +31,7 @@ Session log (per session):
- **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns.
- **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step.
- **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s.
- **a `tool/result` needs a prior `tool/call`** — but NOT the converse: a `tool/call` may have no result (a thrown tool-execution pipeline step ends the turn with no `tool/result`, which is legal).
- **provenance sources are valid and unambiguous** — `sourceEventSeqs` contains unique earlier known seqs; only `assistant/message` may carry an explicit empty list, which denotes a known empty provider stream rather than absent legacy provenance.
- **an appended `tool/result` needs a prior `tool/call`** — fresh `surfaceOp: 'append'` results name the open step and consume its pending call. A Session-validated replacement is a turn-enclosed rewrite, not another execution. A `tool/call` may still have no result when the execution pipeline throws.
Agent status (per agent):

View File

@@ -151,6 +151,16 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
break
}
case 'tool/result': {
// Session has already validated a provenance-backed content rewrite.
// It is durable turn work, not a second execution of the original call.
if (event.surfaceOp !== 'append') {
if (trace.openTurn === null) {
throw new InvariantError(
'tool/result surface replacement appended outside any open turn',
)
}
break
}
requireOpenStep(trace, 'tool/result', event.data.turn, event.data.step)
// A result needs a prior matching call in the same step. (The converse
// does NOT hold: a call may have no result — a throwing tool-execution

View File

@@ -189,6 +189,19 @@ describe('session-log invariants', () => {
.toThrow(/no prior tool\/call/)
})
it('keeps fresh tool-result appends open-step and pending-call checked', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
it('allows a synthetic interrupted tool/result from crash repair without a prior tool/call event', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -465,6 +478,41 @@ describe('HMR safety', () => {
})
describe('surface contract under the invariants composition', () => {
async function toolResultRewriteFixture(openRewriteTurn = true) {
const { ctx } = await setup()
const session = ctx.sessions.create()
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const unrelated = session.append('user/message', {
content: [{ type: 'text', text: 'request' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
name: 'echo',
arguments: '{}',
})
const originalData = {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text' as const, text: 'original' }],
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { presentation: { kind: 'terminal', output: 'full output' } },
futureField: { nested: ['preserve', 1] },
}
const original = session.append('tool/result', originalData, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
if (openRewriteTurn) {
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
}
return { session, unrelated, original }
}
it('accepts well-formed surface metadata', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()
@@ -487,6 +535,71 @@ describe('surface contract under the invariants composition', () => {
// no throw — well-formed replace op
})
it('treats a provenance-backed tool-result replacement as a turn-enclosed rewrite', async () => {
const { session, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).not.toThrow()
})
it('rejects a tool-result replacement outside a turn', async () => {
const { session, original } = await toolResultRewriteFixture(false)
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/outside any open turn/)
})
it('rejects a tool-result replacement targeting an unrelated current node', async () => {
const { session, unrelated, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'forged' }],
}, {
surfaceOp: { op: 'replace', start: unrelated.seq, end: unrelated.seq },
sourceEventSeqs: [unrelated.seq],
})).toThrow(/must target a current tool\/result/)
})
it('rejects a multi-node tool-result replacement even with complete provenance', async () => {
const { session, unrelated, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'forged' }],
}, {
surfaceOp: { op: 'replace', start: unrelated.seq, end: original.seq },
sourceEventSeqs: [unrelated.seq, original.seq],
})).toThrow(/must rewrite exactly one current node/)
})
it.each([
['callId', { callId: CallId('forged') }],
['turn', { turn: 2 }],
['step', { step: 2 }],
['error', { error: { name: 'ExitError', code: 'DIFFERENT' } }],
['meta', { meta: { presentation: { kind: 'generic' } } }],
['future data', { futureField: { nested: ['changed'] } }],
])('rejects a content rewrite with altered %s', async (_label, altered) => {
const { session, original } = await toolResultRewriteFixture()
expect(() => session.append('tool/result', {
...original.data,
...altered,
content: [{ type: 'text', text: 'pruned' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})).toThrow(/may change only content/)
})
it('accepts known-empty assistant provenance and rejects empty provenance elsewhere', async () => {
const { ctx } = await setup()
const session = ctx.sessions.create()