diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml index 4750c9f9b1..4e0e5cbac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.i18n.yaml @@ -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 -2026-07-19-package-invariant-runtime-contracts.md: c8ef723778a3dc704e781b4cfe32705e67f81b5f -2026-07-19-package-invariant-runtime-contracts.zh.md: 7df7ed1ac0743fab3302c47955221721e11fe8c7 +2026-07-19-package-invariant-runtime-contracts.md: 84c632e57b3360756660415d7bdd62397c52e4a3 +2026-07-19-package-invariant-runtime-contracts.zh.md: 51734f6922f393ae37b0c3999723b7aade7651eb diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md index c8ef723778..84c632e57b 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.md @@ -27,7 +27,7 @@ The central `dsh-invariants` service owns only configuration, registration uniqu ### Implemented checks -The current 93-package workspace has 18 executable companions and 75 justified empty companions. +The current 94-package workspace has 19 executable companions and 75 justified empty companions. | Owner | Runtime relationship | |---|---| @@ -36,6 +36,7 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-scope` | Scoped-event carrier presence and routed-subject consistency. | | `dsh-agent-loop` | Frozen loop request reconstruction from the session event log. | | `dsh-llm` | Stream block grammar, delta type/index matching, single usage, closed blocks, and terminal finish. | +| `dsh-llm-retry` | Durable retry records identify the open turn's latest closed step, remain unique per step, increase monotonically, and stay within retry and timer bounds. | | `dsh-tools` | Monotonic pre/execute/post stages and immutable final execution/result snapshots. | | `dsh-system-prompt` | Authoritative assembly section, tool, and variable data constraints. | | `dsh-compact` | Compaction start/summary/end pairing, range endpoints, token counts, and successful-summary presence. | @@ -48,13 +49,13 @@ The current 93-package workspace has 18 executable companions and 75 justified e | `dsh-workflow` | Workflow and child-agent start/end events preserve run metadata, identity, outcome, count, and error relations. | | `dsh-tasks` | Current and terminal task snapshots preserve id/kind, owner, status, and timestamp relationships. | | `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. | -| `dsh-time-context` | Plugin-attributed clock readings agree across turn, step, elapsed baseline, rendered time, and event time. | +| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn and next pre-step position, elapsed baseline, rendered time, and event time. | Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state. ### Repository gate and tests -`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter. The gate deliberately does not infer semantic quality from method names or helper calls. +`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls. Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology loads all companions to prove registration and disposal wiring. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation. diff --git a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md index 7df7ed1ac0..51734f6922 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-package-invariant-runtime-contracts.zh.md @@ -1,6 +1,6 @@ -# Agent Note:有意义的包不变量契约 +# Agent Note: 有意义的包不变量契约 -状态:已实施 +Status: implemented [English](2026-07-19-package-invariant-runtime-contracts.md) | 中文 @@ -27,7 +27,7 @@ ### 已实施的检查 -当前 93 个包的 workspace 包含 18 个可执行 companion 和 75 个有理由的空 companion。 +当前 94 个包的 workspace 包含 19 个可执行 companion 和 75 个有理由的空 companion。 | 所有者 | 运行时关系 | |---|---| @@ -36,6 +36,7 @@ | `dsh-scope` | scoped event 必须携带 carrier,且路由 subject 保持一致。 | | `dsh-agent-loop` | 从 session 事件日志重建冻结的 loop 请求。 | | `dsh-llm` | stream block 文法、delta 类型/索引匹配、单次 usage、block 闭合和终止 finish。 | +| `dsh-llm-retry` | 持久化重试记录指向当前打开 turn 中最近关闭的 step;每个 step 的记录保持唯一,重试次数单调递增,并且重试次数和定时器延迟均保持在边界内。 | | `dsh-tools` | pre/execute/post 阶段单调推进,以及最终 execution/result 快照不可变。 | | `dsh-system-prompt` | 权威 assembly 中 section、tool 和 variable 的数据约束。 | | `dsh-compact` | compaction start/summary/end 配对、范围端点、token 数量和成功时必须存在 summary。 | @@ -48,13 +49,13 @@ | `dsh-workflow` | workflow 和 child-agent start/end 事件保持 run metadata、身份、outcome、数量和 error 关系。 | | `dsh-tasks` | 当前与终态 task snapshot 保持 id/kind、owner、status 和 timestamp 关系。 | | `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 | -| `dsh-time-context` | 标注插件来源的时钟 reading 在 turn、step、elapsed baseline、渲染时间和事件时间之间保持一致。 | +| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn 和下一个 step 开始前的位置,并在 elapsed baseline、渲染时间和事件时间之间保持一致。 | 基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。 ### 仓库门禁与测试 -`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器。门禁不会通过方法名或 helper 调用推断语义质量。 +`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。 Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑加载全部 companion,以证明注册和释放 wiring。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0651237396..3b8fb21176 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:44`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | @@ -54,7 +54,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | -| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/status` | - | [`agent`](../packages/core/agent) | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 388caec4d1..4460e1764c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -240,6 +240,7 @@ flowchart TD pkg_sandbox_policy --> pkg_sandbox pkg_sandbox_policy --> pkg_session pkg_llm_retry --> pkg_agent + pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_timeout @@ -596,7 +597,7 @@ flowchart TD | [`llm-replay`](../packages/support/llm-replay) | `support` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | -| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | diff --git a/docs/testing.md b/docs/testing.md index 32ef0716d4..c158c9b922 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,8 +9,6 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -Every Vitest configuration mounts enabled `ctx.invariants` before ordinary roots start. Package tests add their owner's companion; one exhaustive topology mounts them all. Focused invariant topology tests compose enabled or deliberately disabled services without competing global registrations. - ## The with-key policy: inference is cheap here We are DeepSeek — do not ration real-API tests. A no-key test proves plumbing; only a with-key run proves the agent works against a real model. Write many: file-writing prompts, multi-turn conversations, tool use, cancellation mid-stream. Highest-value are **smoke tests** that boot the real example, send one real prompt, and check the world — they catch the "green unit tests, broken product" class that mocks structurally cannot ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md)). The self-skip exists only so secretless CI and keyless contributors aren't blocked; it is not a cost signal. Every example ships both a keyless smoke and a with-key smoke ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index 804d419627..134d2233ee 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -18,8 +18,39 @@ export const name = 'time-context-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Validate one plugin-attributed time reading against its durable event timestamp. */ -function validateReading(event: SessionEvent<'context/message'>, fail: InvariantFailure): void { +/** Derive the pre-step position at which a time-context reading may append. */ +function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } { + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const event of session.events.slice().reverse()) { + if (event.type === 'turn/end') { + fail('time-context reading must be appended inside an open turn') + } + if (event.type === 'turn/start') { + openTurn = event.data.turn + break + } + currentTurnEvents.push(event) + } + if (openTurn === undefined) fail('time-context reading must be appended inside an open turn') + + for (const event of currentTurnEvents) { + if (event.type === 'step/start') { + fail(`time-context reading must precede step/start, but step ${event.data.step} is already open`) + } + if (event.type === 'step/end') { + return { turn: openTurn, step: event.data.step + 1 } + } + } + return { turn: openTurn, step: 1 } +} + +/** Validate one plugin-attributed time reading against its session position and timestamp. */ +function validateReading( + session: Session, + event: SessionEvent<'context/message'>, + fail: InvariantFailure, +): void { const [block] = event.data.content if (event.data.content.length !== 1 || block?.type !== 'text') { fail('time-context messages must contain exactly one text block') @@ -31,6 +62,10 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) { fail('time-context turn and step must be positive safe integers') } + const expected = preparationPosition(session, fail) + if (turn !== expected.turn || step !== expected.step) { + fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`) + } const baseline = match[4] if ((step === 1) !== (baseline === 'model-visible message')) { fail(`time-context step ${step} uses the wrong elapsed-time baseline ${JSON.stringify(baseline)}`) @@ -49,11 +84,11 @@ function validateReading(event: SessionEvent<'context/message'>, fail: Invariant const install: InvariantInstaller = (ctx, fail) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return - const event = (args as [Session, SessionEvent])[1] + const [session, event] = args as [Session, SessionEvent] if (event.type !== 'context/message' || event.data.source.kind !== 'plugin' || event.data.source.plugin !== SOURCE_NAME) return - validateReading(event, fail) + validateReading(session, event, fail) }, { global: true }) } diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index eab422e95e..b19b56c99f 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -36,12 +36,56 @@ function reading( + `Elapsed since the preceding ${baseline}: unavailable.` } +function preparing(turn: number, step: number): Session { + const session = new Session(SessionId(`time-invariant-${turn}-${step}`)) + for (let priorTurn = 1; priorTurn < turn; priorTurn += 1) { + session.append('turn/start', { turn: priorTurn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: priorTurn, reason: { kind: 'completed' } }) + } + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + for (let priorStep = 1; priorStep < step; priorStep += 1) { + session.append('step/start', { turn, step: priorStep }) + session.append('step/end', { turn, step: priorStep }) + } + return session +} + describe('time-context invariants', () => { it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => { const ctx = await setup() const text = 'Time sampled while preparing turn 2, step 3: 2026-07-14T00:00:00+00:00[UTC]\n' + 'Elapsed since the preceding step context: 4m 2s.' - expect(() => { ctx.emit('session/event', {} as Session, event(text)) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).not.toThrow() + }) + + it.each([ + [reading('1', '3', 'step context'), /expected turn 2\/step 3/], + [reading('2', '2', 'step context'), /expected turn 2\/step 3/], + ])('rejects a reading that disagrees with its session position', async (text, message) => { + const ctx = await setup() + expect(() => { ctx.emit('session/event', preparing(2, 3), event(text)) }).toThrow(message) + }) + + it('rejects a reading after cancellation closes the turn', async () => { + const ctx = await setup() + const session = preparing(1, 2) + session.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { ctx.emit('session/event', session, event(reading('1', '2', 'step context'))) }) + .toThrow(/inside an open turn/) + }) + + it('rejects a reading after step/start or without any open turn', async () => { + const ctx = await setup() + const started = preparing(1, 1) + started.append('step/start', { turn: 1, step: 1 }) + expect(() => { ctx.emit('session/event', started, event(reading())) }).toThrow(/must precede step\/start/) + expect(() => { + ctx.emit('session/event', new Session(SessionId('time-invariant-empty')), event(reading())) + }).toThrow(/inside an open turn/) }) it.each([ @@ -61,8 +105,13 @@ describe('time-context invariants', () => { ['ignored', SECOND, [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], /exactly one text block/], ] as const)('rejects an incoherent durable reading', async (text, time, content, message) => { const ctx = await setup() + const preparationStep = text.includes('turn 1, step 2:') ? 2 : 1 expect(() => { - ctx.emit('session/event', {} as Session, event(text, time, content === undefined ? undefined : [...content])) + ctx.emit('session/event', preparing(1, preparationStep), event( + text, + time, + content === undefined ? undefined : [...content], + )) }).toThrow(message) }) @@ -70,11 +119,11 @@ describe('time-context invariants', () => { const ctx = await setup() const other = event('unrelated') as SessionEvent<'context/message'> other.data.source = { kind: 'plugin', plugin: 'other' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() other.data.source = { kind: 'user' } - expect(() => { ctx.emit('session/event', {} as Session, other) }).not.toThrow() + expect(() => { ctx.emit('session/event', preparing(1, 1), other) }).not.toThrow() expect(() => { - ctx.emit('session/event', {} as Session, { + ctx.emit('session/event', preparing(1, 1), { type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, }) ctx.emit('tools/change') diff --git a/packages/llm/llm-retry/README.md b/packages/llm/llm-retry/README.md index f84fdba09a..22e4cb43e5 100644 --- a/packages/llm/llm-retry/README.md +++ b/packages/llm/llm-retry/README.md @@ -6,6 +6,8 @@ The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, an Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward. +The separately published `./invariant` companion checks that every retry record names the current open turn and its latest closed step, has a unique step record and increasing retry number, and carries a positive bounded retry budget and timer delay. + ```yaml - name: '@deepseek-ai/dsh-llm-retry' config: diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index fce40dbe33..6d6c27636c 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -11,10 +11,15 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, "./package.json": "./package.json" }, "files": [ "lib/index.js", + "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -22,6 +27,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -35,6 +41,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts new file mode 100644 index 0000000000..b925e9148d --- /dev/null +++ b/packages/llm/llm-retry/src/invariant.ts @@ -0,0 +1,97 @@ +/** Package-owned durable retry-event invariants. @module @deepseek-ai/dsh-llm-retry/invariant */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type {} from './index.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-retry' + +/** Cordis companion plugin name. */ +export const name = 'llm-retry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Validate one retry record against the open turn and most recently closed step. */ +function validateRetry( + history: readonly SessionEvent[], + event: SessionEvent<'llm/retry'>, + fail: InvariantFailure, +): void { + const { turn, step, retry, maxRetries, delayMs } = event.data + if (!Number.isSafeInteger(retry) || retry < 1) { + fail('llm/retry retry must be a positive safe integer') + } + if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || retry > maxRetries) { + fail(`llm/retry retry ${retry} must not exceed a positive safe maxRetries ${maxRetries}`) + } + if (!(delayMs > 0 && delayMs <= MAX_TIMER_DELAY_MS)) { + fail(`llm/retry delayMs must be within 1..${MAX_TIMER_DELAY_MS}`) + } + + const currentTurnEvents: SessionEvent[] = [] + let openTurn: number | undefined + for (const prior of history.slice().reverse()) { + if (prior.type === 'turn/end') fail('llm/retry must be appended inside an open turn') + if (prior.type === 'turn/start') { + openTurn = prior.data.turn + break + } + currentTurnEvents.push(prior) + } + if (openTurn === undefined) fail('llm/retry must be appended inside an open turn') + if (turn !== openTurn) { + fail(`llm/retry names turn ${turn}, but the open turn is ${openTurn}`) + } + + let closedStep: number | undefined + for (const prior of currentTurnEvents) { + if (prior.type === 'step/start') { + fail(`llm/retry must follow step/end, but step ${prior.data.step} is still open`) + } + if (prior.type === 'step/end') { + closedStep = prior.data.step + break + } + } + if (closedStep === undefined || step !== closedStep) { + fail(`llm/retry names step ${step}, but the latest closed step is ${String(closedStep)}`) + } + + const priorRetries = currentTurnEvents + .filter((prior): prior is SessionEvent<'llm/retry'> => prior.type === 'llm/retry') + if (priorRetries.some(prior => prior.data.step === step)) { + fail(`llm/retry duplicates the retry record for turn ${turn}/step ${step}`) + } + const priorRetry = priorRetries[0] + if (priorRetry !== undefined && retry <= priorRetry.data.retry) { + fail(`llm/retry retry ${retry} must increase after retry ${priorRetry.data.retry}`) + } +} + +/** Validate every retry record already present in one loaded session. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const [index, event] of session.events.entries()) { + if (event.type === 'llm/retry') validateRetry(session.events.slice(0, index), event, fail) + } +} + +/** Install validation for loaded and newly appended retry records. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('session/created', (session) => { validateSession(session, fail) }, { global: true }) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [session, event] = args as [Session, SessionEvent] + if (event.type === 'llm/retry') validateRetry(session.events, event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) + +/** + * Register the LLM retry invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts new file mode 100644 index 0000000000..4ffffd4c4b --- /dev/null +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant' + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService) + await ctx.plugin(RetryInvariant) + return ctx +} + +function closeStep(ctx: Context, id: string, turn = 1, step = 1) { + const session = ctx.sessions.create(SessionId(id)) + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn, step }) + session.append('step/end', { turn, step }) + return session +} + +const failure = { message: 'provider busy', code: 'RATE_LIMIT', status: 429 } + +describe('llm-retry invariants', () => { + it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { + const ctx = await setup() + const session = closeStep(ctx, 'retry-invariant-valid') + expect(() => { + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 500, failure, + }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('llm/retry', { + turn: 1, step: 2, retry: 2, maxRetries: 2, delayMs: 1_000, failure, + }) + }).not.toThrow() + expect(() => { ctx.emit('tools/change') }).not.toThrow() + }) + + it.each([ + [{ retry: 0, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1.5, maxRetries: 2, delayMs: 1 }, /positive safe integer/], + [{ retry: 1, maxRetries: 0, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 1, maxRetries: 1.5, delayMs: 1 }, /positive safe maxRetries/], + [{ retry: 3, maxRetries: 2, delayMs: 1 }, /must not exceed/], + [{ retry: 1, maxRetries: 2, delayMs: 0 }, /delayMs/], + [{ retry: 1, maxRetries: 2, delayMs: MAX_TIMER_DELAY_MS + 1 }, /delayMs/], + ])('rejects invalid retry bounds %#', async (data, message) => { + const ctx = await setup() + const session = closeStep(ctx, `retry-invariant-bounds-${data.retry}-${data.maxRetries}-${data.delayMs}`) + expect(() => { + session.append('llm/retry', { turn: 1, step: 1, ...data, failure }) + }).toThrow(message) + }) + + it('rejects retry records outside the matching closed-step boundary', async () => { + const ctx = await setup() + const absent = ctx.sessions.create(SessionId('retry-invariant-no-turn')) + expect(() => { + absent.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + + const wrongTurn = closeStep(ctx, 'retry-invariant-wrong-turn') + expect(() => { + wrongTurn.append('llm/retry', { + turn: 2, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/open turn is 1/) + + const openStep = ctx.sessions.create(SessionId('retry-invariant-open-step')) + openStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + openStep.append('step/start', { turn: 1, step: 1 }) + expect(() => { + openStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/step 1 is still open/) + + const noStep = ctx.sessions.create(SessionId('retry-invariant-no-step')) + noStep.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + expect(() => { + noStep.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is undefined/) + + const wrongStep = closeStep(ctx, 'retry-invariant-wrong-step') + expect(() => { + wrongStep.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/latest closed step is 1/) + + const closedTurn = closeStep(ctx, 'retry-invariant-closed-turn') + closedTurn.append('turn/end', { turn: 1, reason: { kind: 'aborted', reason: 'cancelled' } }) + expect(() => { + closedTurn.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + }).toThrow(/inside an open turn/) + }) + + it('rejects duplicate and non-increasing retry records', async () => { + const ctx = await setup() + const duplicate = closeStep(ctx, 'retry-invariant-duplicate') + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + expect(() => { + duplicate.append('llm/retry', { + turn: 1, step: 1, retry: 2, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/duplicates the retry record/) + + const nonIncreasing = closeStep(ctx, 'retry-invariant-non-increasing') + nonIncreasing.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + nonIncreasing.append('step/start', { turn: 1, step: 2 }) + nonIncreasing.append('step/end', { turn: 1, step: 2 }) + expect(() => { + nonIncreasing.append('llm/retry', { + turn: 1, step: 2, retry: 1, maxRetries: 3, delayMs: 1, failure, + }) + }).toThrow(/must increase/) + }) + + it('validates existing histories on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('retry-invariant-late')) + session.append('step/end', { turn: 1, step: 1 }) + session.append('llm/retry', { + turn: 1, step: 1, retry: 1, maxRetries: 2, delayMs: 1, failure, + }) + await ctx.plugin(InvariantService) + await expect(ctx.plugin(RetryInvariant)).rejects.toThrow(/inside an open turn/) + }) +}) diff --git a/packages/llm/llm-retry/tsconfig.json b/packages/llm/llm-retry/tsconfig.json index 44310af6e9..48c858951a 100644 --- a/packages/llm/llm-retry/tsconfig.json +++ b/packages/llm/llm-retry/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../core/agent" }, + { + "path": "../../support/invariants" + }, { "path": "../../util/timeout" } diff --git a/packages/sdk/telemetry/README.md b/packages/sdk/telemetry/README.md index 4ff1cf1387..01a2ee6c2d 100644 --- a/packages/sdk/telemetry/README.md +++ b/packages/sdk/telemetry/README.md @@ -10,8 +10,6 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li | `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). | | `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. | -The separately published `./invariant` diagnostic companion samples the security-critical redaction boundary: credential values must disappear, ordinary package metadata must survive, and a second redaction pass must be idempotent. - Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`. The collection endpoint is a fixed constant (`DSH_TELEMETRY_ENDPOINT`); its `.invalid` placeholder must be replaced with the real endpoint before release. diff --git a/packages/sdk/telemetry/src/redaction-contract.ts b/packages/sdk/telemetry/src/redaction-contract.ts deleted file mode 100644 index c18a582308..0000000000 --- a/packages/sdk/telemetry/src/redaction-contract.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** Structural redactor surface sampled by the telemetry invariant. */ -export interface TelemetryRedactionBoundary { - /** Redact credential material in free-form telemetry content. */ - redactText(value: string): string -} - -/** - * Validate the security-critical telemetry redaction boundary. - * @param redactor - candidate content redactor. - * @param placeholder - expected replacement for a secret value. - * @param packageName - ordinary package metadata that must survive redaction. - * @returns the violated contract, or `undefined` when redaction is safe and idempotent. - */ -export function telemetryRedactionViolation( - redactor: TelemetryRedactionBoundary, - placeholder: string, - packageName: string, -): string | undefined { - const secret = 'sk-abcdefghij1234567890' - const redacted = redactor.redactText(`apiKey: ${secret}\nname: ${packageName}\n`) - return !redacted.includes(secret) - && redacted.includes(`apiKey: ${placeholder}`) - && redacted.includes(`name: ${packageName}`) - && redactor.redactText(redacted) === redacted - ? undefined - : 'telemetry redaction must remove credential values, preserve package metadata, and remain idempotent' -} diff --git a/packages/sdk/telemetry/tests/redaction-contract.spec.ts b/packages/sdk/telemetry/tests/redaction-contract.spec.ts deleted file mode 100644 index 0fcc05e5b4..0000000000 --- a/packages/sdk/telemetry/tests/redaction-contract.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { SecretRedactor } from '../src/secret-redactor.ts' -import { telemetryRedactionViolation } from '../src/redaction-contract.ts' - -describe('telemetryRedactionViolation', () => { - it('accepts the shipped redactor and rejects one that preserves credentials', () => { - expect(telemetryRedactionViolation( - new SecretRedactor(), - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBeUndefined() - expect(telemetryRedactionViolation( - { redactText: value => value }, - '[REDACTED]', - '@deepseek-ai/dsh-telemetry', - )).toBe('telemetry redaction must remove credential values, preserve package metadata, and remain idempotent') - }) -}) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 4cd2aa1f9b..f05855274f 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -35,12 +35,12 @@ The current executable companions protect these relationships: | Companion | Checks | |---|---| | `dsh-session`, `dsh-agent`, `dsh-scope`, `dsh-agent-loop` | Session enclosure and call/result trace, agent-status transitions, scoped subjects, and model-request reconstruction. | -| `dsh-llm`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | +| `dsh-llm`, `dsh-llm-retry`, `dsh-tools`, `dsh-system-prompt` | Stream grammar, durable retry position and bounds, tool-pipeline stages and frozen results, and authoritative prompt-assembly data. | | `dsh-compact`, `dsh-hook-protocol`, `dsh-sandbox-policy` | Durable compaction and hook pairing, compaction metadata, and sandbox-mode vocabulary. | | `dsh-fs`, `dsh-subagent`, `dsh-workflow` | Filesystem event identity, provider/child pairing, and workflow/agent lifecycle identity. | | `dsh-permission`, `dsh-user-approval` | Active-preset references and approval asked/decided audit pairing. | | `dsh-tasks`, `dsh-tool-todo` | Task snapshot lifecycle/ownership fields and durable whole-list todo structure. | -| `dsh-time-context` | Durable clock readings agree with their turn, step, elapsed baseline, and event timestamp. | +| `dsh-time-context` | Durable clock readings agree with the session's open turn and next pre-step position, elapsed baseline, and event timestamp. | The root entrypoint of each owner remains independent of diagnostics. Loading the service alone installs no product checks, and loading a companion without the service waits on its declared `invariants` injection. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 573c95a2a4..c616cdf9a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 1014ce5033..54d777ac9a 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -138,6 +138,20 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo .toContain('install function must use its bound failure reporter') }) + it('rejects registering a different installer than the checked local function', () => { + const decoy = fixture({ + source: ` +export const name = 'probe-invariant' +export const inject = ['invariants'] +const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') } +export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) => + ctx.invariants.register('@deepseek-ai/dsh-probe', () => {}) +`, + }) + expect(collectPackageInvariantViolations(decoy).map(violation => violation.message)) + .toContain('line 6: ctx.invariants.register must use the checked local install function') + }) + it('accepts explained empty installers and rejects unexplained ones', () => { const explained = ` export const name = 'probe-invariant' diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index 6b099c084d..931f6ff588 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -167,12 +167,18 @@ function checkSource( const constants = topLevelStringConstants(sourceFile) const registrations: string[] = [] const unresolved: number[] = [] + const mismatchedInstallers: number[] = [] const visit = (node: ts.Node): void => { if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) { + const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1 const argument = node.arguments[0] const packageName = argument === undefined ? undefined : stringValue(argument, constants) - if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1) + if (packageName === undefined) unresolved.push(line) else registrations.push(packageName) + const installer = node.arguments[1] + if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') { + mismatchedInstallers.push(line) + } } ts.forEachChild(node, visit) } @@ -185,6 +191,13 @@ function checkSource( `line ${line}: ctx.invariants.register package name must resolve to a local string constant`, ) } + for (const line of mismatchedInstallers) { + addViolation( + violations, + owner.sourcePath, + `line ${line}: ctx.invariants.register must use the checked local install function`, + ) + } if (registrations.length !== 1 || registrations[0] !== owner.packageName) { addViolation( violations, diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index 52182cafaf..c16c9402b5 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -83,6 +83,7 @@ describe('global test invariant host', () => { '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts', diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 2f5eec6271..1f218e16f9 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -42,6 +42,7 @@ export const MANUAL_INVARIANT_TESTS = [ '/packages/fs/fs/tests/invariant.spec.ts', '/packages/hooks/hook-protocol/tests/invariant.spec.ts', '/packages/llm/llm/tests/invariant.spec.ts', + '/packages/llm/llm-retry/tests/invariant.spec.ts', '/packages/sandbox/sandbox-policy/tests/invariant.spec.ts', '/packages/subagent/subagent/tests/invariant.spec.ts', '/packages/tasks/tasks/tests/invariant.spec.ts',