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

@@ -129,10 +129,12 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
}, 15_000)
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 })
// Keep the binding delay above the compute allowance while leaving enough
// headroom for worker bootstrap on loaded CI hosts.
const { runtime } = await setup({ computeMs: 1_000, maxWallMs: 30_000 })
const result = await runtime.run({
program: 'return await tools.slow({})',
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }),
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 1_500)) }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('slow-done')

View File

@@ -1,11 +1,12 @@
# compact/ — compaction capability family
A three-package capability seam (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract compaction interface, a backend that summarizes, and the model-facing tool that consumes it. The interface and a first backend (`compact-basic/`) exist; the consumer tool is deferred. All **product** packages.
A compaction capability family (see [capability seams](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract interface, a summarizing backend, a model-free tool-result pruning companion, and a deferred model-facing consumer. All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-tool-result-prune/` | Optional model-free head/middle/tail rewriting before summary compaction | `ctx.toolResultPrune` |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement is a reusable LLM-family service rather than a `CompactService` method; a template- or model-backed compactor can replace `compact-basic` without changing the meter or callers.
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`, and deterministic pruning at `compact/compact-tool-result-prune/`. Unlike the bash seam, the interface depends on `dsh-session` and `dsh-llm` because its verbs are defined over a `Session` and its output uses `ContentBlock`. That deviation is recorded in the [compaction capability-seam Agent Note](../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md). Token measurement remains a reusable LLM-family service; a template- or model-backed compactor can replace `compact-basic` without changing the meter, pruner, or callers.

View File

@@ -9,13 +9,14 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
- **Overflow recovery** — below-threshold overflow bypasses normal retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
@@ -50,7 +51,7 @@ export function apply(ctx: Context): void {
}
```
Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
Loading the plugin registers `ctx.compact`. Add [`dsh-compact-tool-result-prune`](../compact-tool-result-prune/README.md) as a sibling before this plugin to enable the optional model-free pass. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly.
## Model Experience
@@ -58,7 +59,7 @@ Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it c
#### What the model sees
After a successful step crosses the threshold, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from that replacement. This one checkpoint replaces the selected older range and is followed by the retained recent units.
After a successful step crosses the threshold, oversized tool results are first rewritten when the optional pruner is loaded. If summarization remains necessary, the next request receives the checkpoint preamble below, a blank line, `<compacted-summary>`, the data-dependent summary, and `</compacted-summary>`. Overflow recovery rebuilds the immediate retry from whatever replacement advanced the surface. A checkpoint replaces the selected older range and is followed by the retained recent units.
##### Conversation checkpoint preamble
@@ -68,7 +69,7 @@ This is an automatically generated checkpoint condensing an earlier span of the
#### Token effect
The replacement reduces future input history rather than appending a second copy. The summary remains until a later compaction replaces it; one oversized indivisible unit can still exceed the budget.
Model-free pruning can avoid the auxiliary call entirely; otherwise it reduces that call's transcript before the summary replaces an older range. The replacement reduces future input history rather than appending a second copy. A summary remains until a later compaction replaces it, while an indivisible non-tool unit can still exceed the budget.
#### KV Cache effect
@@ -144,7 +145,7 @@ Prefix-stable for auxiliary calls while this instruction and the summarizer rout
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
- **Summarization failure fails closed with full, over-budget history** — including truncation at the summarization `maxTokens`, which hidden reasoning tokens can consume; the auto path logs a warning and proceeds.
- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)).

View File

@@ -27,8 +27,14 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-compact-tool-result-prune": {
"optional": true
}
},
"dependencies": {
"schemastery": "^3.18.0"
},
@@ -43,6 +49,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -12,6 +12,8 @@ import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
import { resolveConfig } from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
@@ -98,22 +100,35 @@ export class BasicCompactService extends CompactService {
|| retryAttempt >= this.config.maxOverflowRetries
|| signal.aborted) return next()
let generation: number
const generation = agent.session.surface.replaceGeneration
let result: CompactionResult | null
try {
generation = agent.session.surface.replaceGeneration
result = await this.compactIfNeeded(agent, 'context-overflow', signal)
} catch (recoveryError: unknown) {
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
ctx.logger.warn(
`context-overflow compaction failed after durable surface progress: ${message}; `
+ 'retrying from the replacement surface',
)
return { action: 'retry' }
}
ctx.logger.warn(
`context-overflow compaction failed: ${message}; preserving the original request error`,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
`context-overflow compaction failed: ${message}; ${signal.aborted
? 'cancellation prevents retry'
: 'preserving the original request error'}`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
if (signal.aborted || result === null
if (signal.aborted
|| agent.session.surface.replaceGeneration <= generation) return next()
logResult(result, 'context overflow recovery')
if (result !== null) logResult(result, 'context overflow recovery')
return { action: 'retry' }
})
}
@@ -142,7 +157,7 @@ export class BasicCompactService extends CompactService {
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest compaction result, or `null` when no check/work applies.
* @returns the latest summary compaction result, or `null` when no summary ran.
*/
override async compactIfNeeded(
agent: Agent,
@@ -152,22 +167,34 @@ export class BasicCompactService extends CompactService {
const model = routedModel(agent.session)
if (model === undefined) return null
const meter = this.ctx.tokenMeter
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
let measurement = meter.measure(agent.session)
switch (trigger) {
case 'context-overflow': {
const measurement = meter.measure(agent.session)
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(range.start, range.end, agent, signal)
}
case 'context-overflow':
break
case 'pressure':
if (measurement.totalTokens < threshold) return null
break
/* v8 ignore next -- closed-union exhaustiveness guard */
default:
assertNever(trigger, 'compaction trigger')
}
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
let measurement = meter.measure(agent.session)
// Pruning is optional so compact-basic remains independently composable.
// Once either trigger qualifies, land the model-free pass before choosing
// a summary range, then remeasure through the singleton replay fold.
const prune = this.ctx.get('toolResultPrune')
if (prune !== undefined) {
prune.pruneSession(agent.session)
measurement = meter.measure(agent.session)
}
if (trigger === 'context-overflow') {
const range = selectCompactableRange(agent.session, measurement, 0)
if (range === null) return null
return this.compactRegion(range.start, range.end, agent, signal)
}
if (measurement.totalTokens < threshold) return null
let result: CompactionResult | null = null

View File

@@ -10,6 +10,7 @@ import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@d
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
import type { Agent } from '@deepseek-ai/dsh-agent'
const SIGNAL = new AbortController().signal
@@ -97,6 +98,43 @@ function toolConversation(): Session {
return session
}
/** One closed routed tool step followed by an open turn for rewrite events. */
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
const session = new Session(SessionId(`oversized-tool-${chars}`))
const callId = CallId('oversized')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
if (withCompactablePrompt) {
session.append('user/message', {
content: [{ type: 'text', text: 'older history '.repeat(200) }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
provenance: { provider: MODEL, model: MODEL },
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
session.append('tool/result', {
turn: 1,
step: 1,
callId,
content: [{ type: 'text', text: 'X'.repeat(chars) }],
isError: false,
meta: { presentation: 'preserved' },
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
return session
}
class TestCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
summaryProvider = 'summary-provider'
@@ -416,6 +454,78 @@ describe('pressure measurement and retention', () => {
})
})
describe('optional model-free tool-result pruning', () => {
const pruneConfig = { thresholdChars: 100, headChars: 20, tailChars: 10 }
it('does not prune a below-pressure session opportunistically', async () => {
const ctx = createContext(10_000)
const prune = new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.8,
retainTokens: 100,
})
const session = oversizedToolResult()
const pruneSession = vi.spyOn(prune, 'pruneSession')
expect(await compactIfNeeded(compact, session)).toBeNull()
expect(pruneSession).not.toHaveBeenCalled()
expect(compact.calls).toHaveLength(0)
expect(session.surface.replaceGeneration).toBe(0)
})
it('skips LLM summarization when pruning alone clears pressure', async () => {
const ctx = createContext(1_000)
void new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = oversizedToolResult()
expect(ctx.tokenMeter.measure(session).totalTokens).toBeGreaterThanOrEqual(500)
expect(await compactIfNeeded(compact, session)).toBeNull()
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(500)
expect(compact.calls).toHaveLength(0)
expect(session.surface.replaceGeneration).toBe(1)
})
it('summarizes the pruned surface when pruning is insufficient', async () => {
const ctx = createContext(2_000)
void new ToolResultPruneService(ctx, pruneConfig)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = toolConversation()
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
expect(compact.calls[0]!.text).not.toContain('result 1 '.repeat(300))
})
it('retains the original compact-basic behavior without the optional plugin', async () => {
const ctx = createContext(2_000)
const compact = new TestCompactService(ctx, {
auto: false,
thresholdRatio: 0.5,
retainTokens: 50,
})
const session = oversizedToolResult(3_000, true)
expect(await compactIfNeeded(compact, session)).not.toBeNull()
expect(compact.calls).toHaveLength(1)
const original = session.events.find(event => event.type === 'tool/result')
expect(original?.type === 'tool/result' && original.data.content[0])
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
expect(session.events.filter(event =>
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
})
})
describe('compaction region transaction', () => {
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
const compact = service()
@@ -876,6 +986,89 @@ describe('automatic listener and loader composition', () => {
expect(session.surface.nodes).toContain(retainedSeq)
})
it('authorizes overflow retry when pruning alone advances an indivisible surface', async () => {
const ctx = createContext(10_000)
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
const session = oversizedToolResult()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
expect(compact.calls).toHaveLength(0)
})
it('continues overflow recovery with summarization on the pruned surface', async () => {
const ctx = createContext(10_000)
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
const session = toolConversation()
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(compact.calls).toHaveLength(1)
expect(compact.calls[0]!.text).toContain('tool result middle pruned')
})
it('retries from a durable prune when later overflow summarization throws', async () => {
const ctx = createContext(10_000)
const warnings: string[] = []
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
compact.error = new Error('summary unavailable after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
expect(session.surface.replaceGeneration).toBe(1)
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
.toMatchObject({ error: 'summary unavailable after prune' })
expect(warnings).toContainEqual(expect.stringContaining('retrying from the replacement surface'))
})
it('lets cancellation win when summary throws after a durable prune', async () => {
const ctx = createContext(10_000)
const controller = new AbortController()
void new ToolResultPruneService(ctx, {
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
const compact = new TestCompactService(ctx, {
thresholdRatio: 1,
retainTokens: 900,
})
compact.mutateDuringSummary = () => { controller.abort('cancelled during summary') }
compact.error = new Error('summary cancelled after prune')
const session = oversizedToolResult(3_000, true)
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
.toEqual({ action: 'fail' })
expect(session.surface.replaceGeneration).toBe(1)
})
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
const ctx = createContext()
void new TestCompactService(ctx, {

View File

@@ -9,6 +9,7 @@ import Include from '@cordisjs/plugin-include'
import LlmService from '@deepseek-ai/dsh-llm'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import BasicCompactService from '@deepseek-ai/dsh-compact-basic'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
let root: string | undefined
let context: Context | undefined
@@ -32,6 +33,7 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-tool-result-prune', ToolResultPruneService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
])
context.loader.internal = {
@@ -50,12 +52,17 @@ async function loadYaml(lines: readonly string[]): Promise<Context> {
}
describe('real Loader composition', () => {
it('loads the flat token-meter and compact-basic YAML shape', async () => {
it('loads the shipped token-meter, pruning, and compact-basic YAML order', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
' config:',
' contextWindow: 4096',
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
' config:',
' thresholdChars: 100',
' headChars: 20',
' tailChars: 10',
"- name: '@deepseek-ai/dsh-compact-basic'",
' config:',
' thresholdRatio: 0.5',
@@ -68,6 +75,7 @@ describe('real Loader composition', () => {
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(loaded.tokenMeter.contextWindow).toBe(4096)
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,

View File

@@ -13,6 +13,7 @@
{ "path": "../../llm/token-meter" },
{ "path": "../../core/session" },
{ "path": "../../core/agent" },
{ "path": "../compact" }
{ "path": "../compact" },
{ "path": "../compact-tool-result-prune" }
]
}

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-compact-tool-result-prune
The replay-safe model-free pruning service (`ctx.toolResultPrune`). It rewrites over-budget `tool/result` surface nodes to a bounded head, a fixed omission marker, and a bounded tail while retaining the full original event in the append-only session log.
This is a concrete companion to [`dsh-compact-basic`](../compact-basic/README.md), not a compaction backend or model-facing tool. Compact-basic reads it through optional `ctx.get('toolResultPrune')`, so either package remains independently composable.
## Service API
`pruneSession(session)` scans one stable snapshot of the current surface. Every over-budget tool result is replaced by one newly appended `tool/result` carrying `{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }`. The replacement spreads the complete original data and changes only `content`, preserving `turn`, `step`, `callId`, error fields, `meta`, and later data additions. The original event remains available for persistence, replay, and exact-log inspection.
The method throws synchronously when the session rejects a replacement. Replacements committed earlier in the pass remain durable.
`measureContent(blocks)` counts Unicode code points in `text` blocks. `pruneContent(blocks)` returns the bounded replacement or `null` when content is already within the threshold. Non-text blocks are retained at their original relative positions; text slicing never splits a UTF-16 surrogate pair, though it can split a multi-code-point grapheme cluster.
Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than `thresholdChars`, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.
## Config
Unrecognized keys fail at plugin construction. Resolved config is detached and deeply immutable.
| Key | Required | Meaning |
|---|---|---|
| `thresholdChars` | no (default `8192`) | Prune when combined text exceeds this many Unicode code points. |
| `headChars` | no (default `4096`) | Leading Unicode code points retained. |
| `tailChars` | no (default `1024`) | Trailing Unicode code points retained. |
All values are integers; the threshold is positive and head/tail are non-negative. `headChars + marker + tailChars` must fit within `thresholdChars`, so a valid configuration can prune every over-budget result without growth or repeated rewriting.
## Usage
```ts
import type { Context } from 'cordis'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
export function apply(ctx: Context): void {
ctx.plugin(ToolResultPruneService)
}
```
## Model Experience
### Pruned tool result
#### What the model sees
Once a compaction trigger qualifies, future requests see the retained head, `\n\n[... tool result middle pruned ...]\n\n`, and retained tail in place of the removed text. Rich blocks keep their order. The model does not see a second copy of the original.
#### Token effect
Each rewritten tool result has at most `thresholdChars` text code points. Pruning itself makes no model call; compact-basic skips summarization when the remeasured request falls below pressure, otherwise the summarizer reads the pruned surface.
#### KV Cache effect
Replacing an earlier result invalidates reuse from the first changed token. The pruned prefix is eligible for reuse while its route, envelope, and preceding history remain identical.
## Known Limitations and Deferred Work
- **Character budgets are not token budgets** — provider token density varies, so `ctx.tokenMeter` remains the authority for deciding whether pruning relieved request pressure.
- **Pruning is syntactic** — it retains the beginning and end without interpreting which middle lines are semantically important.
- **Grapheme clusters can split** — code-point slicing protects surrogate pairs but does not perform locale-aware grapheme segmentation.

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-compact-tool-result-prune",
"description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,77 @@
/** Configuration resolution for deterministic tool-result pruning. */
import { deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ResolvedConfig, ToolResultPruneConfig } from './types.ts'
/** Fixed marker substituted for every removed middle span. */
export const PRUNE_MARKER = '\n\n[... tool result middle pruned ...]\n\n'
/** Low-friction defaults for coding-agent tool output. */
export const DEFAULTS: ResolvedConfig = deepFreeze({
thresholdChars: 8192,
headChars: 4096,
tailChars: 1024,
})
const CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdChars',
'headChars',
'tailChars',
])
/**
* Count Unicode code points without splitting surrogate pairs.
* @param text - text to measure.
* @returns the Unicode code-point count.
*/
export function codePointLength(text: string): number {
return Array.from(text).length
}
/**
* Resolve and validate pruning budgets.
* @param config - raw plugin configuration.
* @returns a detached deeply immutable configuration.
*/
export function resolveConfig(config: ToolResultPruneConfig = {}): ResolvedConfig {
for (const key of Object.keys(config)) {
if (!CONFIG_KEYS.has(key)) {
throw new Error(
`ToolResultPruneConfig: unknown key "${key}" `
+ '(allowed: thresholdChars, headChars, tailChars)',
)
}
}
const resolved: ResolvedConfig = {
thresholdChars: config.thresholdChars ?? DEFAULTS.thresholdChars,
headChars: config.headChars ?? DEFAULTS.headChars,
tailChars: config.tailChars ?? DEFAULTS.tailChars,
}
assertPositiveInteger('thresholdChars', resolved.thresholdChars)
assertNonNegativeInteger('headChars', resolved.headChars)
assertNonNegativeInteger('tailChars', resolved.tailChars)
const emittedChars = resolved.headChars
+ codePointLength(PRUNE_MARKER)
+ resolved.tailChars
if (emittedChars > resolved.thresholdChars) {
throw new Error(
`ToolResultPruneConfig: headChars + marker + tailChars (${emittedChars}) `
+ `must be at most thresholdChars (${resolved.thresholdChars})`,
)
}
return deepFreeze(structuredClone(resolved))
}
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a positive integer`)
}
}
function assertNonNegativeInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`ToolResultPruneConfig: ${name} (${value}) must be a non-negative integer`)
}
}

