mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(llm): route adapters by provider
This commit is contained in:
@@ -11,13 +11,13 @@ The abstract contract states only WHAT compaction does; this backend owns every
|
||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). The pressure gate estimates the NEXT request via `estimatePressure()`: the session prefix (the `agent/session-prefix` product — composed by the loop BEFORE the pre-step seam and handed through it, so the gate counts the prefix this instance will actually send in front of the history, never a stale logged one) + the derived history + the system prompt.
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
||||
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the model comes from `summarizationModel` falling back to the agent's own, and per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request is a direct one-shot `ctx.llm.stream()` call — NOT a loop step, so it does not run `agent/request` (that seam shapes the loop's conversation requests); the target comes from the explicit `summarizationProvider`+`summarizationModel` pair, otherwise the latest logged request pair, otherwise the agent pair. Per-call routing happens at `llm/stream` like any other direct call. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
|
||||
- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`.
|
||||
|
||||
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.
|
||||
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, provider, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
@@ -28,7 +28,8 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju
|
||||
| `contextWindow` | yes | Context window size in tokens. |
|
||||
| `thresholdRatio` | yes | Compact when estimated usage exceeds this fraction of the window. |
|
||||
| `retainTokens` | yes | Tokens of recent context to keep intact. |
|
||||
| `summarizationModel` | yes | Model for summarization (`''` → use the agent's model). |
|
||||
| `summarizationProvider` | yes | Provider for summarization (`''` together with an empty model → use the latest logged request pair, then the agent pair). |
|
||||
| `summarizationModel` | yes | Model for summarization (`''` together with an empty provider → use the latest logged request pair, then the agent pair). |
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
@@ -48,6 +49,7 @@ export function apply(ctx: Context): void {
|
||||
contextWindow: 128000,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
|
||||
@@ -294,8 +294,8 @@ export class BasicCompactService extends CompactService {
|
||||
* loop step: it does not run the `agent/request` waterfall (that seam shapes
|
||||
* the loop's conversation requests); per-call
|
||||
* interception happens at `llm/stream` like any other direct call. The model
|
||||
* comes from `BasicCompactConfig.summarizationModel`, falling back to the
|
||||
* agent's own model.
|
||||
* target comes from the explicit summarization provider/model pair, falling
|
||||
* back to the last logged request target and then the agent's creation options.
|
||||
* Override in a subclass for a template or remote summarizer.
|
||||
*
|
||||
* Honors the adapter failure contract: an adapter may report a model failure
|
||||
@@ -307,23 +307,27 @@ export class BasicCompactService extends CompactService {
|
||||
* down the in-flight summarization rather than orphaning the model call.
|
||||
*
|
||||
* Returns the summary blocks TOGETHER with the call envelope it actually
|
||||
* used (`model`, `maxTokens`) — the caller logs the envelope on the
|
||||
* used (`provider`, `model`, `maxTokens`) — the caller logs the envelope on the
|
||||
* `compact/summary` provenance event, so an overriding subclass (template
|
||||
* or remote summarizer) reports its own envelope honestly.
|
||||
*
|
||||
* @param text - plain-text rendering of the conversation region to condense.
|
||||
* @param agent - supplies the fallback model and the session id stamped on
|
||||
* the call; throws when neither it nor the config names a model.
|
||||
* @param agent - supplies the request-header/creation fallback target and the
|
||||
* session id stamped on the call; throws when no complete target exists.
|
||||
* @param signal - optional abort signal, forwarded into the model call.
|
||||
* @returns the text-only summary blocks plus the call envelope used
|
||||
* (`model`, and `maxTokens` when the summarizer has a cap).
|
||||
* (`provider`, `model`, and `maxTokens` when the summarizer has a cap).
|
||||
*/
|
||||
async summarize(
|
||||
text: string, agent: Agent, signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
const assembler = new BlockAssembler()
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
const provider = this.config.summarizationProvider || logged?.provider || agent.options.provider || ''
|
||||
const model = this.config.summarizationModel || logged?.model || agent.options.model || ''
|
||||
const options: GenerateOptions = {
|
||||
model: this.config.summarizationModel || agent.options.model || '',
|
||||
provider,
|
||||
model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
@@ -335,8 +339,8 @@ export class BasicCompactService extends CompactService {
|
||||
// exactOptionalPropertyTypes: only set `signal` when present — assigning
|
||||
// `undefined` to an optional `signal?: AbortSignal` is a type error.
|
||||
if (signal) options.signal = signal
|
||||
if (!options.model) {
|
||||
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
|
||||
if (!options.provider || !options.model) {
|
||||
throw new Error('no provider/model available for summarization: set both summarization fields or provide a logged/agent target')
|
||||
}
|
||||
for await (const chunk of this.ctx.llm.stream(options)) {
|
||||
assembler.push(chunk)
|
||||
@@ -353,7 +357,7 @@ export class BasicCompactService extends CompactService {
|
||||
// config.maxTokens is required and validated positive, so this backend's
|
||||
// envelope always carries the cap; the return type's optionality exists
|
||||
// for overriding subclasses whose summarizer has none.
|
||||
return { summary, model: options.model, maxTokens: this.config.maxTokens }
|
||||
return { summary, provider: options.provider, model: options.model, maxTokens: this.config.maxTokens }
|
||||
}
|
||||
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
@@ -511,7 +515,7 @@ export class BasicCompactService extends CompactService {
|
||||
try {
|
||||
// --- Extract text and summarize ---
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
|
||||
const { summary, provider, model, maxTokens } = await this.summarize(text, agent, signal)
|
||||
|
||||
// Estimate token count of the shadowed content for provenance.
|
||||
let shadowedTokenCount = 0
|
||||
@@ -533,6 +537,7 @@ export class BasicCompactService extends CompactService {
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
provider,
|
||||
model,
|
||||
...maxTokens !== undefined ? { maxTokens } : {},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,9 @@ export interface BasicCompactConfig {
|
||||
thresholdRatio: number
|
||||
/** Number of tokens of recent context to retain during compaction. */
|
||||
retainTokens: number
|
||||
/** Model to use for summarization (`''` — uses the agent's model). */
|
||||
/** Provider to use for summarization (`''` with an empty model inherits the conversation target). */
|
||||
summarizationProvider: string
|
||||
/** Model to use for summarization (`''` with an empty provider inherits the conversation target). */
|
||||
summarizationModel: string
|
||||
/** Provider generation cap for the summarization call. */
|
||||
maxTokens: number
|
||||
@@ -70,6 +72,12 @@ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
if (typeof resolved.summarizationProvider !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider must be a string.')
|
||||
}
|
||||
if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider and summarizationModel must both be empty or both be set.')
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean.')
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const TEST_CONFIG: BasicCompactConfig = {
|
||||
contextWindow: 128000,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 20480,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
@@ -58,13 +59,17 @@ class TestCompactService extends BasicCompactService {
|
||||
return blocks.length * 10
|
||||
}
|
||||
|
||||
override async summarize(text: string, agent: Agent): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
override async summarize(
|
||||
text: string,
|
||||
agent: Agent,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
const provider = this.config.summarizationProvider || agent.options.provider || ''
|
||||
const model = this.config.summarizationModel || agent.options.model || ''
|
||||
this.summarizeCalls.push({ text, model })
|
||||
if (this.summarizeError) throw this.summarizeError
|
||||
const summary = this.mockSummaryQueue.shift() ?? this.mockSummary
|
||||
this.summaryOutputs.add(summary)
|
||||
return { summary, model }
|
||||
return { summary, provider, model }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +107,7 @@ function multiTurnSession(turns: number, messagesPerTurn: number = 2, opts: { le
|
||||
content: [{ type: 'text', text: `turn ${t} user message ${m + 1}.${LONG_FIXTURE_TEXT}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: t, step: 1,
|
||||
content: [{ type: 'text', text: `turn ${t} assistant response ${m + 1}.${LONG_FIXTURE_TEXT}` }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -127,7 +132,7 @@ function sessionWithTools(): Session {
|
||||
content: [{ type: 'text', text: 'read file x' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me read that file.' },
|
||||
@@ -140,7 +145,7 @@ function sessionWithTools(): Session {
|
||||
content: [{ type: 'text', text: 'hello world' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'text', text: 'The file contains: hello world' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -169,7 +174,7 @@ function toolTurnSession(turns: number): Session {
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('step/start', { turn: t, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: t, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: `turn ${t} calling tool` },
|
||||
@@ -240,7 +245,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
|
||||
const s = new Session(SessionId('one-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', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -286,7 +291,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -348,7 +353,7 @@ describe('BasicCompactService.estimateEventTokens', () => {
|
||||
const userEvent: SessionEvent = { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } }
|
||||
expect(svc.estimateEventTokens(userEvent)).toBe(10)
|
||||
|
||||
const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } }
|
||||
const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }], provenance: { provider: 'mock', model: 'mock' } } }
|
||||
expect(svc.estimateEventTokens(asstEvent)).toBe(20)
|
||||
|
||||
const toolEvent: SessionEvent = { type: 'tool/result', seq: 2, time: 3, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'output' }], isError: false } }
|
||||
@@ -633,7 +638,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'do a big multi-step task' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
for (let step = 1; step <= 5; step++) {
|
||||
s.append('step/start', { turn: 1, step })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step,
|
||||
content: [{ type: 'text', text: `step ${step}` }, { type: 'tool-call', id: CallId(`c${step}`), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -686,7 +691,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
// the fresh nodes are retained.
|
||||
s.append('step/start', { turn: 5, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'turn 5 work' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 5, step: 1 })
|
||||
|
||||
const second = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
|
||||
@@ -785,7 +790,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
|
||||
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: 'turn 1' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply 1' }] }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'reply 1' }] }, { surfaceOp: 'append' })
|
||||
s.append('compact/start', { turn: 1 }) // ← orphaned: no matching compact/end
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) // repair closed the turn
|
||||
@@ -991,7 +996,7 @@ async function ctxWithFinish(reason: (StreamChunk & { type: 'finish' })['reason'
|
||||
|
||||
/** A minimal Agent stub carrying just session + options (enough for the listeners). */
|
||||
function stubAgent(session: Session, model?: string): Agent {
|
||||
return { session, options: { model } } as unknown as Agent
|
||||
return { session, options: { provider: model, model } } as unknown as Agent
|
||||
}
|
||||
|
||||
function compactIfNeeded(
|
||||
@@ -1076,7 +1081,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
|
||||
it('throws when no model is provided', async () => {
|
||||
const { ctx } = await ctxWithModel('x')
|
||||
const svc = new BasicCompactService(ctx, cfg({ auto: false }))
|
||||
await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/)
|
||||
await expect(summarize(svc, 'text', '')).rejects.toThrow(/no provider\/model available/)
|
||||
})
|
||||
|
||||
it('rethrows when the stream ends with a finish-error chunk', async () => {
|
||||
@@ -1153,7 +1158,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'tiny user' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'tiny assistant' }] }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'tiny assistant' }] }, { 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' } } })
|
||||
@@ -1262,9 +1267,10 @@ describe('BasicCompactService auto-compaction (agent/pre-step listener)', () =>
|
||||
// The summarize call is a direct one-shot model call, not a loop step: it
|
||||
// does not run agent/request (that seam shapes the loop's conversation
|
||||
// requests). llm/stream is its interception surface, and a hand-built
|
||||
// request is not frozen, so mutate-then-next model routing works — the
|
||||
// adapter resolves AFTER the waterfall, so the rewrite picks the adapter.
|
||||
// request is not frozen, so mutate-then-next provider/model routing works —
|
||||
// the adapter resolves AFTER the waterfall, so the rewrite picks it.
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
options.provider = 'routed-model'
|
||||
options.model = 'routed-model'
|
||||
return next()
|
||||
})
|
||||
@@ -1307,7 +1313,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)',
|
||||
content: [{ type: 'text', text: 'project context here' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'reasoning', text: 'thinking hard' }, { type: 'text', text: 'answer' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -1335,7 +1341,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)',
|
||||
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: 'run it' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c9'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -1364,7 +1370,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
// assistant/message carrying a nested tool-result block, an unknown block,
|
||||
// and the tool-call that the following tool/result answers (so the surface
|
||||
// is tool-pairing balanced).
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] },
|
||||
@@ -1427,7 +1433,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
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: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const nodes = s.surface.nodes
|
||||
@@ -1522,7 +1528,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
// nothing and are skipped.
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'reasoning', text: '' }] }, { surfaceOp: 'append' })
|
||||
s.append('context/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -1531,7 +1537,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
// surface stays tool-pairing balanced; its text extracts to the tool-call
|
||||
// placeholder (the one surviving line).
|
||||
s.append('step/start', { turn: 1, step: 2 })
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 2,
|
||||
content: [{ type: 'tool-call', id: CallId('z1'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
@@ -1562,7 +1568,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with a plugin-added block AND the tool-call its
|
||||
// tool/result answers (so the surface is tool-pairing balanced).
|
||||
s.append('assistant/message', {
|
||||
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
chart('z'),
|
||||
@@ -1713,7 +1719,7 @@ describe('BasicCompactService under the real invariants plugin', () => {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('user/message', { content: [{ type: 'text', text: `turn ${turn} user.${LONG_FIXTURE_TEXT}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant.${LONG_FIXTURE_TEXT}` }] }, { surfaceOp: 'append' })
|
||||
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn, step: 1, content: [{ type: 'text', text: `turn ${turn} assistant.${LONG_FIXTURE_TEXT}` }] }, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ class ReproCompactService extends BasicCompactService {
|
||||
return blocks.length * TOKENS_PER_BLOCK
|
||||
}
|
||||
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
|
||||
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
|
||||
override async summarize(): Promise<{ summary: ContentBlock[]; provider: string; model: string }> {
|
||||
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], provider: 'mock', model: 'stub' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
contextWindow: 64,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 20,
|
||||
summarizationProvider: '',
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
@@ -119,7 +120,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(AgentId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user