Merge latest origin/master into compact-post-step-overflow-recovery

# Conflicts:
#	docs/agent-lifecycle.md
#	docs/architecture.md
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/rfc/INDEX.md
#	docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md
#	docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md
#	docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md
#	docs/rfc/implemented/feature/2026-07-07-session-prefix.md
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/config.ts
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/src/summarizer.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
#	packages/compact/compact/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/stream.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/index.ts
#	packages/llm/llm/tests/service.spec.ts
#	scripts/gen-doc-graphs.ts
This commit is contained in:
Tianyi Cui
2026-07-19 12:06:23 +08:00
814 changed files with 42348 additions and 10067 deletions

View File

@@ -25,20 +25,22 @@ class StubCompactService extends CompactService {
}
override async compactRegion(
session: Session,
start: number,
end: number,
_agent: CompactAgentContext,
agent: CompactAgentContext,
signal?: AbortSignal,
): Promise<CompactionResult> {
this.lastSignal = signal
const session = agent.session
const summary = [{ type: 'text' as const, text: 'stub' }]
// Minimal stub honoring the lock + log-only event contract.
const startEvent = session.append('compact/start', { turn: 0 })
const summaryEvent = session.append('compact/summary', {
summary: [{ type: 'text', text: 'stub' }],
summary,
shadowedRange: { start, end },
shadowedSeqs: [],
shadowedTokenCount: 0,
provider: 'mock',
model: 'stub',
})
const endEvent = session.append('compact/end', { turn: 0 })
@@ -46,7 +48,7 @@ class StubCompactService extends CompactService {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary: [{ type: 'text', text: 'stub' }],
summary,
shadowedRange: { start, end },
shadowedSeqs: [],
shadowedTokenCount: 0,
@@ -86,7 +88,7 @@ describe('CompactService seam', () => {
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'))
const result = await svc.compactRegion(0, 0, stubAgent(session, 'm'))
const startEvent = session.events.find(e => e.type === 'compact/start')
expect(startEvent).toBeDefined()
@@ -94,8 +96,12 @@ describe('CompactService seam', () => {
// verify the runtime value is absent.
const raw = startEvent as unknown as { surfaceOp?: unknown }
expect(raw.surfaceOp).toBeUndefined()
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
expect(result.shadowedRange).toEqual({ start: 0, end: 0 })
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
.toEqual(['compact/start', 'compact/summary', 'compact/end'])
})
it('threads the cancellation signal through to the backend', async () => {
@@ -104,7 +110,7 @@ describe('CompactService seam', () => {
const session = new Session(SessionId('s'))
const controller = new AbortController()
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)

View File

@@ -54,7 +54,7 @@ describe('renderTranscript', () => {
content: [{ type: 'text', text: 'fix the bug' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const assistant = s.append('assistant/message', {
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
turn: 0, step: 0,
content: [{ type: 'text', text: 'looking' }],
}, { surfaceOp: 'append' })
@@ -111,7 +111,7 @@ describe('renderTranscript', () => {
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const emptyAssistant = s.append('assistant/message', {
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
turn: 0, step: 0,
content: [{ type: 'text', text: '' }],
}, { surfaceOp: 'append' })

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
const SURFACE = { surfaceOp: 'append' as const }
@@ -10,18 +10,18 @@ function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
return session.events.filter(event => event.type === type)[nth]!.seq
}
function nodeAt(session: Session, seq: number): SurfaceNode {
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
return node
function surfaceSeq(session: Session, seq: number): number {
const current = session.surface.nodes.find(candidate => candidate === seq)
if (current === undefined) throw new Error(`seq ${seq} is not on the surface`)
return current
}
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth)))
return toolPairingBalancedBefore(session, surfaceSeq(session, seqOf(session, type, nth)))
}
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
}
function closedToolStep(): Session {
@@ -34,6 +34,7 @@ function closedToolStep(): Session {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
session.append('tool/result', {
turn: 1,
@@ -60,6 +61,7 @@ describe('tool-pairing boundaries', () => {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
})
@@ -73,6 +75,7 @@ describe('tool-pairing boundaries', () => {
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
@@ -91,6 +94,7 @@ describe('tool-pairing boundaries', () => {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
}, SURFACE)
midStep.append('context/message', {
content: [{ type: 'text', text: 'background update' }],
@@ -113,16 +117,19 @@ describe('tool-pairing boundaries', () => {
})
describe('tool-pairing surface identity', () => {
it('rebuilds after replace and rejects nodes removed from current membership', () => {
it('rebuilds after replace and rejects sequences removed from current membership', () => {
const session = closedToolStep()
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
const staleTail = surfaceSeq(session, seqOf(session, 'tool/result'))
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
const nodes = session.surface.nodes
session.append('user/message', {
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq } })
}, {
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
sourceEventSeqs: [...nodes],
})
const checkpoint = session.surface.nodes[0]!
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
@@ -131,16 +138,16 @@ describe('tool-pairing surface identity', () => {
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('ignores a caller-held node next field and answers from cached balances', () => {
it('answers repeated queries from cached balances', () => {
const session = closedToolStep()
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
const assistant = surfaceSeq(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
expect(toolPairingBalancedAfter(session, assistant)).toBe(false)
})
it('rejects missing seqs before and after, including an empty surface', () => {
const session = new Session(SessionId('missing-membership'))
const missing: SurfaceNode = { seq: 999, prev: null, next: null }
const missing = 999
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
@@ -162,7 +169,12 @@ describe('tool-pairing cache refresh', () => {
},
{
type: 'assistant/message', seq: 1, time: 1,
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
data: {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
},
surfaceOp: 'append',
},
{
@@ -171,11 +183,7 @@ describe('tool-pairing cache refresh', () => {
surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: 2 },
{ seq: 2, prev: 1, next: null },
]
const nodes: number[] = [0, 1, 2]
let generation = 0
let eventCollectionReads = 0
let eventIndexReads = 0
@@ -219,7 +227,7 @@ describe('tool-pairing cache refresh', () => {
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
surfaceOp: 'append',
})
nodes.push({ seq: 4, prev: 2, next: null })
nodes.push(4)
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
expect(eventCollectionReads).toBe(2)
expect(eventIndexReads).toBe(4)
@@ -227,7 +235,12 @@ describe('tool-pairing cache refresh', () => {
events.push(
{
type: 'assistant/message', seq: 5, time: 5,
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
data: {
turn: 2,
step: 1,
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
provenance: { provider: 'mock', model: 'mock' },
},
surfaceOp: 'append',
},
{
@@ -236,10 +249,7 @@ describe('tool-pairing cache refresh', () => {
surfaceOp: 'append',
},
)
nodes.push(
{ seq: 5, prev: 4, next: 6 },
{ seq: 6, prev: 5, next: null },
)
nodes.push(5, 6)
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
expect(eventCollectionReads).toBe(3)
expect(eventIndexReads).toBe(6)
@@ -249,14 +259,14 @@ describe('tool-pairing cache refresh', () => {
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 6 },
})
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
nodes.splice(0, nodes.length, 7)
generation += 1
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
expect(eventCollectionReads).toBe(4)
expect(eventIndexReads).toBe(7)
})
it('rebuilds defensively when a same-generation surface node count regresses', () => {
it('rebuilds defensively when a same-generation surface entry count regresses', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
@@ -267,10 +277,7 @@ describe('tool-pairing cache refresh', () => {
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: null },
]
const nodes: number[] = [0, 1]
const session = {
events,
surface: { nodes, replaceGeneration: 0 },
@@ -304,24 +311,24 @@ describe('tool-pairing corrupt surfaces', () => {
})
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
const missingSeq = 1
const missing = {
events: [{
type: 'user/message', seq: 0, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [missingNode], replaceGeneration: 0 },
surface: { nodes: [missingSeq], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
const mismatchedSeq = 0
const mismatched = {
events: [{
type: 'user/message', seq: 99, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
})
})