Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
3973 files changed
+144864
-60887
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# 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 packages/compact/compact-basic/README.md
|
||||
README.md: 22ee4c00df8ab9f52ebe86bbda540b912dfe16e2
|
||||
README.zh.md: 555d3ce7792e2978cf83ee44bea748578d3558d1
|
||||
README.md: d2a2aeb34210169f1e8dafdc1ad26509b7dd885c
|
||||
README.zh.md: 14505e443975b1b38bdb9be4fe1961c610c38f02
|
||||
@@ -17,7 +17,7 @@ This backend owns the compaction policy:
|
||||
- **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, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. 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; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing.
|
||||
- **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** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
|
||||
- **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends provenance plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`.
|
||||
- **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** — a live unmatched `compact/start` is the durable lock. An unmatched marker before a newer `session/end-seed` is stale evidence from a prior lifecycle and does not block; one after that boundary reports `busy`. Summary and changed-span failures close with an error and leave the conversation surface untouched, though the attempt remains in the log. A failed close deliberately leaves a blocking orphan. 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 cleanup and durability.
|
||||
|
||||
@@ -162,4 +162,3 @@ The replayed system prompt, tools, and shadowed-region messages match the conver
|
||||
- **Some indivisible-unit and envelope-only overflow remains outside surface compaction** — recovery cannot shrink system/tools/prefix, split an indivisible non-tool node, or repair a tool unit whose non-prunable remainder still exceeds the window. The optional pruner can shrink text-bearing tool-result bulk inside an otherwise indivisible pair.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
- **Summarization failure preserves the latest durable surface** — before any replacement, the auto path logs a warning and proceeds with full over-budget history. If pruning already landed, a later summarization failure proceeds from that durable pruned surface. Summarization truncation at `maxTokens`, which hidden reasoning tokens can consume, follows the same rule.
|
||||
- **The summarization call has no transcript-snapshot coverage** — `dsh-llm-replay` derives calls from `assistant/chunk` events, so this chunk-less direct `ctx.llm.stream()` call cannot replay (named deferred replay infrastructure in [the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
**基础压缩(compaction)后端**:`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV cache(可在 `llm/stream` 处拦截)。
|
||||
**基础压缩(compaction)后端**:`BasicCompactService` 实现 `@deepseek-ai/dsh-compact` seam,使用可复用的 `ctx.tokenMeter` 压力、token 预算保留与摘要。摘要是直接的一次性 `ctx.llm.stream()` 调用,它会回放会话前缀以复用提供方的 KV Cache(可在 `llm/stream` 处拦截)。
|
||||
|
||||
这是压缩能力的实现层。seam 见 [接口包(package)](../compact/README.md),设计见 [能力 seam Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
|
||||
这是压缩能力的实现层。seam 见 [接口包](../compact/README.md),设计见 [能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md)。
|
||||
|
||||
## 拥有的职责
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
- **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。
|
||||
- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` seam。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息(包括图片引用),并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用;图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。
|
||||
- **框定**:替换 user 消息使用 `<compacted-summary>` 标签标记已建立的检查点上下文。原始摘要保留在溯源事件上,后续自动周期会合并之前的检查点。
|
||||
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
|
||||
- **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加溯源信息和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。
|
||||
- **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。
|
||||
- **失败处理**:活动的未匹配 `compact/start` 是持久锁。位于较新 `session/end-seed` 之前的未匹配标记,是先前生命周期留下的陈旧证据,不会阻塞;位于该边界之后的标记报告 `busy`。摘要和 span 变更失败会以错误闭合,并保持会话表层不变,但日志中仍保留该尝试。闭合失败会有意留下阻塞性的未匹配标记。压力检查中的运行故障会发出警告并继续;只有此前没有替换推进表层时,溢出恢复失败才保留原始提供方错误。完成清理与持久化后,取消仍具有最终决定权。
|
||||
|
||||
@@ -162,4 +162,3 @@ Rules:
|
||||
- **部分不可分单元与仅 envelope 溢出仍不在表层压缩范围内**:恢复无法缩减系统/工具/前缀、拆分不可分的非工具节点,或修复不可剪枝剩余部分仍超出窗口的工具单元。可选 pruner 可以缩减原本不可分工具对内的文本型工具结果主体。
|
||||
- **`compactRegion` 要求存在未结束的轮次**:在完全关闭的会话上手动调用会抛出异常(「no open turn」),而不是执行压缩。
|
||||
- **摘要失败会保留最新持久表层**:任何替换前,自动路径会记录警告,并携带完整超预算历史继续。如果剪枝已落地,后续摘要失败会从该持久剪枝表层继续。因达到 `maxTokens` 而发生的摘要截断(隐藏推理 token 可能会耗尽该额度)遵循同一规则。
|
||||
- **摘要调用没有 transcript 快照覆盖**:`dsh-llm-replay` 从 `assistant/chunk` 事件派生调用,因此这次不含分片的直接 `ctx.llm.stream()` 调用无法回放([seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md) 中明确的暂缓回放基础设施)。
|
||||
@@ -21,9 +21,7 @@
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
// Type-only: makes the optional sibling service available to `ctx.get()`.
|
||||
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
|
||||
import {
|
||||
@@ -143,28 +143,28 @@ export class BasicCompactService extends CompactService {
|
||||
)
|
||||
}
|
||||
|
||||
ctx.on('agent/step', async (
|
||||
agent: Agent,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
if (signal.aborted) return
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
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)
|
||||
ctx.on('agent/pre-step', async (
|
||||
{ agent, signal },
|
||||
next,
|
||||
): Promise<PreStepDecision> => {
|
||||
if (!signal.aborted) {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, 'pressure', signal)
|
||||
if (result !== null) logResult(result, 'step pressure')
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TargetPressureConfigError) {
|
||||
if (this.warnedPressureConfigTargets.has(error.targetKey)) return next()
|
||||
this.warnedPressureConfigTargets.add(error.targetKey)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
ctx.on('agent/settled', (agent) => {
|
||||
this.overflowRetries.delete(agent)
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
if (status === 'idle') this.overflowRetries.delete(agent)
|
||||
})
|
||||
|
||||
// A successful response starts a fresh overflow-recovery sequence even
|
||||
@@ -176,14 +176,7 @@ export class BasicCompactService extends CompactService {
|
||||
})
|
||||
|
||||
ctx.on('agent/request-error', async (
|
||||
agent,
|
||||
_turn,
|
||||
_step,
|
||||
_error,
|
||||
failure,
|
||||
_priorFailures,
|
||||
_retryPolicy,
|
||||
signal,
|
||||
{ agent, failure, signal },
|
||||
next,
|
||||
) => {
|
||||
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
|
||||
@@ -367,44 +360,56 @@ export class BasicCompactService extends CompactService {
|
||||
* Force one useful idle-session compaction below the pressure threshold, and
|
||||
* resolve only after its standalone marker pair is durably checkpointed.
|
||||
* @param agent - idle agent whose next-turn admission this call reserves.
|
||||
* @param signal - command-owned cancellation forwarded to summarization.
|
||||
* @param signal - cancellation scoped to this compaction request.
|
||||
* @returns the committed result, or `null` when no safe useful range exists.
|
||||
*/
|
||||
override async compactNow(
|
||||
agent: Agent,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
override compactNow(agent: Agent, signal: AbortSignal): Promise<CompactionResult | null> {
|
||||
signal.throwIfAborted()
|
||||
const releaseTurnAdmission = agent.reserveTurnAdmission()
|
||||
if (releaseTurnAdmission === undefined) {
|
||||
try {
|
||||
return agent.runMaintenance(async (agentSignal) => {
|
||||
const operationSignal = AbortSignal.any([agentSignal, signal])
|
||||
try {
|
||||
operationSignal.throwIfAborted()
|
||||
const range = selectCompactableRange(
|
||||
agent.session,
|
||||
this.ctx.tokenMeter.measure(agent.session),
|
||||
0,
|
||||
)
|
||||
if (range === null) return null
|
||||
return await compactSurfaceRegion(
|
||||
this.regionDependencies(),
|
||||
agent.session,
|
||||
range.start,
|
||||
range.end,
|
||||
agent,
|
||||
{
|
||||
owner: null,
|
||||
stability: 'selected-span',
|
||||
flush: async () => {
|
||||
await this.ctx.sessions.flush(agent.session)
|
||||
},
|
||||
},
|
||||
operationSignal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (agentSignal.aborted && operationSignal.reason === agentSignal.reason) {
|
||||
throw new ManualCompactionError(
|
||||
'cancelled',
|
||||
'manual compaction was cancelled',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
operationSignal.throwIfAborted()
|
||||
throw error
|
||||
}
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
throw new ManualCompactionError(
|
||||
'busy',
|
||||
'manual compaction requires an idle agent with no waking queued work',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
try {
|
||||
const range = selectCompactableRange(
|
||||
agent.session,
|
||||
this.ctx.tokenMeter.measure(agent.session),
|
||||
0,
|
||||
)
|
||||
if (range === null) return null
|
||||
return await compactSurfaceRegion(
|
||||
this.regionDependencies(),
|
||||
agent.session,
|
||||
range.start,
|
||||
range.end,
|
||||
agent,
|
||||
{
|
||||
owner: null,
|
||||
stability: 'selected-span',
|
||||
flush: () => this.ctx.sessions.flush(agent.session),
|
||||
},
|
||||
signal,
|
||||
)
|
||||
} finally {
|
||||
releaseTurnAdmission()
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
|
||||
|
||||
@@ -104,9 +104,9 @@ function promptInput(text: string): SummarizationInput {
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
const session = Session.create(SessionId(`conversation-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${text} user ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -135,16 +135,15 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
}
|
||||
session.append('turn/start', {
|
||||
turn: turns + 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
function toolConversation(): Session {
|
||||
const session = new Session(SessionId('tools'))
|
||||
const session = Session.create(SessionId('tools'))
|
||||
for (let turn = 1; turn <= 3; turn += 1) {
|
||||
const callId = CallId(`call-${turn}`)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
|
||||
source: { kind: 'user' },
|
||||
@@ -184,15 +183,15 @@ function toolConversation(): Session {
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
return session
|
||||
}
|
||||
|
||||
/** One closed routed tool step followed by an open turn for rewrite events. */
|
||||
function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Session {
|
||||
const session = new Session(SessionId(`oversized-tool-${chars}`))
|
||||
const session = Session.create(SessionId(`oversized-tool-${chars}`))
|
||||
const callId = CallId('oversized')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
if (withCompactablePrompt) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'older history '.repeat(200) }],
|
||||
@@ -229,7 +228,7 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
}, { 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' } } })
|
||||
session.append('turn/start', { turn: 2 })
|
||||
return session
|
||||
}
|
||||
|
||||
@@ -486,8 +485,8 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('headerless'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const session = Session.create(SessionId('headerless'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
await expect(compact.compactIfNeeded(agent(session, MODEL), 'pressure', SIGNAL))
|
||||
.resolves.toBeNull()
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
@@ -568,9 +567,9 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = new Session(SessionId('single-tool-pair'))
|
||||
const session = Session.create(SessionId('single-tool-pair'))
|
||||
const callId = CallId('single-call')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL } },
|
||||
@@ -659,8 +658,8 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const empty = new Session(SessionId('empty'))
|
||||
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const empty = Session.create(SessionId('empty'))
|
||||
empty.append('turn/start', { turn: 1 })
|
||||
empty.append('request/header', {
|
||||
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
|
||||
reason: 'initial',
|
||||
@@ -734,9 +733,9 @@ describe('pressure measurement and retention', () => {
|
||||
|
||||
it('declines when rounding a cut would consume the only tool pair', () => {
|
||||
const ctx = createContext()
|
||||
const session = new Session(SessionId('one-tool-pair'))
|
||||
const session = Session.create(SessionId('one-tool-pair'))
|
||||
const callId = CallId('only')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
@@ -875,7 +874,7 @@ describe('compaction region transaction', () => {
|
||||
expect(head.content[0]?.type === 'text' ? head.content[0].text : '').toContain('<compacted-summary>')
|
||||
expect(head.content.at(-1)).toEqual({ type: 'text', text: '</compacted-summary>' })
|
||||
|
||||
const replay = new Session(SessionId('replay'), [...session.events])
|
||||
const replay = Session.create(SessionId('replay'), [...session.events])
|
||||
expect(replay.deriveMessages()).toEqual(session.deriveMessages())
|
||||
})
|
||||
|
||||
@@ -957,7 +956,7 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('rejects a session with no turn boundary at all', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
const session = Session.create(SessionId('turnless'))
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'orphan' }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1077,8 +1076,8 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const session = Session.create(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||
source: { kind: 'user' },
|
||||
@@ -1326,13 +1325,13 @@ describe('default one-shot summarizer', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx)
|
||||
const compact = new ExposedCompactService(ctx, { auto: false })
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(new Session(SessionId('model-less')))))
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(Session.create(SessionId('model-less')))))
|
||||
.rejects.toThrow(/no provider\/model available for summarization/)
|
||||
})
|
||||
|
||||
it('uses a complete AgentOptions target when no durable route exists', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const session = new Session(SessionId('headerless-summary'))
|
||||
const session = Session.create(SessionId('headerless-summary'))
|
||||
|
||||
await expect(compact.runSummarize(promptInput('history'), agent(session, MODEL))).resolves.toMatchObject({
|
||||
provider: MODEL,
|
||||
@@ -1348,7 +1347,7 @@ describe('default one-shot summarizer', () => {
|
||||
])('rejects incomplete AgentOptions target %#', async (options) => {
|
||||
const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
|
||||
const owner = {
|
||||
session: new Session(SessionId(`incomplete-${String(options.model)}`)),
|
||||
session: Session.create(SessionId(`incomplete-${String(options.model)}`)),
|
||||
options,
|
||||
} as Agent
|
||||
await expect(compact.runSummarize(promptInput('history'), owner))
|
||||
@@ -1421,8 +1420,11 @@ 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/step', 1, 1, signal)
|
||||
function preStep(ctx: Context, owner: Agent, signal = SIGNAL) {
|
||||
return agentEvents(ctx, owner).waterfall(
|
||||
'agent/pre-step', { messages: [], turn: 1, step: 1, signal },
|
||||
() => Promise.resolve({ kind: 'enter' as const, messages: [] }),
|
||||
)
|
||||
}
|
||||
|
||||
function recover(
|
||||
@@ -1435,7 +1437,9 @@ describe('automatic listener and loader composition', () => {
|
||||
const failure: LlmFailure = { message: error.message, code: error.code ?? 'UNKNOWN' }
|
||||
const turn = owner.session.events.findLast(event => event.type === 'turn/start')?.data.turn ?? 1
|
||||
return agentEvents(ctx, owner).waterfall(
|
||||
'agent/request-error', turn, 1, error, failure, [], undefined, signal, next,
|
||||
'agent/request-error',
|
||||
{ turn, step: 1, provider: 'test', failure, retryPolicy: undefined, signal },
|
||||
next,
|
||||
).then(action => action?.kind === 'retry')
|
||||
}
|
||||
|
||||
@@ -1443,23 +1447,23 @@ describe('automatic listener and loader composition', () => {
|
||||
return Object.assign(new Error(message), { code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
||||
}
|
||||
|
||||
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
|
||||
it('compacts before a step above threshold using the durable routed model and remains idle below it', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const pressured = conversation(4)
|
||||
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
||||
await preStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
||||
expect(pressured.events.some(event => event.type === 'compact/summary')).toBe(true)
|
||||
|
||||
const small = conversation(1)
|
||||
await postStep(ctx, agent(small, MODEL))
|
||||
await preStep(ctx, agent(small, MODEL))
|
||||
expect(small.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips post-step pressure when the step signal is already aborted', async () => {
|
||||
it('skips pre-step pressure when the step signal is already aborted', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, {
|
||||
thresholdRatio: 0.5,
|
||||
@@ -1468,8 +1472,8 @@ describe('automatic listener and loader composition', () => {
|
||||
const pressured = conversation(4)
|
||||
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
|
||||
|
||||
await expect(postStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
|
||||
.resolves.toBeUndefined()
|
||||
await expect(preStep(ctx, agent(pressured, MODEL), AbortSignal.abort('step aborted')))
|
||||
.resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
|
||||
expect(compactIfNeeded).not.toHaveBeenCalled()
|
||||
expect(pressured.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
@@ -1486,7 +1490,7 @@ describe('automatic listener and loader composition', () => {
|
||||
compact.error = 'temporary failure'
|
||||
const session = conversation(4)
|
||||
|
||||
await expect(postStep(ctx, agent(session, MODEL))).resolves.toBeUndefined()
|
||||
await expect(preStep(ctx, agent(session, MODEL))).resolves.toEqual({ kind: 'enter', messages: [] })
|
||||
expect(warnings).toContainEqual(expect.stringContaining('temporary failure'))
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
@@ -1506,8 +1510,8 @@ describe('automatic listener and loader composition', () => {
|
||||
})
|
||||
const session = conversation(4)
|
||||
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`),
|
||||
@@ -1524,8 +1528,8 @@ describe('automatic listener and loader composition', () => {
|
||||
})
|
||||
const session = conversation(4)
|
||||
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
|
||||
expect(warnings).toEqual([
|
||||
expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'),
|
||||
@@ -1748,10 +1752,9 @@ describe('automatic listener and loader composition', () => {
|
||||
it('delegates canonical overflow when no durable routed target exists', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx)
|
||||
const session = new Session(SessionId('headerless-overflow'))
|
||||
const session = Session.create(SessionId('headerless-overflow'))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toBe(false)
|
||||
@@ -1809,7 +1812,7 @@ describe('automatic listener and loader composition', () => {
|
||||
retainTokens: 180,
|
||||
})
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await preStep(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())).toBe(false)
|
||||
@@ -1824,7 +1827,7 @@ describe('automatic listener and loader composition', () => {
|
||||
retainTokens: 180,
|
||||
})
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
})
|
||||
@@ -1854,7 +1857,7 @@ describe('automatic listener and loader composition', () => {
|
||||
await fiber.dispose()
|
||||
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
await preStep(ctx, agent(session, MODEL))
|
||||
expect(session.events.some(event => event.type === 'compact/start')).toBe(false)
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toBe(false)
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
const dispose = ctx.on('agent/status', ({ agent: subject, status }) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
@@ -185,12 +185,11 @@ function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
}
|
||||
|
||||
function overflowHistorySeed(): SessionEvent[] {
|
||||
const session = new Session(SessionId('overflow-history-seed'))
|
||||
const session = Session.create(SessionId('overflow-history-seed'))
|
||||
for (let turn = 1; turn <= 2; turn += 1) {
|
||||
const sentinel = turn === 1 ? 'OLD HISTORY SENTINEL' : 'RECENT HISTORY'
|
||||
session.append('turn/start', {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
@@ -218,7 +217,7 @@ function overflowHistorySeed(): SessionEvent[] {
|
||||
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, _signal, next) => ({
|
||||
ctx.on('agent/request', async (_payload, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
try {
|
||||
@@ -307,7 +306,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
|
||||
describe('context-overflow recovery across the real loop and compact-basic', () => {
|
||||
it.each(['thrown', 'in-band'] as const)(
|
||||
'force-compacts a %s overflow between failed and retry steps',
|
||||
'force-compacts a %s overflow within the retried step',
|
||||
async (delivery) => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverflowRecoveryAdapter(delivery)
|
||||
@@ -316,7 +315,7 @@ 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, _signal, next) => ({
|
||||
ctx.on('agent/request', async (_payload, next) => ({
|
||||
...await next(), provider: 'mock', model: 'mock',
|
||||
}))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
@@ -353,18 +352,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
expect(retry).not.toContain('OLD HISTORY SENTINEL')
|
||||
|
||||
const events = [...agent.session.events]
|
||||
const failedStepEnd = events.find(event =>
|
||||
const stepStart = events.find(event =>
|
||||
event.type === 'step/start' && event.data.turn === 3 && event.data.step === 1,
|
||||
)!
|
||||
const stepEnd = 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 === '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'
|
||||
|| event.type === 'compact/summary'
|
||||
@@ -375,11 +368,13 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
'compact/summary',
|
||||
'compact/end',
|
||||
])
|
||||
expect(retryStart.seq).toBeGreaterThan(failedEnd.seq)
|
||||
expect(compaction.every(event =>
|
||||
event.seq > failedStepEnd.seq && event.seq < failedEnd.seq,
|
||||
event.seq > stepStart.seq && event.seq < stepEnd.seq,
|
||||
)).toBe(true)
|
||||
expect(retryStep.seq).toBeGreaterThan(retryStart.seq)
|
||||
expect(events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
|
||||
.toEqual([3])
|
||||
expect(events.filter(event => event.type === 'step/start' && event.data.turn === 3))
|
||||
.toHaveLength(1)
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
@@ -419,9 +414,9 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
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({ 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])
|
||||
.toEqual([expect.objectContaining({ turn: 3, step: 1, retry: 1, failure: { message: 'temporary provider outage', code: 'SERVER' } })])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start').slice(-1).map(event => event.data.turn))
|
||||
.toEqual([3])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'completed' } },
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('real Loader composition', () => {
|
||||
expect(unloaded).toEqual([])
|
||||
expect(loaded.get('toolResultPrune')).toBeInstanceOf(ToolResultPruneService)
|
||||
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
expect((loaded.compact as BasicCompactService).config).toMatchObject({
|
||||
expect((loaded.compact as unknown as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainRatio: 0.125,
|
||||
auto: false,
|
||||
|
||||
@@ -112,7 +112,7 @@ async function loopHarness(): Promise<LoopHarness> {
|
||||
const agent = ctx.agentLoop.create(SessionId('manual-compact'), { provider: MODEL, model: MODEL })
|
||||
const log: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/start') log.push(`turn/start:${event.data.trigger.kind}`)
|
||||
if (event.type === 'turn/start') log.push('turn/start')
|
||||
if (event.type === 'turn/end') log.push('turn/end')
|
||||
if (event.type === 'compact/start') log.push(`compact/start:${String(event.data.turn)}`)
|
||||
if (event.type === 'compact/summary') log.push('compact/summary')
|
||||
@@ -141,11 +141,14 @@ function derivedText(session: Session): string[] {
|
||||
}
|
||||
|
||||
/** Await one classified manual-compaction rejection. */
|
||||
async function rejection(operation: Promise<unknown>): Promise<ManualCompactionError> {
|
||||
const caught: unknown = await operation.then(
|
||||
(value: unknown) => { throw new Error(`expected a rejection, resolved with ${String(value)}`) },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
async function rejection(operation: Promise<unknown> | (() => Promise<unknown>)): Promise<ManualCompactionError> {
|
||||
let caught: unknown
|
||||
try {
|
||||
const value = await (typeof operation === 'function' ? operation() : operation)
|
||||
throw new Error(`expected a rejection, resolved with ${String(value)}`)
|
||||
} catch (error: unknown) {
|
||||
caught = error
|
||||
}
|
||||
if (!(caught instanceof ManualCompactionError)) {
|
||||
throw new Error(`expected a ManualCompactionError, got ${String(caught)}`)
|
||||
}
|
||||
@@ -166,10 +169,10 @@ function deferred(): { promise: Promise<undefined>; resolve: () => void } {
|
||||
|
||||
/** A closed-tail session with compactable exchanges and no live agent. */
|
||||
function closedConversation(turns = 2, lastTurnNumber = turns): Session {
|
||||
const session = new Session(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
const session = Session.create(SessionId(`closed-${turns}-${lastTurnNumber}`))
|
||||
for (let index = 1; index <= turns; index += 1) {
|
||||
const turn = index === turns ? lastTurnNumber : index
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${PROMPT} ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
@@ -195,15 +198,20 @@ function closedConversation(turns = 2, lastTurnNumber = turns): Session {
|
||||
return session
|
||||
}
|
||||
|
||||
/** A fake idle agent whose admission reservation is scripted per test. */
|
||||
/** A fake idle agent whose maintenance claim is scripted per test. */
|
||||
function fakeAgent(
|
||||
session: Session,
|
||||
reserve: () => (() => void) | undefined,
|
||||
maintenanceSignal = new AbortController().signal,
|
||||
): Agent {
|
||||
return {
|
||||
session,
|
||||
options: { provider: MODEL, model: MODEL },
|
||||
reserveTurnAdmission: reserve,
|
||||
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
const release = reserve()
|
||||
if (release === undefined) throw new Error('agent already has active work')
|
||||
return task(maintenanceSignal).finally(release)
|
||||
},
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
@@ -217,7 +225,7 @@ function detachedService(): { ctx: Context; compact: GatedCompactService; flushe
|
||||
let flushes = 0
|
||||
vi.spyOn(ctx.sessions, 'flush').mockImplementation(() => {
|
||||
flushes += 1
|
||||
return Promise.resolve()
|
||||
return Promise.resolve(false)
|
||||
})
|
||||
return { ctx, compact: new GatedCompactService(ctx, { auto: false }), flushes: () => flushes }
|
||||
}
|
||||
@@ -256,7 +264,7 @@ describe('compactNow through the real loop', () => {
|
||||
const summary = log.indexOf('compact/summary')
|
||||
const end = log.indexOf('compact/end:null')
|
||||
const flush = log.indexOf('flush')
|
||||
const nextTurn = log.indexOf('turn/start:message')
|
||||
const nextTurn = log.indexOf('turn/start')
|
||||
expect(start).toBeLessThan(summary)
|
||||
expect(summary).toBeLessThan(end)
|
||||
expect(end).toBeLessThan(flush)
|
||||
@@ -270,7 +278,7 @@ describe('compactNow through the real loop', () => {
|
||||
expect(second.some(text => text.includes(PROMPT))).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps context injected during summarization between the markers and after the checkpoint', async () => {
|
||||
it('keeps context injected during summarization pending for the next step', async () => {
|
||||
const harness = await loopHarness()
|
||||
const { agent, compact } = harness
|
||||
await seedHistory(harness)
|
||||
@@ -285,18 +293,22 @@ describe('compactNow through the real loop', () => {
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
const start = agent.session.events.findLast(event => event.type === 'compact/start')
|
||||
const injected = agent.session.events.findLast(event => event.type === 'user/message'
|
||||
&& event.data.source.kind === 'plugin' && event.data.source.plugin === 'test')
|
||||
const injected = agent.inbox.nextStep.find(message =>
|
||||
message.source.kind === 'plugin' && message.source.plugin === 'test')
|
||||
const end = agent.session.events.findLast(event => event.type === 'compact/end')
|
||||
expect(start).toBeDefined()
|
||||
expect(injected).toBeDefined()
|
||||
expect(end).toBeDefined()
|
||||
expect(start!.seq).toBeLessThan(injected!.seq)
|
||||
expect(injected!.seq).toBeLessThan(end!.seq)
|
||||
expect(result?.shadowedSeqs).not.toContain(injected?.seq)
|
||||
expect(agent.session.events.some(event => event.type === 'user/message'
|
||||
&& event.data.id === injected?.id)).toBe(false)
|
||||
|
||||
agent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'after compaction' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
await agent.whenIdle()
|
||||
const messages = derivedText(agent.session)
|
||||
expect(messages[0]).toContain('checkpoint')
|
||||
expect(messages.at(-1)).toContain('INJECTED CONTEXT')
|
||||
expect(messages.filter(text => text.includes('INJECTED CONTEXT'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -334,7 +346,7 @@ describe('compactNow through the real loop', () => {
|
||||
content: [{ type: 'text', text: 'first in line' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
|
||||
await agent.whenIdle()
|
||||
@@ -368,7 +380,7 @@ describe('compactNow through the real loop', () => {
|
||||
describe('compactNow transaction and failure classification', () => {
|
||||
it('returns null without writing a bracket for history that cannot be compacted', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('empty'))
|
||||
const session = Session.create(SessionId('empty'))
|
||||
let released = 0
|
||||
const agent = fakeAgent(session, () => () => { released += 1 })
|
||||
|
||||
@@ -400,7 +412,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
session.append('compact/start', { turn: null })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
const error = await rejection(() => compact.compactNow(agent, SIGNAL))
|
||||
expect(error.code).toBe('busy')
|
||||
expect(error.message).toContain('compaction lock is already active')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
@@ -410,7 +422,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { compact } = detachedService()
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
const reloaded = new Session(SessionId('stale-orphan'), [...original.events])
|
||||
const reloaded = Session.create(SessionId('stale-orphan'), [...original.events])
|
||||
const boundary = reloaded.events.findLast(event => event.type === 'session/end-seed')
|
||||
const orphan = reloaded.events.find(event => event.type === 'compact/start')
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
@@ -424,9 +436,9 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { compact } = detachedService()
|
||||
const original = closedConversation(2)
|
||||
original.append('compact/start', { turn: null })
|
||||
original.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
original.append('turn/start', { turn: 3 })
|
||||
original.append('turn/end', { turn: 3, reason: { kind: 'interrupted' } })
|
||||
const reloaded = new Session(SessionId('reloaded-orphan'), [...original.events])
|
||||
const reloaded = Session.create(SessionId('reloaded-orphan'), [...original.events])
|
||||
const agent = fakeAgent(reloaded, () => () => undefined)
|
||||
|
||||
await expect(compact.compactNow(agent, SIGNAL)).resolves.not.toBeNull()
|
||||
@@ -436,7 +448,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('refuses an open turn in the log', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(2)
|
||||
session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 3 })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
@@ -448,7 +460,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { compact } = detachedService()
|
||||
const agent = fakeAgent(closedConversation(2), () => undefined)
|
||||
|
||||
expect((await rejection(compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect((await rejection(() => compact.compactNow(agent, SIGNAL))).code).toBe('busy')
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
@@ -638,7 +650,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
|
||||
it('compacts a session with no durable turn boundary without creating one', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
const session = Session.create(SessionId('turnless'))
|
||||
for (const text of [PROMPT, 'recent tail']) {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
@@ -671,7 +683,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('lets a pre-aborted signal win before reservation, measurement, or summarization', async () => {
|
||||
const cases = [
|
||||
{ name: 'busy', session: closedConversation(2), release: undefined },
|
||||
{ name: 'empty', session: new Session(SessionId('pre-aborted-empty')), release: () => undefined },
|
||||
{ name: 'empty', session: Session.create(SessionId('pre-aborted-empty')), release: () => undefined },
|
||||
{ name: 'compactable', session: closedConversation(2, 9), release: () => undefined },
|
||||
] as const
|
||||
|
||||
@@ -685,7 +697,13 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(compact.compactNow(agent, controller.signal)).rejects.toBe(reason)
|
||||
let thrown: unknown
|
||||
try {
|
||||
void compact.compactNow(agent, controller.signal)
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBe(reason)
|
||||
expect(reserve).not.toHaveBeenCalled()
|
||||
expect(measure).not.toHaveBeenCalled()
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
@@ -713,6 +731,21 @@ describe('compactNow transaction and failure classification', () => {
|
||||
.toContain('summarizer aborted')
|
||||
})
|
||||
|
||||
it('classifies agent cancellation during maintenance as an expected cancellation', async () => {
|
||||
const { compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('agent cancelled maintenance')
|
||||
const session = closedConversation(2)
|
||||
const agent = fakeAgent(session, () => () => undefined, controller.signal)
|
||||
compact.duringSummary = () => { controller.abort(reason) }
|
||||
compact.error = new Error('summarizer observed cancellation')
|
||||
|
||||
const error = await rejection(compact.compactNow(agent, SIGNAL))
|
||||
|
||||
expect(error.code).toBe('cancelled')
|
||||
expect(error.cause).toBe(reason)
|
||||
})
|
||||
|
||||
it('aborts before committing when cancellation lands after summarization', async () => {
|
||||
const { compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
@@ -730,7 +763,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
const { ctx, compact } = detachedService()
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('cancelled during flush')
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
const flushGate = Promise.withResolvers<boolean>()
|
||||
const flush = vi.spyOn(ctx.sessions, 'flush').mockReturnValueOnce(flushGate.promise)
|
||||
const session = closedConversation(2)
|
||||
let released = 0
|
||||
@@ -750,7 +783,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
expect(settled).toBe(false)
|
||||
expect(released).toBe(0)
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
flushGate.resolve(false)
|
||||
await expect(running).rejects.toBe(reason)
|
||||
expect(released).toBe(1)
|
||||
})
|
||||
@@ -814,7 +847,7 @@ describe('compactNow transaction and failure classification', () => {
|
||||
it('excludes a manual request while an explicit region compaction runs', async () => {
|
||||
const { compact } = detachedService()
|
||||
const session = closedConversation(3)
|
||||
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/start', { turn: 4 })
|
||||
const agent = fakeAgent(session, () => () => undefined)
|
||||
const gate = deferred()
|
||||
compact.gate = gate.promise
|
||||
|
||||
Reference in New Issue
Block a user