Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui
# Conflicts: # packages/client/ui-primitives/src/Menu.tsx # packages/client/ui-trajectory/src/client/TrajectoryCell.tsx # packages/client/ui-trajectory/src/client/TrajectoryView.tsx # packages/client/ui-trajectory/tests/layout.spec.tsx # pnpm-lock.yaml # pnpm-workspace.yaml
This commit is contained in:
1303 files changed
+41080
-19714
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 2f65b0d8f223c4de8999006d005778e7087e8a4d
|
||||
README.zh.md: c0be5d7dc92a60c649b792bfa181c0df7a12db9f
|
||||
# pnpm run verify-translation-pairing --write packages/compact/compact-basic/README.md
|
||||
README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d
|
||||
README.zh.md: 2f7ccc7dd00fa5599d3d6bbe66e81d3312d38dd9
|
||||
@@ -10,16 +10,16 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Step-boundary pressure therefore includes the actual system prompt, tools, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted.
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure post-step checks never prune.
|
||||
- **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/step` listener checks pressure before request derivation. A canonical provider overflow is offered through `agent/request-error` after the failed step; the plugin compacts there and returns a retry action only after durable surface progress.
|
||||
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational pressure failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.
|
||||
|
||||
The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
|
||||
@@ -38,7 +38,7 @@ Every setting is optional. Top-level policy fields are defaults for every routed
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
|
||||
| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. |
|
||||
| `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. |
|
||||
| `auto` | no (default `true`) | Register step-boundary pressure and overflow-recovery listeners. Set `false` for manual-only. |
|
||||
|
||||
Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry.
|
||||
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
|
||||
该后端拥有压缩策略:
|
||||
|
||||
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤后压力会包含实际系统提示词、工具、前缀、路由、assistant 完成、工具结果、缓冲上下文与 steering。
|
||||
- **测量**:单例 `ctx.tokenMeter` 会在同一个已消费日志 revision 上为最新规范已记录 envelope 与当前表层计价。因此,步骤边界压力会包含实际系统提示词、工具、路由、assistant 完成、工具结果、缓冲上下文与 steering。
|
||||
- **路由策略**:主动压力从拥有最新持久提供方/模型路由的适配器解析容量,再将默认策略与可选的精确目标覆盖缩放为具体 token 预算。模型发现仍只提供建议,不会被咨询。
|
||||
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤后检查绝不剪枝。
|
||||
- **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则摘要已剪枝表层。低于压力的步骤检查绝不剪枝。
|
||||
- **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 保持工具调用/结果 cut 平衡。轮次边界不会保护失控轮次内的旧步骤。开启且不可分的尾部在关闭前会拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。
|
||||
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
|
||||
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent(智能体)目标,而不运行仅用于 loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见主体。只有返回文本会进入检查点;会排除可能泄露私有推理或产生遗留调用的 reasoning 与工具调用。
|
||||
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
|
||||
- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/post-step` listener 会在成功输出与工具工作持久后、`step/end` 之前检查压力。规范提供方溢出会在失败步骤关闭后通过 `agent/request-error` 处理。
|
||||
- **生命周期**:`compactRegion()` 会更改 `agent.session`,并记录开始、摘要、替换与结束。异步摘要后,它会拒绝已改变的表层节点快照,而不相关的仅日志事件可以追加,不会使已选 span 失效。串行 `agent/step` listener 会在派生请求之前检查压力。规范提供方溢出会在失败步骤之后经由 `agent/request-error` 交给本插件;插件在此执行压缩,并且只在表层取得持久进展后才返回重试动作。
|
||||
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、精确目标上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
|
||||
- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性步骤后失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。
|
||||
- **失败处理**:不匹配的 `compact/start` 是惰性崩溃标记,因为没有摘要替换落地。区域失败会记录错误结束;除非剪枝已落地,否则表层保持不变。操作性压力失败会发出警告并继续;只当之前没有替换使表层前进时,溢出恢复失败才保留原始提供方错误。取消在任何进展后仍具有最高权威。
|
||||
|
||||
受保护的 `summarize()` 方法是唯一的子类 hook。基于模板或远程摘要器的子类可以覆盖该方法,同时压力、保留、溯源、缩减验证与已遮蔽 token 计量仍位于 `ctx.tokenMeter`。hook 会将摘要块与它使用的调用 envelope 一并返回(`{ summary, provider, model, maxTokens? }`),并记录在 `compact/summary` 上。
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
| `compactionRetries` | 否(默认 `1`) | 压力仍高于阈值时,在首次尝试后进行的额外尝试次数。 |
|
||||
| `maxOverflowRetries` | 否(默认 `1`) | 规范上下文窗口溢出后的最大重试次数;`0` 只禁用恢复。 |
|
||||
| `modelPolicies` | 否(默认 `[]`) | 精确的 `{ provider, model, ...partialPolicy }` 覆盖;匹配使用两个字段,不依赖 `listModels()`。 |
|
||||
| `auto` | 否(默认 `true`) | 注册步骤后压力与溢出恢复 listener。设为 `false` 则仅手动执行。 |
|
||||
| `auto` | 否(默认 `true`) | 注册步骤边界压力与溢出恢复 listener。设为 `false` 则仅手动执行。 |
|
||||
|
||||
每个 `modelPolicies` 配置项都接受上述策略字段,但不接受 `auto` 和 `modelPolicies` 自身。如果配置项提供任意一个保留字段,就替换默认策略的保留选择;否则继承保留设置。摘要提供方/模型在每个配置项内仍然成对。
|
||||
|
||||
|
||||
@@ -111,6 +111,8 @@ export class BasicCompactService extends CompactService {
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
private readonly warnedPressureConfigTargets = new Set<string>()
|
||||
private readonly overflowRetries = new WeakMap<Agent, number>()
|
||||
private readonly overflowAgents = new WeakMap<Session, Agent>()
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||
super(ctx)
|
||||
@@ -119,8 +121,8 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the automatic post-step pressure and context-overflow recovery
|
||||
* listeners. `compactIfNeeded` stays dynamically dispatched so subclass
|
||||
* Register automatic between-step pressure and model-request overflow
|
||||
* recovery. `compactIfNeeded` stays dynamically dispatched so subclass
|
||||
* overrides are honored at event time.
|
||||
*/
|
||||
private _registerAutomaticCompaction(): void {
|
||||
@@ -133,7 +135,7 @@ export class BasicCompactService extends CompactService {
|
||||
)
|
||||
}
|
||||
|
||||
ctx.on('agent/post-step', async (
|
||||
ctx.on('agent/step', async (
|
||||
agent: Agent,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
@@ -142,35 +144,47 @@ export class BasicCompactService extends CompactService {
|
||||
if (signal.aborted) return
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
if (result !== null) logResult(result, 'post-step pressure')
|
||||
if (result !== null) logResult(result, 'step pressure')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TargetPressureConfigError) {
|
||||
if (this.warnedPressureConfigTargets.has(error.targetKey)) return
|
||||
this.warnedPressureConfigTargets.add(error.targetKey)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
|
||||
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.on('agent/settled', (agent) => {
|
||||
this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
// A successful response starts a fresh overflow-recovery sequence even
|
||||
// when tool calls continue the same turn into another request.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type !== 'assistant/message') return
|
||||
const agent = this.overflowAgents.get(session)
|
||||
if (agent !== undefined) this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
failure,
|
||||
priorFailures,
|
||||
_priorFailures,
|
||||
_retryPolicy,
|
||||
signal,
|
||||
next,
|
||||
) => {
|
||||
const priorOverflowFailures = priorFailures.filter(
|
||||
item => item.code === CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
).length
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
|
||||
this.overflowAgents.set(agent.session, agent)
|
||||
const target = routedTarget(agent.session)
|
||||
if (target === undefined) return next()
|
||||
const policy = resolveTargetPolicy(this.config, target)
|
||||
if (priorOverflowFailures >= policy.maxOverflowRetries) return next()
|
||||
const retries = this.overflowRetries.get(agent) ?? 0
|
||||
if (retries >= policy.maxOverflowRetries) return next()
|
||||
|
||||
const generation = agent.session.surface.replaceGeneration
|
||||
let result: CompactionResult | null
|
||||
@@ -181,27 +195,29 @@ export class BasicCompactService extends CompactService {
|
||||
// A model-free prune can land before later summary work fails. That
|
||||
// durable reduction is sufficient retry proof; do not discard it just
|
||||
// because the optional second phase threw. Cancellation still wins.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
|
||||
if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
|
||||
ctx.logger.warn(
|
||||
`context-overflow compaction failed after durable surface progress: ${message}; `
|
||||
+ 'retrying from the replacement surface',
|
||||
)
|
||||
return { action: 'retry' }
|
||||
this.overflowRetries.set(agent, retries + 1)
|
||||
return { kind: 'retry' }
|
||||
}
|
||||
ctx.logger.warn(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while recovery is awaited.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while recovery is awaited.
|
||||
`context-overflow compaction failed: ${message}; ${signal.aborted
|
||||
? 'cancellation prevents retry'
|
||||
: 'preserving the original request error'}`,
|
||||
)
|
||||
return next()
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- the signal can abort while compaction is awaited.
|
||||
if (signal.aborted
|
||||
|| agent.session.surface.replaceGeneration <= generation) return next()
|
||||
if (result !== null) logResult(result, 'context overflow recovery')
|
||||
return { action: 'retry' }
|
||||
this.overflowRetries.set(agent, retries + 1)
|
||||
return { kind: 'retry' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -228,12 +244,12 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact for replayed post-step pressure or one provider-confirmed context
|
||||
* Compact for replayed step-boundary pressure or one provider-confirmed context
|
||||
* overflow. Both triggers price the latest durable routed request envelope;
|
||||
* overflow bypasses the normal threshold and retained-tail policy so it can
|
||||
* force one useful balanced reduction.
|
||||
* @param agent - agent whose latest durable routed request is measured.
|
||||
* @param trigger - normal post-step pressure or context-overflow recovery.
|
||||
* @param trigger - normal step-boundary pressure or context-overflow recovery.
|
||||
* @param signal - live turn cancellation signal forwarded to summarization.
|
||||
* @returns the latest summary compaction result, or `null` when no summary ran.
|
||||
*/
|
||||
|
||||
@@ -182,10 +182,10 @@ export async function compactSurfaceRegion(
|
||||
|
||||
/**
|
||||
* Reconstruct the last routed request's cacheable prefix for the shadowed
|
||||
* region: its system prompt and tool schemas, then the request-only message
|
||||
* prefix followed by the region's own derived messages in surface order. The
|
||||
* summarizer appends only the compaction instruction after this, so the call
|
||||
* is a genuine prefix of the conversation and reuses the provider's KV cache.
|
||||
* region: its system prompt and tool schemas, then the region's own derived
|
||||
* messages in surface order. The summarizer appends only the compaction
|
||||
* instruction after this, so the call is a genuine prefix of the conversation
|
||||
* and reuses the provider's KV cache.
|
||||
* @param session - session supplying the request header and per-node projection.
|
||||
* @param shadowedSeqs - the surface-node seqs, in order, being compacted.
|
||||
* @returns the replayed conversation prefix to condense.
|
||||
@@ -204,7 +204,7 @@ function buildSummarizationInput(
|
||||
return {
|
||||
...header?.system === undefined ? {} : { system: header.system },
|
||||
...header?.tools === undefined ? {} : { tools: header.tools },
|
||||
messages: [...header?.messagePrefix ?? [], ...regionMessages],
|
||||
messages: regionMessages,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface SummarizationInput {
|
||||
readonly system?: string
|
||||
/** The conversation's tool schemas, reused for prefix-cache alignment; absent when the request carried none. */
|
||||
readonly tools?: readonly ToolSchema[]
|
||||
/** The request prefix followed by the shadowed region, in surface order, that precedes the compaction instruction. */
|
||||
/** The shadowed region, in surface order, that precedes the compaction instruction. */
|
||||
readonly messages: readonly Message[]
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface ModelCompactPolicyConfig extends CompactPolicyConfig {
|
||||
export interface BasicCompactConfig extends CompactPolicyConfig {
|
||||
/** Exact provider/model overrides; duplicate targets fail plugin load. */
|
||||
modelPolicies?: ModelCompactPolicyConfig[]
|
||||
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||
/** Enable automatic step-boundary pressure and overflow-recovery listeners. Defaults to `true`. */
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, type Agent, type RequestErrorAction } from '@deepseek-ai/dsh-agent'
|
||||
import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
|
||||
const SIGNAL = new AbortController().signal
|
||||
@@ -76,7 +76,10 @@ function createContext(contextWindow = 1_000): Context {
|
||||
}
|
||||
|
||||
function agent(session: Session, model?: string): Agent {
|
||||
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
|
||||
return {
|
||||
session,
|
||||
options: model === undefined ? {} : { provider: model, model },
|
||||
} as Agent
|
||||
}
|
||||
|
||||
/** Flatten every text fragment the summarizer received, recursing tool-result blocks. */
|
||||
@@ -568,7 +571,7 @@ describe('pressure measurement and retention', () => {
|
||||
expect(session.surface.nodes.length).toBeLessThan(8)
|
||||
})
|
||||
|
||||
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
|
||||
it('counts the durable routed request envelope without putting it on the surface', async () => {
|
||||
const compact = service({
|
||||
auto: false,
|
||||
thresholdRatio: 0.9,
|
||||
@@ -577,22 +580,15 @@ describe('pressure measurement and retention', () => {
|
||||
const session = conversation(2, 'x'.repeat(600))
|
||||
expect(await compactIfNeeded(compact, session)).toBeNull()
|
||||
|
||||
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { provider: MODEL, model: MODEL },
|
||||
system: 's'.repeat(600),
|
||||
messagePrefix: prefix,
|
||||
system: 's'.repeat(2_000),
|
||||
},
|
||||
reason: 'resume',
|
||||
})
|
||||
const result = await compactIfNeeded(compact, session)
|
||||
expect(result).not.toBeNull()
|
||||
expect(prefix).toHaveLength(1)
|
||||
// The routed request prefix must not reach the surface as its own message
|
||||
// (the compaction summary itself is an expected plugin-sourced checkpoint).
|
||||
expect(session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.includes('p'.repeat(600))))).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the latest logged request envelope without an AgentOptions override', async () => {
|
||||
@@ -822,13 +818,12 @@ describe('compaction region transaction', () => {
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
})
|
||||
|
||||
it('replays the latest routed header prefix so the summarizer reuses the cache', async () => {
|
||||
it('replays the latest routed header so the summarizer reuses the cache', async () => {
|
||||
const compact = service()
|
||||
const session = conversation(3)
|
||||
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
||||
const messagePrefix: Message[] = [{ role: 'user', content: [{ type: 'text', text: 'SESSION PREFIX' }] }]
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools, messagePrefix },
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'CONVERSATION SYSTEM', tools },
|
||||
reason: 'resume',
|
||||
})
|
||||
const nodes = session.surface.nodes
|
||||
@@ -837,7 +832,6 @@ describe('compaction region transaction', () => {
|
||||
const { input } = compact.calls[0]!
|
||||
expect(input.system).toBe('CONVERSATION SYSTEM')
|
||||
expect(input.tools).toEqual(tools)
|
||||
expect(input.messages[0]).toEqual(messagePrefix[0])
|
||||
expect(summarizedText(input)).toContain('fixture user 1')
|
||||
})
|
||||
|
||||
@@ -1289,22 +1283,21 @@ describe('default one-shot summarizer', () => {
|
||||
|
||||
describe('automatic listener and loader composition', () => {
|
||||
function postStep(ctx: Context, owner: Agent, signal = SIGNAL): Promise<unknown> {
|
||||
return agentEvents(ctx, owner).serial('agent/post-step', 1, 1, signal)
|
||||
return agentEvents(ctx, owner).serial('agent/step', 1, 1, signal)
|
||||
}
|
||||
|
||||
function recover(
|
||||
ctx: Context,
|
||||
owner: Agent,
|
||||
error: Error & { code?: string },
|
||||
retryAttempt = 0,
|
||||
signal = SIGNAL,
|
||||
next: () => Promise<{ action: 'fail' | 'retry' }> = () => Promise.resolve({ action: 'fail' }),
|
||||
): Promise<{ action: 'fail' | 'retry' }> {
|
||||
next: () => Promise<RequestErrorAction> = () => Promise.resolve(undefined),
|
||||
): Promise<boolean> {
|
||||
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
|
||||
const priorFailures = Object.freeze(Array.from({ length: retryAttempt }, () => failure))
|
||||
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
|
||||
return agentEvents(ctx, owner).waterfall(
|
||||
'agent/request-error', 1, 1, error, failure, priorFailures, signal, next,
|
||||
)
|
||||
'agent/request-error', turn, 1, error, failure, [], undefined, signal, next,
|
||||
).then(action => action?.kind === 'retry')
|
||||
}
|
||||
|
||||
function overflow(message = 'provider overflow'): Error & { code: string } {
|
||||
@@ -1413,7 +1406,7 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
|
||||
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
|
||||
|
||||
expect(decision).toEqual({ action: 'retry' })
|
||||
expect(decision).toBe(true)
|
||||
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(session.surface.nodes).toContain(retainedSeq)
|
||||
@@ -1432,7 +1425,7 @@ describe('automatic listener and loader composition', () => {
|
||||
})
|
||||
const session = oversizedToolResult()
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
@@ -1451,7 +1444,7 @@ describe('automatic listener and loader composition', () => {
|
||||
})
|
||||
const session = toolConversation()
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
expect(summarizedText(compact.calls[0]!.input)).toContain('tool result middle pruned')
|
||||
@@ -1473,7 +1466,7 @@ describe('automatic listener and loader composition', () => {
|
||||
compact.error = new Error('summary unavailable after prune')
|
||||
const session = oversizedToolResult(3_000, true)
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
|
||||
@@ -1497,8 +1490,7 @@ describe('automatic listener and loader composition', () => {
|
||||
compact.error = new Error('summary cancelled after prune')
|
||||
const session = oversizedToolResult(3_000, true)
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
|
||||
expect(session.surface.replaceGeneration).toBe(1)
|
||||
})
|
||||
|
||||
@@ -1512,7 +1504,7 @@ describe('automatic listener and loader composition', () => {
|
||||
const newestAssistant = session.surface.nodes.at(-2)!
|
||||
const newestResult = session.surface.nodes.at(-1)!
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(true)
|
||||
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
|
||||
const currentResult = session.surface.nodes.find(node => node === newestResult)
|
||||
expect(currentAssistant).toBeDefined()
|
||||
@@ -1536,7 +1528,7 @@ describe('automatic listener and loader composition', () => {
|
||||
}
|
||||
vi.spyOn(compact, 'compactIfNeeded').mockResolvedValue(fakeResult)
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
expect(session.surface.replaceGeneration).toBe(0)
|
||||
})
|
||||
|
||||
@@ -1551,7 +1543,6 @@ describe('automatic listener and loader composition', () => {
|
||||
ctx,
|
||||
agent(conversation(2), MODEL),
|
||||
overflow(),
|
||||
0,
|
||||
SIGNAL,
|
||||
() => {
|
||||
calls += 1
|
||||
@@ -1569,7 +1560,7 @@ describe('automatic listener and loader composition', () => {
|
||||
compact.error = new Error('summary unavailable')
|
||||
const original = overflow('original provider overflow')
|
||||
|
||||
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(conversation(3), MODEL), original)).toBe(false)
|
||||
expect(original).toMatchObject({
|
||||
message: 'original provider overflow',
|
||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
@@ -1588,12 +1579,12 @@ describe('automatic listener and loader composition', () => {
|
||||
const original = overflow('original provider failure')
|
||||
let delegations = 0
|
||||
|
||||
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
|
||||
const decision = await recover(ctx, agent(session, MODEL), original, SIGNAL, () => {
|
||||
delegations += 1
|
||||
return Promise.resolve({ action: 'fail' })
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
|
||||
expect(decision).toEqual({ action: 'fail' })
|
||||
expect(decision).toBe(false)
|
||||
expect(delegations).toBe(1)
|
||||
expect(session.surface.replaceGeneration).toBe(generation)
|
||||
expect(original).toMatchObject({
|
||||
@@ -1612,7 +1603,7 @@ describe('automatic listener and loader composition', () => {
|
||||
reason: 'resume',
|
||||
})
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
|
||||
.toEqual({ action: 'retry' })
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
it('delegates canonical overflow when no durable routed target exists', async () => {
|
||||
@@ -1624,21 +1615,19 @@ describe('automatic listener and loader composition', () => {
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' })
|
||||
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('honors retry caps, non-context failures, and cancellation', async () => {
|
||||
it('honors retry caps and ignores non-context failures', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 })
|
||||
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
|
||||
const owner = agent(conversation(3), MODEL)
|
||||
expect(await recover(ctx, owner, Object.assign(new Error('rate limit'), { code: 'RATE_LIMIT' })))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, owner, overflow(), 1)).toEqual({ action: 'fail' })
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort('cancelled')
|
||||
expect(await recover(ctx, owner, overflow(), 0, controller.signal)).toEqual({ action: 'fail' })
|
||||
.toBe(false)
|
||||
expect(await recover(ctx, owner, overflow())).toBe(true)
|
||||
compactSpy.mockClear()
|
||||
expect(await recover(ctx, owner, overflow())).toBe(false)
|
||||
expect(compactSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1653,9 +1642,11 @@ describe('automatic listener and loader composition', () => {
|
||||
}],
|
||||
})
|
||||
const compactSpy = vi.spyOn(compact, 'compactIfNeeded')
|
||||
const owner = agent(conversation(3), MODEL)
|
||||
|
||||
expect(await recover(ctx, agent(conversation(3), MODEL), overflow(), 1))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, owner, overflow())).toBe(true)
|
||||
compactSpy.mockClear()
|
||||
expect(await recover(ctx, owner, overflow())).toBe(false)
|
||||
expect(compactSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1667,8 +1658,7 @@ describe('automatic listener and loader composition', () => {
|
||||
const session = conversation(3)
|
||||
const generation = session.surface.replaceGeneration
|
||||
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow(), 0, controller.signal))
|
||||
.toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow(), controller.signal)).toBe(false)
|
||||
expect(session.surface.replaceGeneration).toBe(generation + 1)
|
||||
})
|
||||
|
||||
@@ -1683,7 +1673,7 @@ describe('automatic listener and loader composition', () => {
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
const summaries = session.events.filter(event => event.type === 'compact/summary').length
|
||||
expect(summaries).toBe(1)
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
expect(session.events.filter(event => event.type === 'compact/summary')).toHaveLength(summaries)
|
||||
})
|
||||
|
||||
@@ -1697,7 +1687,7 @@ describe('automatic listener and loader composition', () => {
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
})
|
||||
|
||||
it('loads and disposes the real zero-config service stack', async () => {
|
||||
@@ -1726,6 +1716,6 @@ describe('automatic listener and loader composition', () => {
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
@@ -15,7 +15,7 @@ import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import * as LlmRetry from '@deepseek-ai/dsh-llm-retry'
|
||||
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, type SessionEvent, type SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* CBR-001 regression through the real loop. A replacement checkpoint has a high
|
||||
@@ -73,6 +73,11 @@ class StepwiseToolAdapter extends LlmAdapter {
|
||||
class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
readonly conversationRequests: GenerateOptions[] = []
|
||||
readonly summaryRequests: GenerateOptions[] = []
|
||||
private readonly retryPolicy = resolveRetryPolicy({
|
||||
mode: 'normal',
|
||||
maxRetries: 1,
|
||||
backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 },
|
||||
}, 'compaction test provider retryPolicy')
|
||||
|
||||
constructor(
|
||||
private readonly delivery: 'thrown' | 'in-band',
|
||||
@@ -90,6 +95,10 @@ class OverflowRecoveryAdapter extends LlmAdapter {
|
||||
})
|
||||
}
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.retryPolicy
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// The cache-reusing summarizer replays the conversation prefix and marks
|
||||
// its call only by the compaction instruction in the trailing user message.
|
||||
@@ -175,39 +184,43 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function seedOverflowHistory(agent: Agent): void {
|
||||
function overflowHistorySeed(): SessionEvent[] {
|
||||
const session = new Session(SessionId('overflow-history-seed'))
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
agent.session.append('turn/start', {
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
agent.session.append('user/message', {
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn, step: 1 })
|
||||
agent.session.append('assistant/message', {
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
}, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn, step: 1 })
|
||||
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
return [...session.events]
|
||||
}
|
||||
|
||||
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
|
||||
it('uses the model actually routed by agent/request for post-step pressure', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
agent.followup([{ type: 'text', text: 'do a routed multi-step task' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.requestHeader()?.config.model).toBe('mock')
|
||||
@@ -221,11 +234,11 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||
it('runs automatic pressure between the completed tool step and the next step', async () => {
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'do tool work' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
@@ -235,13 +248,19 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
event.type === 'tool/result' && event.seq < compactStart!.seq,
|
||||
)
|
||||
if (precedingResult?.type !== 'tool/result') throw new Error('expected a durable tool result before compaction')
|
||||
const stepEnd = events.find(event =>
|
||||
const precedingStepEnd = events.find(event =>
|
||||
event.type === 'step/end'
|
||||
&& event.data.step === precedingResult.data.step
|
||||
&& event.seq > precedingResult.seq,
|
||||
)
|
||||
const nextStepStart = events.find(event =>
|
||||
event.type === 'step/start'
|
||||
&& event.data.step === precedingResult.data.step + 1
|
||||
&& event.seq > compactStart!.seq,
|
||||
)
|
||||
expect(precedingResult.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(stepEnd!.seq)
|
||||
expect(precedingStepEnd!.seq).toBeLessThan(compactStart!.seq)
|
||||
expect(compactStart!.seq).toBeLessThan(nextStepStart!.seq)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -251,7 +270,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup([{ type: 'text', text: 'do a long multi-step task' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
@@ -291,7 +310,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _signal, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
@@ -301,13 +322,16 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId(`overflow-${delivery}`),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: {
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
},
|
||||
})
|
||||
seedOverflowHistory(agent)
|
||||
|
||||
agent.followup([{ type: 'text', text: 'continue from history' }])
|
||||
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
@@ -318,11 +342,17 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
expect(retry).not.toContain('OLD HISTORY SENTINEL')
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const failedEnd = events.find(event =>
|
||||
const failedStepEnd = events.find(event =>
|
||||
event.type === 'step/end' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const failedEnd = events.find(event =>
|
||||
event.type === 'turn/end' && event.data.turn === 3,
|
||||
)!
|
||||
const retryStart = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 2,
|
||||
event.type === 'turn/start' && event.data.turn === 4,
|
||||
)!
|
||||
const retryStep = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 4 && event.data.step === 1,
|
||||
)!
|
||||
const compaction = events.filter(event =>
|
||||
event.type === 'compact/start'
|
||||
@@ -334,7 +364,11 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
'compact/summary',
|
||||
'compact/end',
|
||||
])
|
||||
expect(compaction.every(event => event.seq > failedEnd.seq && event.seq < retryStart.seq)).toBe(true)
|
||||
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
|
||||
expect(compaction.every(event =>
|
||||
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
|
||||
)).toBe(true)
|
||||
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -350,12 +384,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
const adapter = new OverflowRecoveryAdapter('thrown', true)
|
||||
await mountAgentLoopTestDependencies(ctx)
|
||||
await mountInvariants(ctx)
|
||||
await ctx.plugin(LlmRetry, {
|
||||
maxTransientRetries: 1,
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 1,
|
||||
jitterRatio: 0,
|
||||
})
|
||||
await ctx.plugin(LlmRetry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -368,17 +397,20 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
})
|
||||
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('alternating-recovery'), { provider: 'mock', model: 'mock' })
|
||||
seedOverflowHistory(agent)
|
||||
agent.followup([{ type: 'text', text: 'continue from history' }])
|
||||
const { agent } = await ctx.agentLoop.createAgent(ctx, {
|
||||
sessionId: SessionId('alternating-recovery'),
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(3)
|
||||
expect(adapter.summaryRequests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data))
|
||||
.toEqual([expect.objectContaining({ step: 2, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start').slice(-3).map(event => event.data.step))
|
||||
.toEqual([1, 2, 3])
|
||||
.toEqual([expect.objectContaining({ turn: 4, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-3).map(event => event.data.turn))
|
||||
.toEqual([3, 4, 5])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
|
||||
Reference in New Issue
Block a user