View File

@@ -0,0 +1,159 @@
/**
* Replay-safe, model-free tool-result pruning service.
*
* @module @deepseek-ai/dsh-compact-tool-result-prune
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
import type {
PrunedEntry,
PruneResult,
ResolvedConfig,
ToolResultPruneConfig,
} from './types.ts'
export { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
export type {
PrunedEntry,
PruneResult,
ResolvedConfig,
ToolResultPruneConfig,
} from './types.ts'
declare module 'cordis' {
interface Context {
toolResultPrune: ToolResultPruneService
}
}
interface SnapshotCandidate {
readonly seq: number
readonly event: SessionEvent<'tool/result'>
}
/** Deterministic head/middle/tail pruning for current tool-result surface nodes. */
export class ToolResultPruneService extends Service {
static Config: z<ToolResultPruneConfig> = z.object({
thresholdChars: z.number().step(1).min(1).default(DEFAULTS.thresholdChars),
headChars: z.number().step(1).min(0).default(DEFAULTS.headChars),
tailChars: z.number().step(1).min(0).default(DEFAULTS.tailChars),
})
/** Resolved and immutable character budgets. */
readonly config: ResolvedConfig
constructor(ctx: Context, config: ToolResultPruneConfig = {}) {
super(ctx, 'toolResultPrune')
this.config = resolveConfig(config)
}
/**
* Measure text content in Unicode code points; non-text blocks cost zero.
* @param blocks - tool-result content to measure.
* @returns total Unicode code points across text blocks.
*/
measureContent(blocks: readonly ContentBlock[]): number {
let chars = 0
for (const block of blocks) {
if (block.type === 'text') chars += codePointLength(block.text)
}
return chars
}
/**
* Replace an over-budget text middle while retaining rich-block order.
* Text slicing is by Unicode code point, not UTF-16 code unit, so a retained
* boundary cannot split a surrogate pair. Grapheme clusters may still split.
* @param blocks - original tool-result content.
* @returns pruned content, or `null` when the text is within budget.
*/
pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null {
const totalChars = this.measureContent(blocks)
if (totalChars <= this.config.thresholdChars) return null
const removedStart = this.config.headChars
const removedEnd = totalChars - this.config.tailChars
const pruned: ContentBlock[] = []
let consumed = 0
let markerInserted = false
for (const block of blocks) {
if (block.type !== 'text') {
pruned.push(block)
continue
}
const points = Array.from(block.text)
const blockStart = consumed
const blockEnd = blockStart + points.length
const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart))
const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart))
const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart
const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : ''
if (marker.length > 0) markerInserted = true
const text = points.slice(0, headEnd).join('')
+ marker
+ points.slice(tailStart).join('')
if (text.length > 0) pruned.push({ ...block, text })
consumed = blockEnd
}
/* v8 ignore next -- totalChars > threshold and valid budgets guarantee a removed text span. */
if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span')
const charsAfter = this.measureContent(pruned)
/* v8 ignore next -- config validation fixes the emitted head + marker + tail budget. */
if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) {
throw new Error('tool-result prune: replacement must be smaller and within threshold')
}
return pruned
}
/**
* Prune every over-budget tool result from one stable current-surface snapshot.
* Each replacement preserves the complete event data except for `content`,
* and points at the shadowed node for durable provenance and replay.
* @param session - session whose current surface is rewritten.
* @returns landed replacements and aggregate Unicode-code-point savings.
* @throws when the session rejects a replacement; replacements committed
* earlier in the pass remain durable.
*/
pruneSession(session: Session): PruneResult {
const candidates: SnapshotCandidate[] = []
for (const seq of [...session.surface.nodes]) {
const event = session.events[seq]
/* v8 ignore next -- surface seqs are validated contiguous log references. */
if (event?.type === 'tool/result') candidates.push({ seq, event })
}
const pruned: PrunedEntry[] = []
let charsRemoved = 0
for (const { seq, event } of candidates) {
const content = this.pruneContent(event.data.content)
if (content === null) continue
const charsBefore = this.measureContent(event.data.content)
const charsAfter = this.measureContent(content)
const replacement = session.append('tool/result', {
...event.data,
content,
}, {
surfaceOp: { op: 'replace', start: seq, end: seq },
sourceEventSeqs: [seq],
})
pruned.push({
originalSeq: seq,
replacementSeq: replacement.seq,
callId: event.data.callId,
charsBefore,
charsAfter,
})
charsRemoved += charsBefore - charsAfter
}
return { pruned, charsRemoved }
}
}
export default ToolResultPruneService

