mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge master into codex/simp-session-log-representation
This commit is contained in:
@@ -245,7 +245,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
@@ -255,7 +255,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
@@ -267,7 +267,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -277,7 +277,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com
|
||||
|
||||
Types: [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/index.ts:36`](../../packages/compact/compact/src/index.ts)
|
||||
Source: [`packages/compact/compact/src/index.ts:37`](../../packages/compact/compact/src/index.ts)
|
||||
|
||||
## `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
@@ -219,7 +219,7 @@ list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:557`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:556`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
|
||||
@@ -53,3 +53,5 @@ interface CompactionResult {
|
||||
`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy.
|
||||
|
||||
Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details.
|
||||
|
||||
The seam exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for those edge checks. Both validate current surface membership and reject missing seqs and orphan results; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics.
|
||||
|
||||
@@ -192,7 +192,7 @@ Required for `SurfaceEventType` events — every message-producing event must de
|
||||
|
||||
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
|
||||
|
||||
`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. `SurfaceManager` uses the same transitions for its incremental cache without retaining replacement history.
|
||||
`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. `SurfaceManager` uses the same transitions for its incremental cache without retaining replacement history. Its `replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite.
|
||||
|
||||
```ts type-equiv
|
||||
export interface SurfaceFoldReplacement {
|
||||
|
||||
@@ -25,10 +25,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
@@ -52,7 +52,7 @@ The loop derives messages once after `agent/pre-step`. Running before `step/star
|
||||
|
||||
Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed.
|
||||
|
||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||
|
||||
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
|
||||
|
||||
@@ -113,7 +113,7 @@ Two failure paths, both documented:
|
||||
- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred.
|
||||
- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature.
|
||||
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
|
||||
- **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused.
|
||||
- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation.
|
||||
- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged.
|
||||
- **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas.
|
||||
|
||||
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
||||
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads either link: compact's tool-pairing balance answers from per-cut balances cached in surface order. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
||||
|
||||
The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid.
|
||||
|
||||
@@ -14,7 +14,7 @@ The implementation retains append and replacement `sourceEventSeqs`, crash-repai
|
||||
|
||||
## Decision
|
||||
|
||||
`SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. The complete `foldSurface()` read used by session-query returns the same number-array representation plus replacement metadata without making the incremental manager retain history. Tool-pairing balance and compaction use array values and indices for successor and replacement ranges.
|
||||
`SurfaceManager.nodes` is a `readonly number[]` of event sequences; the public `SurfaceNode` shape, node links, and seq-to-node map are removed. The internal replace-generation signal remains. The complete `foldSurface()` read used by session-query returns the same number-array representation plus replacement metadata without making the incremental manager retain history. Tool-pairing balance and compaction use event sequences and surface positions; the compact-owned per-cut balance cache does not depend on node links.
|
||||
|
||||
Request headers use canonical full snapshots only. Initial and resume anchors remain full snapshots even when unchanged; an in-instance change appends another full `request/header` with reason `change`. The delta event, codec types, diff/apply helpers, and codec-only `fallback` reason are removed. Request reconstruction selects the latest snapshot.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Split the checkpoint into two classes and make shadowed history reachable.
|
||||
|
||||
### Frozen index checkpoints
|
||||
|
||||
Newly stale history splits into chunks by deterministic policy: accumulate toward `chunkTokens`, snap edges to balanced tool-pairing cuts (`isToolPairingBalanced`), prefer turn boundaries, and place the final boundary as close to the retain boundary as balance allows, so the trailing slice shrinks to roughly one turn. Each chunk is compacted by one `compactRegion` call into an **index stub** (`stubTokens`, ~100–200 tokens):
|
||||
Newly stale history splits into chunks by deterministic policy: accumulate toward `chunkTokens`, snap edges with `toolPairingBalancedBefore` / `toolPairingBalancedAfter`, prefer turn boundaries, and place the final boundary as close to the retain boundary as balance allows, so the trailing slice shrinks to roughly one turn. Each chunk is compacted by one `compactRegion` call into an **index stub** (`stubTokens`, ~100–200 tokens):
|
||||
|
||||
- two or three lines of what happened;
|
||||
- a keyword line of low-frequency literal anchors — exact error strings, values, config keys — grouped by kind;
|
||||
|
||||
@@ -9,7 +9,7 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. 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.
|
||||
- **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.
|
||||
- **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 model and cap 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.
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
@@ -348,14 +347,14 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
// Both range edges must preserve assistant tool-call/result pairing.
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const startSeq = nodes[startIdx]!
|
||||
if (!toolPairingBalancedBefore(session, startSeq)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
const afterEnd = nodes[endIdx + 1] ?? null
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const endSeq = nodes[endIdx]!
|
||||
if (!toolPairingBalancedAfter(session, endSeq)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
@@ -508,7 +507,7 @@ export class BasicCompactService extends CompactService {
|
||||
// safe compactable prefix exists.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break
|
||||
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -118,9 +118,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
for (const cp of checkpoints) {
|
||||
const index = nodes.indexOf(cp.seq)
|
||||
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(isToolPairingBalanced(nodes, events, cp.seq),
|
||||
expect(toolPairingBalancedBefore(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
|
||||
expect(isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null),
|
||||
expect(toolPairingBalancedAfter(agent.session, cp.seq),
|
||||
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
@@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
The interface exports `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper validates that the event sequence is in the current surface and answers from balances cached per cut in surface order.
|
||||
|
||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-entry count. An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||
|
||||
## Surface contract
|
||||
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
@@ -67,6 +68,8 @@ export abstract class CompactService extends Service {
|
||||
* balanced so assistant tool calls remain paired with their results. A model-
|
||||
* backed implementation forwards cancellation and rejects active, missing,
|
||||
* reversed, or unbalanced ranges.
|
||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||
* for the edge checks.
|
||||
*
|
||||
* @param session - session to mutate.
|
||||
* @param start - first surface seq, inclusive.
|
||||
|
||||
131
packages/compact/compact/src/tool-pairing.ts
Normal file
131
packages/compact/compact/src/tool-pairing.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content in current
|
||||
* surface order rather than step markers.
|
||||
* @module @deepseek-ai/dsh-compact/tool-pairing
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Incremental balance state for one session surface generation. */
|
||||
interface BalanceCache {
|
||||
/** Surface rewrite generation this state describes. */
|
||||
generation: number
|
||||
/**
|
||||
* Balance of every surface cut in current order: a surface of N sequences has
|
||||
* N + 1 cuts, entry `i` being the cut before sequence `i` and the final entry
|
||||
* the cut after the surface tail.
|
||||
*/
|
||||
cutBalanced: readonly boolean[]
|
||||
/** Current surface position of each event seq, indexing {@link cutBalanced}. */
|
||||
indexBySeq: Map<number, number>
|
||||
/** In-progress tool-call count after the processed surface tail. */
|
||||
inProgressToolCalls: number
|
||||
}
|
||||
|
||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
|
||||
/** Return how one surface event changes the in-progress tool-call count. */
|
||||
function eventDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the event named by a surface sequence. */
|
||||
function eventForSeq(events: readonly SessionEvent[], seq: number): SessionEvent {
|
||||
const event = events[seq]
|
||||
if (event === undefined || event.seq !== seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** Fold surface sequences not yet in the cache into its balance state. */
|
||||
function extendCache(
|
||||
session: Session,
|
||||
cache: BalanceCache,
|
||||
seqs: readonly number[],
|
||||
): BalanceCache {
|
||||
const processed = cache.cutBalanced.length - 1
|
||||
const tail = seqs.slice(processed)
|
||||
// Validate the unseen tail before mutating the live cache, so a corrupt
|
||||
// append cannot leave a partially advanced state behind.
|
||||
const events = session.events
|
||||
const pendingCuts: boolean[] = []
|
||||
let inProgressToolCalls = cache.inProgressToolCalls
|
||||
for (const seq of tail) {
|
||||
inProgressToolCalls += eventDelta(eventForSeq(events, seq))
|
||||
if (inProgressToolCalls < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
pendingCuts.push(inProgressToolCalls === 0)
|
||||
}
|
||||
|
||||
tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
|
||||
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||
cache.inProgressToolCalls = inProgressToolCalls
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Return balance state synchronized with the current session surface. */
|
||||
function balanceCache(session: Session): BalanceCache {
|
||||
const surface = session.surface
|
||||
const seqs = surface.nodes
|
||||
const generation = surface.replaceGeneration
|
||||
const cached = balanceCacheBySession.get(session)
|
||||
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
|
||||
// A rebuild is the same fold started from the empty-surface state, whose
|
||||
// single leading cut is trivially balanced.
|
||||
const rebuilt = extendCache(session, {
|
||||
generation,
|
||||
cutBalanced: [true],
|
||||
indexBySeq: new Map(),
|
||||
inProgressToolCalls: 0,
|
||||
}, seqs)
|
||||
balanceCacheBySession.set(session, rebuilt)
|
||||
return rebuilt
|
||||
}
|
||||
if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Balance of the cut at a sequence's position plus offset, rejecting seqs outside current membership. */
|
||||
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
const index = cache.indexBySeq.get(seq)
|
||||
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
|
||||
if (balanced === undefined) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
|
||||
}
|
||||
return balanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately before a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param seq - event sequence whose leading cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedBefore(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately after a current surface sequence is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param seq - event sequence whose trailing cut is checked.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface sequence has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedAfter(session: Session, seq: number): boolean {
|
||||
return cutBalance(balanceCache(session), seq, 1)
|
||||
}
|
||||
320
packages/compact/compact/tests/tool-pairing.spec.ts
Normal file
320
packages/compact/compact/tests/tool-pairing.spec.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
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 } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return session.events.filter(event => event.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
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, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedAfter(session, surfaceSeq(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
const session = new Session(SessionId('closed-tool-step'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
}, SURFACE)
|
||||
return session
|
||||
}
|
||||
|
||||
describe('tool-pairing boundaries', () => {
|
||||
it('classifies closed and open single-call steps', () => {
|
||||
const closed = closedToolStep()
|
||||
expect(before(closed, 'user/message')).toBe(true)
|
||||
expect(after(closed, 'user/message')).toBe(true)
|
||||
expect(before(closed, 'assistant/message')).toBe(true)
|
||||
expect(after(closed, 'assistant/message')).toBe(false)
|
||||
expect(before(closed, 'tool/result')).toBe(false)
|
||||
expect(after(closed, 'tool/result')).toBe(true)
|
||||
|
||||
const open = new Session(SessionId('open-tool-step'))
|
||||
open.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every result from a multiple-call assistant message', () => {
|
||||
const session = new Session(SessionId('multiple-calls'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
|
||||
expect(after(session, 'tool/result', 0)).toBe(false)
|
||||
expect(after(session, 'tool/result', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
|
||||
const midStep = new Session(SessionId('neutral-mid-step'))
|
||||
midStep.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, SURFACE)
|
||||
midStep.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(before(midStep, 'context/message')).toBe(false)
|
||||
expect(after(midStep, 'context/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
free.append('context/message', {
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(before(free, 'context/message')).toBe(true)
|
||||
expect(after(free, 'context/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing surface identity', () => {
|
||||
it('rebuilds after replace and rejects sequences removed from current membership', () => {
|
||||
const session = closedToolStep()
|
||||
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]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
|
||||
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
})
|
||||
|
||||
it('answers repeated queries from cached balances', () => {
|
||||
const session = closedToolStep()
|
||||
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 = 999
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first node after empty cache' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing cache refresh', () => {
|
||||
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'assistant/message', seq: 1, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 2, time: 2,
|
||||
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: number[] = [0, 1, 2]
|
||||
let generation = 0
|
||||
let eventCollectionReads = 0
|
||||
let eventIndexReads = 0
|
||||
const trackedEvents = new Proxy(events, {
|
||||
get(target, property, receiver) {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
|
||||
return Reflect.get(target, property, receiver) as unknown
|
||||
},
|
||||
})
|
||||
const surface = {
|
||||
get nodes() { return nodes },
|
||||
get replaceGeneration() { return generation },
|
||||
}
|
||||
const session = {
|
||||
surface,
|
||||
get events() {
|
||||
eventCollectionReads += 1
|
||||
return trackedEvents
|
||||
},
|
||||
} as unknown as Session
|
||||
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'turn/end', seq: 3, time: 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 4, time: 4,
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push(4)
|
||||
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(2)
|
||||
expect(eventIndexReads).toBe(4)
|
||||
|
||||
events.push(
|
||||
{
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 6, time: 6,
|
||||
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
nodes.push(5, 6)
|
||||
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(3)
|
||||
expect(eventIndexReads).toBe(6)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 7, time: 7,
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
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 entry count regresses', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: number[] = [0, 1]
|
||||
const session = {
|
||||
events,
|
||||
surface: { nodes, replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
|
||||
nodes.pop()
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing corrupt surfaces', () => {
|
||||
it('throws for an orphan result during a rebuild', () => {
|
||||
const session = new Session(SessionId('orphan-rebuild'))
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('retries an orphan result in an appended tail without committing partial cache state', () => {
|
||||
const session = new Session(SessionId('orphan-tail'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
|
||||
const missingSeq = 1
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(missing, missingSeq)).toThrow(/no matching session event/)
|
||||
|
||||
const mismatchedSeq = 0
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedSeq)).toThrow(/no matching session event/)
|
||||
})
|
||||
})
|
||||
@@ -78,7 +78,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 log and rebuilds its surface. `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.
|
||||
- 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`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { isToolPairingBalanced } from './tool-pairing.ts'
|
||||
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content on the
|
||||
* surface rather than step markers in the append-only log.
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* The tool-pairing delta of a surface node: how it shifts the count of
|
||||
* unanswered tool calls. An `assistant/message` opens one bracket per
|
||||
* `tool-call` block; a `tool/result` closes one; every other surface node
|
||||
* (`user/message`, `context/message`, `steering/message`, a usage-only
|
||||
* `assistant/message` with no tool-call blocks) is pairing-neutral.
|
||||
*/
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
// Non-pairing surface nodes and every non-surface event contribute nothing.
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a surface cut does not split a tool call from its result. A region
|
||||
* is safe to collapse only when both edge cuts return true.
|
||||
*
|
||||
* @param nodes - surface event sequence numbers in head-to-tail order.
|
||||
* @param events - the session log indexed by those sequence numbers.
|
||||
* @param beforeSeq - event immediately after the cut; null or an absent seq means after-tail.
|
||||
* @returns whether every call before the cut is answered before it.
|
||||
* @throws if a result appears without a preceding open call.
|
||||
*/
|
||||
export function isToolPairingBalanced(
|
||||
nodes: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | null,
|
||||
): boolean {
|
||||
let depth = 0
|
||||
for (const seq of nodes) {
|
||||
if (seq === beforeSeq) return depth === 0
|
||||
// seq is a surface event sequence, always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
depth += nodeDelta(events[seq]!)
|
||||
if (depth < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
}
|
||||
// A missing cut sequence means the after-tail boundary.
|
||||
return depth === 0
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
|
||||
import type { SessionEvent } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Tool-pairing cut coverage over real session surfaces, including replacement
|
||||
* nodes whose surface order differs from append-log order.
|
||||
*/
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
/** Surface nodes + log for a session, the two args the balance check takes. */
|
||||
function surfaceOf(session: Session): { nodes: readonly number[]; events: readonly SessionEvent[] } {
|
||||
return { nodes: session.surface.nodes, events: session.events }
|
||||
}
|
||||
|
||||
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
|
||||
function startBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
return isToolPairingBalanced(nodes, events, seq)
|
||||
}
|
||||
|
||||
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
|
||||
function endBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
const index = nodes.indexOf(seq)
|
||||
if (index === -1) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null)
|
||||
}
|
||||
|
||||
/** Surface seq of the nth (0-based) event of a given type. */
|
||||
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return s.events.filter(e => e.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
/** A closed turn with one closed step holding an assistant + its tool result. */
|
||||
function toolStepSession(): Session {
|
||||
const s = new Session(SessionId('tool-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
describe('isToolPairingBalanced — region START (cut before a node)', () => {
|
||||
it('is true for a pre-step user/message (belongs to no step)', () => {
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for the first surface node of a step (the assistant/message)', () => {
|
||||
// The cut before the assistant is balanced — nothing unanswered precedes it.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
|
||||
// The cut before the tool/result has one unanswered tool-call (the
|
||||
// assistant's) → starting the region here would orphan that call.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the surface head (nothing precedes)', () => {
|
||||
const s = new Session(SessionId('lone'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — region END (cut after a node)', () => {
|
||||
it('is true for the last surface node of a closed step (the tool/result)', () => {
|
||||
// After the tool/result the assistant's single call is answered → balanced.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for an assistant/message with a later tool/result in the same step', () => {
|
||||
// After the assistant its tool-call is still unanswered → ending here strands
|
||||
// the result.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for a pre-step user/message', () => {
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false at the tail when the node is inside an open (unclosed) step', () => {
|
||||
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
|
||||
// The after-tail cut still has one unanswered call → not balanced.
|
||||
const s = new Session(SessionId('open-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
|
||||
// A steering message appended after step/end, at the tail. The prior step's
|
||||
// pair is balanced and steering is neutral → the after-tail cut is balanced.
|
||||
const s = new Session(SessionId('trailing-steer'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true at the tail when no step ever opened', () => {
|
||||
const s = new Session(SessionId('no-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
|
||||
// An assistant message with two tool-calls needs BOTH results before the cut
|
||||
// after it is balanced — depth +2, then -1, -1.
|
||||
function twoCallStep(): Session {
|
||||
const s = new Session(SessionId('two-call'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('is unbalanced after the first of two results (one call still open)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
|
||||
})
|
||||
|
||||
it('is balanced after the second result (both calls answered)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
|
||||
// The injected context is pairing-neutral, but both adjacent cuts remain
|
||||
// unbalanced because the tool call is still open across them.
|
||||
function midStepInjection(): Session {
|
||||
const s = new Session(SessionId('mid-inject'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced on an injection turn (no step)', () => {
|
||||
// An idle inject() wraps a context/message in a bare turn/start →
|
||||
// context/message → turn/end with NO step. The context node is a free boundary
|
||||
// both ways (pairing-neutral, nothing open around it).
|
||||
function injectionSession(): Session {
|
||||
const s = new Session(SessionId('injection'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('end: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
|
||||
// A replacement checkpoint has a high log seq but sits at the surface head;
|
||||
// its cuts are balanced regardless of later raw-log neighbors.
|
||||
function checkpointHeadedSession(): Session {
|
||||
const s = new Session(SessionId('checkpoint'))
|
||||
// A closed turn with a tool step → surface [u1, asst(call), result].
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// An OPEN turn whose step is in progress (loop fires compaction here).
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 2, step: 1 })
|
||||
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
|
||||
// summary user/message — appended now, so it carries a high log seq.
|
||||
const u1 = seqOf(s, 'user/message')
|
||||
const result = s.events.find(e => e.type === 'tool/result')!.seq
|
||||
const shadowedSeqs = [...s.surface.nodes]
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'CHECKPOINT' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
|
||||
// The step's own assistant/message lands AFTER the checkpoint in the log,
|
||||
// still inside the open step.
|
||||
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
|
||||
return s
|
||||
}
|
||||
|
||||
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
const nodes = s.surface.nodes
|
||||
const checkpointSeq = nodes[0]!
|
||||
// The checkpoint heads the surface while the open step's assistant follows
|
||||
// it in append-log order.
|
||||
const laterSurfaceInLog = s.events.find(
|
||||
e => e.seq > checkpointSeq && nodes.includes(e.seq),
|
||||
)
|
||||
expect(laterSurfaceInLog).toBeDefined()
|
||||
expect(nodes[0]!).toBe(checkpointSeq)
|
||||
})
|
||||
|
||||
it('start cut before the head checkpoint is balanced (it is the head)', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
expect(startBalanced(s, s.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
|
||||
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
|
||||
// The neutral checkpoint closes no open call at its following surface cut.
|
||||
const s = checkpointHeadedSession()
|
||||
expect(endBalanced(s, s.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — corrupt surface guard', () => {
|
||||
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
|
||||
// A surface that opens with a tool/result (no assistant call before it) is
|
||||
// structurally corrupt — surfaced loudly rather than mis-classified.
|
||||
const s = new Session(SessionId('corrupt'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
|
||||
const { nodes, events } = surfaceOf(s)
|
||||
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
Abstract compaction service. Implementations own token estimation, retention, and summarization, but a successful run must replace the selected surface span with one summary node and prevent concurrent compaction of the same session. Load one implementation per context as `ctx.compact`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L36)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L37)
|
||||
|
||||
### ctx.compact.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||
|
||||
@@ -23,7 +23,7 @@ Check token pressure and compact if the conversation is too large. Estimate the
|
||||
|
||||
**Returns** the compaction result, or `null` if no compaction was needed.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L56)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L57)
|
||||
|
||||
### ctx.compact.compactRegion(session, start, end, agent, signal?)
|
||||
|
||||
@@ -31,7 +31,7 @@ Check token pressure and compact if the conversation is too large. Estimate the
|
||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||
```
|
||||
|
||||
Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges.
|
||||
Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks.
|
||||
|
||||
- `session` — session to mutate.
|
||||
- `start` — first surface seq, inclusive.
|
||||
@@ -41,4 +41,4 @@ Forcibly compact a range of surface nodes into a single summary node. `start` an
|
||||
|
||||
**Returns** the replaced range and summary.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L79)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L82)
|
||||
|
||||
@@ -307,7 +307,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
|
||||
- `session` — the session just entered and announced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L47)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L46)
|
||||
|
||||
### session/disposed
|
||||
|
||||
@@ -321,7 +321,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
|
||||
- `session` — the session that is no longer live in the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L57)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L56)
|
||||
|
||||
### session/event
|
||||
|
||||
@@ -336,7 +336,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
- `session` — the session whose log grew.
|
||||
- `event` — the appended event, exactly as recorded.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L69)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L68)
|
||||
|
||||
### session/flush
|
||||
|
||||
@@ -350,7 +350,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
|
||||
- `session` — the session whose buffered events must reach durable storage.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L79)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L78)
|
||||
|
||||
## subagent/*
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
In-memory session store (`ctx.sessions`).
|
||||
Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L557)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L556)
|
||||
|
||||
### ctx.sessions.create(id?, options?)
|
||||
|
||||
@@ -23,7 +23,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop
|
||||
|
||||
**Returns** the live session, already entered and announced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L586)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L585)
|
||||
|
||||
### ctx.sessions.prepare(id?, options?)
|
||||
|
||||
@@ -38,7 +38,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c
|
||||
|
||||
**Returns** the constructed session, NOT yet in the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L615)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L614)
|
||||
|
||||
### ctx.sessions.enter(session)
|
||||
|
||||
@@ -53,7 +53,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package
|
||||
|
||||
**Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L659)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L658)
|
||||
|
||||
### ctx.sessions.announce(session)
|
||||
|
||||
@@ -65,7 +65,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent
|
||||
|
||||
- `session` — the entered session to announce to listeners.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L714)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L713)
|
||||
|
||||
### ctx.sessions.flush(session)
|
||||
|
||||
@@ -79,7 +79,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t
|
||||
|
||||
**Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L766)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L765)
|
||||
|
||||
### ctx.sessions.get(id)
|
||||
|
||||
@@ -93,7 +93,7 @@ Look up a live session.
|
||||
|
||||
**Returns** the session, or undefined when no live session has that id.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L798)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L797)
|
||||
|
||||
### ctx.sessions.list()
|
||||
|
||||
@@ -105,7 +105,7 @@ All live sessions, in creation order.
|
||||
|
||||
**Returns** a fresh array; mutating it does not affect the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L806)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L805)
|
||||
|
||||
### ctx.sessions.fork(source, boundary?, childSessionId?)
|
||||
|
||||
@@ -121,4 +121,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound
|
||||
|
||||
**Returns** The created live child session.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L823)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L822)
|
||||
|
||||
Reference in New Issue
Block a user