View File

@@ -0,0 +1,40 @@
import type { CallId } from '@deepseek-ai/dsh-llm'
/** Character-budget policy for deterministic tool-result pruning. */
export interface ToolResultPruneConfig {
/** Prune when total text exceeds this many Unicode code points. Defaults to `8192`. */
thresholdChars?: number
/** Maximum leading Unicode code points retained. Defaults to `4096`. */
headChars?: number
/** Maximum trailing Unicode code points retained. Defaults to `1024`. */
tailChars?: number
}
/** Validated, detached, deeply immutable pruning configuration. */
export interface ResolvedConfig {
readonly thresholdChars: number
readonly headChars: number
readonly tailChars: number
}
/** Provenance and size accounting for one landed surface replacement. */
export interface PrunedEntry {
/** Full-fidelity tool-result event shadowed by the replacement. */
readonly originalSeq: number
/** Newly appended pruned tool-result event. */
readonly replacementSeq: number
/** Tool call shared by the original and replacement. */
readonly callId: CallId
/** Original text size in Unicode code points. */
readonly charsBefore: number
/** Replacement text size in Unicode code points. */
readonly charsAfter: number
}
/** Aggregate outcome of one stable-surface pruning pass. */
export interface PruneResult {
/** Replacements in the snapshotted surface order. */
readonly pruned: readonly PrunedEntry[]
/** Total Unicode code points removed across replacements. */
readonly charsRemoved: number
}

View File

@@ -0,0 +1,67 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('compact-tool-result-prune real Loader composition', () => {
it('loads and resolves the flat YAML plugin shape', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-compact-tool-result-prune-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-compact-tool-result-prune'",
' config:',
' thresholdChars: 100',
' headChars: 20',
' tailChars: 10',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier !== '@deepseek-ai/dsh-compact-tool-result-prune') {
throw new Error(`unexpected Loader import: ${specifier}`)
}
return ToolResultPruneService
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect(context.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
expect(context.toolResultPrune.config).toEqual({
thresholdChars: 100,
headChars: 20,
tailChars: 10,
})
})
it('rejects stale config after plugin schema normalization', async () => {
context = new Context()
await expect(context.plugin(ToolResultPruneService, {
maxChars: 100,
} as never)).rejects.toThrow(/unknown key "maxChars"/)
})
})

View File

@@ -0,0 +1,239 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import ToolResultPruneService, {
codePointLength,
DEFAULTS,
PRUNE_MARKER,
resolveConfig,
} from '@deepseek-ai/dsh-compact-tool-result-prune'
import type { ToolResultPruneConfig } from '@deepseek-ai/dsh-compact-tool-result-prune'
const MODEL = 'test-model'
const SMALL: ToolResultPruneConfig = {
thresholdChars: 50,
headChars: 4,
tailChars: 3,
}
function service(config: ToolResultPruneConfig = SMALL): ToolResultPruneService {
return new ToolResultPruneService(new Context(), config)
}
function appendToolStep(
session: Session,
turn: number,
call: string,
content: ContentBlock[],
extra: Record<string, unknown> = {},
): number {
const callId = CallId(call)
session.append('turn/start', {
turn,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn, step: 1 })
session.append('assistant/message', {
turn,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
provenance: { provider: MODEL, model: MODEL },
}, { surfaceOp: 'append' })
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
const result = session.append('tool/result', {
turn,
step: 1,
callId,
content,
isError: false,
...extra,
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
return result.seq
}
describe('tool-result pruning configuration', () => {
it('resolves detached immutable defaults and partial overrides', () => {
const raw = { thresholdChars: 100, headChars: 20, tailChars: 10 }
const resolved = resolveConfig(raw)
raw.headChars = 1
expect(resolved).toEqual({ thresholdChars: 100, headChars: 20, tailChars: 10 })
expect(Object.isFrozen(resolved)).toBe(true)
expect(DEFAULTS).toEqual({ thresholdChars: 8192, headChars: 4096, tailChars: 1024 })
expect(Object.isFrozen(DEFAULTS)).toBe(true)
})
it('rejects stale keys, invalid scalars, and an output budget above threshold', () => {
const bad = [
[{ thresholdChars: 0 }, /thresholdChars .* positive integer/],
[{ headChars: -1 }, /headChars .* non-negative integer/],
[{ tailChars: 1.5 }, /tailChars .* non-negative integer/],
[{ thresholdChars: 50, headChars: 20, tailChars: 20 }, /headChars \+ marker \+ tailChars/],
[{ threshold: 10 }, /unknown key "threshold"/],
] as Array<[unknown, RegExp]>
for (const [config, pattern] of bad) {
expect(() => resolveConfig(config as ToolResultPruneConfig)).toThrow(pattern)
}
})
})
describe('ToolResultPruneService content transform', () => {
it('measures text code points only and skips content within threshold', () => {
const prune = service()
const blocks = [
{ type: 'text', text: 'a😀b' },
{ type: 'reasoning', text: 'not measured' },
] satisfies ContentBlock[]
expect(prune.measureContent(blocks)).toBe(3)
expect(prune.pruneContent(blocks)).toBeNull()
expect(codePointLength('a😀b')).toBe(3)
})
it('keeps configured head and tail without splitting surrogate pairs', () => {
const prune = service()
const result = prune.pruneContent([{ type: 'text', text: '😀'.repeat(60) }])
expect(result).toEqual([{
type: 'text',
text: `${'😀'.repeat(4)}${PRUNE_MARKER}${'😀'.repeat(3)}`,
}])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
expect(result![0]).toMatchObject({ type: 'text' })
expect((result![0] as { text: string }).text).not.toContain('\uFFFD')
})
it('preserves non-text blocks and their relative ordering across removed text', () => {
const prune = service()
const reasoning: ContentBlock = { type: 'reasoning', text: 'private-rich-block' }
const call: ContentBlock = {
type: 'tool-call',
id: CallId('nested'),
name: 'nested',
arguments: '{}',
}
const result = prune.pruneContent([
{ type: 'text', text: 'A'.repeat(40) },
reasoning,
{ type: 'text', text: 'B'.repeat(30) },
call,
{ type: 'text', text: 'C'.repeat(30) },
])
expect(result).toEqual([
{ type: 'text', text: `AAAA${PRUNE_MARKER}` },
reasoning,
call,
{ type: 'text', text: 'CCC' },
])
expect(prune.measureContent(result!)).toBeLessThanOrEqual(50)
})
it('supports zero-sized head and tail while still shrinking', () => {
const prune = service({
thresholdChars: codePointLength(PRUNE_MARKER),
headChars: 0,
tailChars: 0,
})
const result = prune.pruneContent([{ type: 'text', text: 'x'.repeat(100) }])
expect(result).toEqual([{ type: 'text', text: PRUNE_MARKER }])
expect(prune.measureContent(result!)).toBe(prune.config.thresholdChars)
})
})
describe('ToolResultPruneService session transaction', () => {
it('prunes a stable snapshot, preserves all data, and records provenance', () => {
const session = new Session(SessionId('preserve'))
const originalSeq = appendToolStep(session, 1, 'one', [{
type: 'text',
text: 'x'.repeat(100),
}], {
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
})
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const result = service().pruneSession(session)
expect(result.pruned).toHaveLength(1)
expect(result.charsRemoved).toBeGreaterThan(0)
const entry = result.pruned[0]!
expect(entry).toMatchObject({ originalSeq, callId: CallId('one'), charsBefore: 100 })
expect(entry.charsAfter).toBeLessThanOrEqual(50)
const original = session.events[originalSeq]!
const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
expect(original).toMatchObject({
type: 'tool/result',
data: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
})
expect(replacement).toMatchObject({
type: 'tool/result',
data: {
turn: 1,
step: 1,
callId: CallId('one'),
isError: true,
error: { name: 'ExitError', code: 'EXIT_1' },
meta: { diff: ['a', 'b'] },
futureField: { nested: true },
},
surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq },
sourceEventSeqs: [originalSeq],
})
expect(session.surface.nodes).not.toContain(originalSeq)
})
it('prunes multiple results, skips short ones, and converges in one pass', () => {
const session = new Session(SessionId('multiple'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
appendToolStep(session, 2, 'b', [{ type: 'text', text: 'short' }])
appendToolStep(session, 3, 'c', [{ type: 'text', text: 'C'.repeat(80) }])
session.append('turn/start', {
turn: 4,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const prune = service()
const first = prune.pruneSession(session)
const second = prune.pruneSession(session)
expect(first.pruned.map(entry => entry.callId)).toEqual([CallId('a'), CallId('c')])
expect(first.charsRemoved).toBe(
first.pruned.reduce((sum, entry) => sum + entry.charsBefore - entry.charsAfter, 0),
)
expect(second).toEqual({ pruned: [], charsRemoved: 0 })
})
it('replays to the identical pruned model messages', () => {
const session = new Session(SessionId('replay'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
service().pruneSession(session)
const replay = new Session(session.id, [...session.events])
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
expect(replay.surface.replaceGeneration).toBe(session.surface.replaceGeneration)
})
it('runs under real invariants between closed steps but not outside a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(Invariants)
const prune = new ToolResultPruneService(ctx, SMALL)
const session = ctx.sessions.create(SessionId('invariants'))
appendToolStep(session, 1, 'a', [{ type: 'text', text: 'A'.repeat(100) }])
expect(() => prune.pruneSession(session)).toThrow(/outside any open turn/)
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
expect(() => prune.pruneSession(session)).not.toThrow()
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../core/session" }
]
}

View File

@@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
1. appends `compact/start` (log-only) — acquires the lock,
2. summarizes the range,
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation**,
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
5. appends `compact/end` (log-only) — releases the lock.
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
@@ -90,5 +90,5 @@ No conversation-cache invalidation. A consumer's auxiliary request can reuse onl
## Known Limitations and Deferred Work
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted.
- **Some single-unit overflow is out of contract** — balanced summary compaction cannot split one indivisible unit. The optional pruning companion can still repair a closed tool pair when text-bearing tool-result bulk is removable; a large non-tool node or a tool unit whose non-prunable remainder is oversized cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.

View File

@@ -527,6 +527,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'toolResultPrune',
summary: 'Deterministic head/middle/tail pruning for current tool-result surface nodes.',
methods: [
{
signature: 'measureContent(blocks: readonly ContentBlock[]): number',
jsDoc: '/**\n * Measure text content in Unicode code points; non-text blocks cost zero.\n * @param blocks - tool-result content to measure.\n * @returns total Unicode code points across text blocks.\n */',
},
{
signature: 'pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null',
jsDoc: '/**\n * Replace an over-budget text middle while retaining rich-block order.\n * Text slicing is by Unicode code point, not UTF-16 code unit, so a retained\n * boundary cannot split a surrogate pair. Grapheme clusters may still split.\n * @param blocks - original tool-result content.\n * @returns pruned content, or `null` when the text is within budget.\n */',
},
{
signature: 'pruneSession(session: Session): PruneResult',
jsDoc: '/**\n * Prune every over-budget tool result from one stable current-surface snapshot.\n * Each replacement preserves the complete event data except for `content`,\n * and points at the shadowed node for durable provenance and replay.\n * @param session - session whose current surface is rewritten.\n * @returns landed replacements and aggregate Unicode-code-point savings.\n * @throws when the session rejects a replacement; replacements committed\n * earlier in the pass remain durable.\n */',
},
],
},
{
key: 'tools',
summary: 'Tool registry and execution pipeline.',
@@ -1221,6 +1239,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PromptSection',
declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}',
},
{
name: 'PrunedEntry',
declaration: 'export interface PrunedEntry {\n readonly originalSeq: number;\n readonly replacementSeq: number;\n readonly callId: CallId;\n readonly charsBefore: number;\n readonly charsAfter: number;\n}',
},
{
name: 'PruneResult',
declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}',
},
{
name: 'ReasoningBlock',
declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}',

View File

@@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, complete replacement coverage, and content-only single-result `tool/result` rewrites, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
- `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback.
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite.
@@ -49,7 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent``{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log.
### Request-header reconstruction (`request-header.ts`)
@@ -79,7 +79,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous current-format log and rebuilds its surface; request headers require provider/model and assistant messages require provider/model provenance. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface entries behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership and `replaceGeneration`.
- Compaction: `dsh-compact-basic` appends a `user/message` replacement for summary checkpoints, while `dsh-compact-tool-result-prune` appends a content-only `tool/result` replacement. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns ordered surface membership, replacement validation, and `replaceGeneration`.
## Model Experience

View File

@@ -5,6 +5,7 @@
* @module @deepseek-ai/dsh-session/surface
*/
import { isDeepStrictEqual } from 'node:util'
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/** Runtime counterpart of the message-producing event union. */
@@ -187,11 +188,37 @@ function replacementRange(
}
}
/** Restrict a tool-result replacement to one current result's content. */
function assertToolResultRewrite(
event: SessionEvent,
shadowedSeqs: readonly number[],
events: readonly SessionEvent[],
): void {
if (event.type !== 'tool/result') return
if (shadowedSeqs.length !== 1) {
throw new Error('tool/result surface replacement must rewrite exactly one current node')
}
for (const originalSeq of shadowedSeqs) {
const original = events[originalSeq]
if (original?.type !== 'tool/result') {
throw new Error('tool/result surface replacement must target a current tool/result')
}
const originalRest = { ...original.data } as Record<string, unknown>
const replacementRest = { ...event.data } as Record<string, unknown>
delete originalRest['content']
delete replacementRest['content']
if (!isDeepStrictEqual(originalRest, replacementRest)) {
throw new Error('tool/result surface replacement may change only content')
}
}
}
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
function planSurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
): SurfacePlan | undefined {
if (event.seq !== expectedSeq) {
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
@@ -204,6 +231,7 @@ function planSurfaceEvent(
}
const range = replacementRange(state, surfaceOp)
assertProvenance(event, range.shadowedSeqs)
assertToolResultRewrite(event, range.shadowedSeqs, events)
return {
kind: 'replace',
seq: event.seq,
@@ -218,8 +246,9 @@ function applySurfaceEvent(
state: SurfaceFoldState,
event: SessionEvent,
expectedSeq: number,
events: readonly SessionEvent[],
): SurfaceFoldReplacement | undefined {
const plan = planSurfaceEvent(state, event, expectedSeq)
const plan = planSurfaceEvent(state, event, expectedSeq, events)
if (plan?.kind === 'append') {
state.nodes.push(plan.seq)
} else if (plan?.kind === 'replace') {
@@ -239,13 +268,13 @@ function applySurfaceEvent(
* Replay a complete session log through the canonical surface fold.
* @param events - session events in contiguous seq order.
* @returns detached current sequences and replacement history.
* @throws when an event violates surface metadata, provenance, or range rules.
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
*/
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
const state = createFoldState()
const replacements: SurfaceFoldReplacement[] = []
for (const [index, event] of events.entries()) {
const replacement = applySurfaceEvent(state, event, index)
const replacement = applySurfaceEvent(state, event, index, events)
if (replacement !== undefined) replacements.push(replacement)
}
return { nodes: [...state.nodes], replacements }
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
*/
validateNext(event: SessionEvent): void {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
planSurfaceEvent(this._state, event, this.log.length)
planSurfaceEvent(this._state, event, this.log.length, this.log)
}
/** Monotonic count of folded positional replacements. */
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
applySurfaceEvent(this._state, this.log[i]!, i)
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
this._lastProcessedSeq = i
}
}

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

View File

@@ -114,7 +114,7 @@ When optional consumers are loaded, ACP form answers become the exact JSON shape
#### Token effect
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens.
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
#### KV Cache effect

View File

@@ -82,7 +82,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From `tool/result` via `presentResult`. |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated``{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ❌ | ✅ | ✅ | No slash commands advertised. |
| `current_mode_update` | S | ❌ | ✅ | ✅ | No session modes. |

View File

@@ -1039,7 +1039,8 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void {
* loaded transcript reconstructs the USER side of each turn without echoing
* a live `session/prompt` back to the client
* - `tool/call` → `tool_call` (pending)
* - `tool/result` → `tool_call_update` (completed/failed)
* - appended `tool/result` → `tool_call_update` (completed/failed)
* - replacement `tool/result` → no update (context rewrite, not execution)
*
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
@@ -1100,6 +1101,10 @@ export function streamSessionEventUpdate(
return
}
case 'tool/result': {
// Replacements (for example model-free pruning) are transcript rewrites,
// not repeated tool executions. Re-presenting one would consume no
// pending call and could clobber the original terminal/diff completion.
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
return

View File

@@ -161,6 +161,53 @@ describe('acp bridge — session/load replay', () => {
expect(meta.terminal_exit?.exit_code).toBe(0)
})
it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
const session = live.ctx.agents.get(SessionId(sessionId))!.session
const original = session.events.find(event => event.type === 'tool/result')
if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
const liveCompletions = () => live!.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(liveCompletions()).toHaveLength(1)
expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// The replacement is durable but is not another live completion.
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
expect(liveCompletions()).toHaveLength(1)
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const replayed = loader.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(replayed).toHaveLength(1)
expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
// or the bridge's post-await guard fires, no agent may survive for the dead connection.

View File

@@ -105,6 +105,22 @@ describe('streamSessionEventUpdate', () => {
expect((failed[0] as { status: string }).status).toBe('failed')
})
it('emits no execution update for a tool-result surface replacement', () => {
const replacement = {
...evt('tool/result', {
turn: 1,
step: 1,
callId: CallId('c1'),
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
isError: false,
}),
seq: 2,
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
} as SessionEvent
expect(updatesFor(replacement)).toEqual([])
})
it('drops non-text tool-result content (text-only)', () => {
const update = updatesFor(evt('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
@@ -450,6 +466,16 @@ describe('terminal-card mapping (capability-gated)', () => {
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
const prunedResultEvent = {
...resultEvent,
seq: 2,
data: {
...resultEvent.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
} as SessionEvent
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
@@ -477,6 +503,27 @@ describe('terminal-card mapping (capability-gated)', () => {
})
})
it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
const updates = termUpdates(
termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
true,
'/work/proj',
callEvent,
resultEvent,
prunedResultEvent,
)
expect(updates).toHaveLength(2)
expect(updates[1]).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
_meta: {
terminal_output: { terminal_id: 'c1', data: 'hi\n' },
terminal_exit: { terminal_id: 'c1', exit_code: 0 },
},
})
})
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
@@ -633,17 +680,38 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo
// call-time snippet, then the tool/result carries the tool's computed applied-hunk `meta`,
// which presentResult narrows into a `diff` result card the bridge forwards as `{ type:
// 'diff' }` content blocks. The real tool is required because its result metadata is the contract.
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
it('live/replay translation keeps the applied diff when a pruning rewrite follows', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
// The applied hunk the tool would compute and persist on the result meta.
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const [, resultUpdate] = updatesWith(
const originalResult = evt('tool/result', {
turn: 1,
step: 1,
callId: CallId('e1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta,
})
const replacement = {
...originalResult,
seq: 3,
data: {
...originalResult.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
} as SessionEvent
const updates = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
originalResult,
replacement,
)
expect(updates).toHaveLength(2)
const resultUpdate = updates[1]
expect(resultUpdate).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',

View File

@@ -31,7 +31,7 @@ Each non-empty terminal line outside an active question becomes one text block,
#### Token effect
Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens.
Submitted text is retained under the agent loop's normal session-history and compaction rules. The welcome banner, `> ` prompt, rendered transcript, and `[tool call]` / `[tool result]` terminal lines add no tokens. A replacement `tool/result` remains model-visible through the session surface but is not rendered as a second execution; stdio keeps the original full-fidelity result line.
#### KV Cache effect

View File

@@ -145,6 +145,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
inReasoning = false
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
// A surface replacement changes future model context; it is not another
// execution. Keep the original full-fidelity terminal presentation and
// suppress duplicate output during live delivery or log replay.
if (event.surfaceOp !== undefined && event.surfaceOp !== 'append') return
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)

View File

@@ -367,6 +367,43 @@ describe('createStdioChat rendering', () => {
expect(out.text()).toContain('[tool result] file.txt')
})
it('renders one full-fidelity result whether the event feed is live or replayed', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
const original = {
type: 'tool/result',
seq: 2,
time: 0,
data: {
turn: 1,
step: 1,
callId: 'c1',
content: [{ type: 'text', text: 'full terminal output' }],
isError: false,
meta: { terminal: { output: 'full terminal output' } },
},
surfaceOp: 'append',
} as SessionEvent
const replacement = {
...original,
seq: 3,
data: {
...original.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
},
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
} as SessionEvent
// Stdio consumes the same session/event shape whether a host forwards a
// live append or replays a stored log through the rendering feed.
for (const event of [original, replacement]) ctx.emit('session/event', session, event)
expect(out.text().match(/\[tool result\]/g)).toHaveLength(1)
expect(out.text()).toContain('full terminal output')
expect(out.text()).not.toContain('tool result middle pruned')
})
it('renders a todo/write session event as a glyphed checklist', async () => {
const { ctx, out } = await setup()
const session = {} as Session