comisai 1.0.19 → 1.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/dist/cli-entry.js +0 -0
  2. package/node_modules/@comis/agent/dist/context-engine/context-engine.js +43 -2
  3. package/node_modules/@comis/agent/dist/context-engine/signature-replay-scrubber.d.ts +51 -0
  4. package/node_modules/@comis/agent/dist/context-engine/signature-replay-scrubber.js +110 -0
  5. package/node_modules/@comis/agent/dist/context-engine/signature-surrogate-guard.d.ts +54 -0
  6. package/node_modules/@comis/agent/dist/context-engine/signature-surrogate-guard.js +145 -0
  7. package/node_modules/@comis/agent/dist/context-engine/types-core.d.ts +17 -0
  8. package/node_modules/@comis/agent/dist/executor/error-classifier.d.ts +11 -1
  9. package/node_modules/@comis/agent/dist/executor/error-classifier.js +13 -0
  10. package/node_modules/@comis/agent/dist/executor/executor-context-engine-setup.d.ts +1 -0
  11. package/node_modules/@comis/agent/dist/executor/executor-context-engine-setup.js +55 -0
  12. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +106 -5
  13. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +1 -0
  14. package/node_modules/@comis/agent/dist/executor/pi-executor.d.ts +1 -4
  15. package/node_modules/@comis/agent/dist/executor/replay-drift-detector.d.ts +85 -0
  16. package/node_modules/@comis/agent/dist/executor/replay-drift-detector.js +92 -0
  17. package/node_modules/@comis/agent/dist/executor/signature-block-scrubber.d.ts +34 -0
  18. package/node_modules/@comis/agent/dist/executor/signature-block-scrubber.js +69 -0
  19. package/node_modules/@comis/agent/dist/executor/signed-replay-detector.d.ts +39 -0
  20. package/node_modules/@comis/agent/dist/executor/signed-replay-detector.js +72 -0
  21. package/node_modules/@comis/agent/package.json +1 -1
  22. package/node_modules/@comis/channels/package.json +1 -1
  23. package/node_modules/@comis/cli/dist/cli.js +0 -0
  24. package/node_modules/@comis/cli/package.json +1 -1
  25. package/node_modules/@comis/core/dist/config/git-manager.js +10 -4
  26. package/node_modules/@comis/core/dist/config/index.d.ts +1 -0
  27. package/node_modules/@comis/core/dist/config/index.js +2 -0
  28. package/node_modules/@comis/core/dist/config/managed-sections.d.ts +67 -0
  29. package/node_modules/@comis/core/dist/config/managed-sections.js +124 -0
  30. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +28 -10
  31. package/node_modules/@comis/core/dist/config/schema-agent.js +6 -0
  32. package/node_modules/@comis/core/dist/config/schema-gateway.d.ts +2 -2
  33. package/node_modules/@comis/core/dist/config/schema.d.ts +65 -64
  34. package/node_modules/@comis/core/dist/event-bus/events-messaging.d.ts +16 -0
  35. package/node_modules/@comis/core/dist/exports/config.d.ts +1 -1
  36. package/node_modules/@comis/core/dist/exports/config.js +1 -1
  37. package/node_modules/@comis/core/package.json +1 -1
  38. package/node_modules/@comis/daemon/bundled-skills/skill-creator/scripts/init-skill.py +0 -0
  39. package/node_modules/@comis/daemon/bundled-skills/skill-creator/scripts/validate-skill.py +0 -0
  40. package/node_modules/@comis/daemon/dist/daemon.js +0 -0
  41. package/node_modules/@comis/daemon/dist/rpc/config-handlers.js +20 -7
  42. package/node_modules/@comis/daemon/dist/rpc/session-handlers.js +27 -1
  43. package/node_modules/@comis/daemon/package.json +1 -1
  44. package/node_modules/@comis/gateway/package.json +1 -1
  45. package/node_modules/@comis/infra/package.json +1 -1
  46. package/node_modules/@comis/memory/package.json +1 -1
  47. package/node_modules/@comis/scheduler/package.json +1 -1
  48. package/node_modules/@comis/shared/package.json +1 -1
  49. package/node_modules/@comis/skills/dist/bridge/tool-metadata-registry.js +23 -8
  50. package/node_modules/@comis/skills/dist/builtin/platform/gateway-tool.d.ts +1 -1
  51. package/node_modules/@comis/skills/dist/builtin/platform/gateway-tool.js +18 -14
  52. package/node_modules/@comis/skills/dist/builtin/platform/unified-session-tool.js +1 -1
  53. package/node_modules/@comis/skills/package.json +1 -1
  54. package/node_modules/@comis/web/package.json +1 -1
  55. package/package.json +24 -26
  56. package/node_modules/@comis/agent/dist/provider/response/strip-minimax-xml.d.ts +0 -9
  57. package/node_modules/@comis/agent/dist/provider/response/strip-minimax-xml.js +0 -17
  58. package/node_modules/@comis/agent/dist/provider/response/strip-model-tokens.d.ts +0 -13
  59. package/node_modules/@comis/agent/dist/provider/response/strip-model-tokens.js +0 -19
  60. package/node_modules/@comis/agent/dist/provider/response/strip-tool-text.d.ts +0 -11
  61. package/node_modules/@comis/agent/dist/provider/response/strip-tool-text.js +0 -32
  62. package/node_modules/@comis/agent/dist/safety/follow-through-detector.d.ts +0 -46
  63. package/node_modules/@comis/agent/dist/safety/follow-through-detector.js +0 -76
  64. package/node_modules/@comis/agent/dist/safety/post-compaction-safety.d.ts +0 -30
  65. package/node_modules/@comis/agent/dist/safety/post-compaction-safety.js +0 -51
  66. package/node_modules/@comis/agent/dist/safety/schema-normalizer.d.ts +0 -37
  67. package/node_modules/@comis/agent/dist/safety/schema-normalizer.js +0 -137
  68. package/node_modules/@comis/agent/dist/safety/schema-pruning.d.ts +0 -50
  69. package/node_modules/@comis/agent/dist/safety/schema-pruning.js +0 -112
  70. package/node_modules/@comis/agent/dist/safety/tool-image-sanitizer.d.ts +0 -43
  71. package/node_modules/@comis/agent/dist/safety/tool-image-sanitizer.js +0 -96
  72. package/node_modules/@comis/agent/dist/safety/tool-sanitizer.d.ts +0 -44
  73. package/node_modules/@comis/agent/dist/safety/tool-sanitizer.js +0 -94
  74. package/node_modules/@comis/channels/dist/shared/thinking-tag-filter.d.ts +0 -28
  75. package/node_modules/@comis/channels/dist/shared/thinking-tag-filter.js +0 -206
  76. package/node_modules/@comis/cli/dist/wizard/config-writer.d.ts +0 -25
  77. package/node_modules/@comis/cli/dist/wizard/config-writer.js +0 -144
  78. package/node_modules/@comis/cli/dist/wizard/flow-types.d.ts +0 -48
  79. package/node_modules/@comis/cli/dist/wizard/flow-types.js +0 -70
  80. package/node_modules/@comis/cli/dist/wizard/manual-flow.d.ts +0 -21
  81. package/node_modules/@comis/cli/dist/wizard/manual-flow.js +0 -345
  82. package/node_modules/@comis/cli/dist/wizard/quickstart-flow.d.ts +0 -21
  83. package/node_modules/@comis/cli/dist/wizard/quickstart-flow.js +0 -116
  84. package/node_modules/@comis/core/dist/config/schema-agent-model.d.ts +0 -135
  85. package/node_modules/@comis/core/dist/config/schema-agent-model.js +0 -114
  86. package/node_modules/@comis/core/dist/config/schema-agent-session.d.ts +0 -177
  87. package/node_modules/@comis/core/dist/config/schema-agent-session.js +0 -116
  88. package/node_modules/@comis/core/dist/config/schema-context-engine.d.ts +0 -92
  89. package/node_modules/@comis/core/dist/config/schema-context-engine.js +0 -92
  90. package/node_modules/@comis/core/dist/config/schema-context-guard.d.ts +0 -34
  91. package/node_modules/@comis/core/dist/config/schema-context-guard.js +0 -32
  92. package/node_modules/@comis/core/dist/config/schema-delivery-mirror.d.ts +0 -27
  93. package/node_modules/@comis/core/dist/config/schema-delivery-mirror.js +0 -26
  94. package/node_modules/@comis/core/dist/config/schema-delivery-queue.d.ts +0 -31
  95. package/node_modules/@comis/core/dist/config/schema-delivery-queue.js +0 -30
  96. package/node_modules/@comis/core/dist/config/schema-delivery-timing.d.ts +0 -41
  97. package/node_modules/@comis/core/dist/config/schema-delivery-timing.js +0 -31
  98. package/node_modules/@comis/core/dist/config/schema-monitoring.d.ts +0 -105
  99. package/node_modules/@comis/core/dist/config/schema-monitoring.js +0 -67
  100. package/node_modules/@comis/core/dist/ports/media-ports.d.ts +0 -278
  101. package/node_modules/@comis/core/dist/ports/media-ports.js +0 -1
  102. package/node_modules/@comis/core/dist/security/input-guard.d.ts +0 -46
  103. package/node_modules/@comis/core/dist/security/input-guard.js +0 -166
  104. package/node_modules/@comis/core/dist/security/scoped-secret-manager.d.ts +0 -38
  105. package/node_modules/@comis/core/dist/security/scoped-secret-manager.js +0 -94
  106. package/node_modules/@comis/daemon/dist/observability/delivery-context.d.ts +0 -37
  107. package/node_modules/@comis/daemon/dist/observability/delivery-context.js +0 -1
  108. package/node_modules/@comis/daemon/dist/observability/log-level-manager.d.ts +0 -23
  109. package/node_modules/@comis/daemon/dist/observability/log-level-manager.js +0 -34
  110. package/node_modules/@comis/daemon/dist/observability/log-transport.d.ts +0 -44
  111. package/node_modules/@comis/daemon/dist/observability/log-transport.js +0 -74
  112. package/node_modules/@comis/daemon/dist/observability/obs-write-buffer.d.ts +0 -53
  113. package/node_modules/@comis/daemon/dist/observability/obs-write-buffer.js +0 -68
  114. package/node_modules/@comis/daemon/dist/observability/types.d.ts +0 -6
  115. package/node_modules/@comis/daemon/dist/observability/types.js +0 -1
  116. package/node_modules/@comis/daemon/dist/wiring/seed-bundled-skills.d.ts +0 -41
  117. package/node_modules/@comis/daemon/dist/wiring/seed-bundled-skills.js +0 -84
  118. package/node_modules/@comis/daemon/dist/wiring/setup-delivery-mirror.d.ts +0 -24
  119. package/node_modules/@comis/daemon/dist/wiring/setup-delivery-mirror.js +0 -88
  120. package/node_modules/@comis/daemon/dist/wiring/setup-delivery-queue.d.ts +0 -31
  121. package/node_modules/@comis/daemon/dist/wiring/setup-delivery-queue.js +0 -132
  122. package/node_modules/@comis/daemon/dist/wiring/setup-monitoring.d.ts +0 -38
  123. package/node_modules/@comis/daemon/dist/wiring/setup-monitoring.js +0 -100
  124. package/node_modules/@comis/daemon/dist/wiring/setup-rpc-bridge.d.ts +0 -34
  125. package/node_modules/@comis/daemon/dist/wiring/setup-rpc-bridge.js +0 -52
  126. package/node_modules/@comis/daemon/dist/wiring/setup-task-extraction.d.ts +0 -41
  127. package/node_modules/@comis/daemon/dist/wiring/setup-task-extraction.js +0 -86
  128. package/node_modules/@comis/memory/dist/embedding-cache.d.ts +0 -36
  129. package/node_modules/@comis/memory/dist/embedding-cache.js +0 -94
  130. package/node_modules/@comis/skills/dist/bridge/tool-output-schemas.d.ts +0 -17
  131. package/node_modules/@comis/skills/dist/bridge/tool-output-schemas.js +0 -125
  132. package/node_modules/@comis/skills/dist/bridge/tool-parallelism-metadata.d.ts +0 -14
  133. package/node_modules/@comis/skills/dist/bridge/tool-parallelism-metadata.js +0 -92
  134. package/node_modules/@comis/skills/dist/bridge/tool-result-caps.d.ts +0 -14
  135. package/node_modules/@comis/skills/dist/bridge/tool-result-caps.js +0 -36
  136. package/node_modules/@comis/skills/dist/bridge/tool-search-hints.d.ts +0 -15
  137. package/node_modules/@comis/skills/dist/bridge/tool-search-hints.js +0 -68
  138. package/node_modules/@comis/skills/dist/bridge/tool-validators.d.ts +0 -11
  139. package/node_modules/@comis/skills/dist/bridge/tool-validators.js +0 -105
  140. package/node_modules/@comis/skills/dist/builtin/file/find-sort-wrapper.d.ts +0 -22
  141. package/node_modules/@comis/skills/dist/builtin/file/find-sort-wrapper.js +0 -95
  142. package/node_modules/@comis/skills/dist/builtin/file/grep-output-mode-wrapper.d.ts +0 -24
  143. package/node_modules/@comis/skills/dist/builtin/file/grep-output-mode-wrapper.js +0 -167
  144. package/node_modules/@comis/skills/dist/builtin/task-plan-tool.d.ts +0 -25
  145. package/node_modules/@comis/skills/dist/builtin/task-plan-tool.js +0 -67
  146. package/node_modules/@comis/skills/dist/integrations/mcp-tool-bridge.d.ts +0 -75
  147. package/node_modules/@comis/skills/dist/integrations/mcp-tool-bridge.js +0 -235
package/dist/cli-entry.js CHANGED
File without changes
@@ -18,6 +18,8 @@
18
18
  import { LAYER_CIRCUIT_BREAKER_THRESHOLD, CHARS_PER_TOKEN_RATIO, DEFAULT_COMPACTION_PREFIX_ANCHOR_TURNS } from "./constants.js";
19
19
  import { computeTokenBudget } from "./token-budget.js";
20
20
  import { createThinkingBlockCleaner } from "./thinking-block-cleaner.js";
21
+ import { createSignatureReplayScrubber } from "./signature-replay-scrubber.js";
22
+ import { createSignatureSurrogateGuard } from "./signature-surrogate-guard.js";
21
23
  import { createReasoningTagStripper } from "./reasoning-tag-stripper.js";
22
24
  import { createHistoryWindowLayer } from "./history-window.js";
23
25
  import { createObservationMaskerLayer } from "./observation-masker.js";
@@ -93,8 +95,8 @@ async function runLayer(layer, messages, budget, breaker, logger) {
93
95
  const result = await layer.apply(messages, budget);
94
96
  breaker.recordSuccess(layer.name);
95
97
  const durationMs = Date.now() - start;
96
- // Only log layers that actually modify messages or take measurable time
97
- if (messagesIn !== result.length || durationMs > 0) {
98
+ // Only log layers that actually modify messages
99
+ if (messagesIn !== result.length) {
98
100
  logger.debug({ layerName: layer.name, messagesIn, messagesOut: result.length, durationMs }, "Context engine layer applied");
99
101
  }
100
102
  return {
@@ -132,6 +134,8 @@ function getCallbackSnapshot(state) {
132
134
  return {
133
135
  masker: state.masker,
134
136
  thinking: state.thinking,
137
+ signatureReplayScrubber: state.signatureReplayScrubber,
138
+ signatureSurrogateGuard: state.signatureSurrogateGuard,
135
139
  reasoningTags: state.reasoningTags,
136
140
  compaction: state.compaction,
137
141
  rehydration: state.rehydration,
@@ -175,6 +179,8 @@ export function createContextEngine(config, deps) {
175
179
  const callbackState = {
176
180
  masker: null,
177
181
  thinking: null,
182
+ signatureReplayScrubber: null,
183
+ signatureSurrogateGuard: null,
178
184
  reasoningTags: null,
179
185
  compaction: null,
180
186
  rehydration: null,
@@ -190,6 +196,27 @@ export function createContextEngine(config, deps) {
190
196
  thinkingCleaner = createThinkingBlockCleaner(config.thinkingKeepTurns, (stats) => { callbackState.thinking = stats; }, deps.getThinkingKeepTurnsOverride);
191
197
  layers.push(thinkingCleaner);
192
198
  }
199
+ // Signature replay scrubber (Fix #2): activates only when the executor
200
+ // memoized a `{ drop: true }` drift decision for this execute() call.
201
+ // NOT gated on `model.reasoning` because Gemini's `thoughtSignature` lives
202
+ // on toolCall blocks even when the model itself isn't flagged as reasoning.
203
+ // The layer no-ops when the gate is closed, so unconditional addition is
204
+ // cheap; opt-in is via the executor providing `getReplayDriftMode`.
205
+ if (deps.getReplayDriftMode) {
206
+ const getReplayDriftMode = deps.getReplayDriftMode;
207
+ layers.push(createSignatureReplayScrubber({
208
+ getReplayDriftMode,
209
+ onScrubbed: (stats) => { callbackState.signatureReplayScrubber = stats; },
210
+ }));
211
+ }
212
+ // Signature surrogate guard (Fix #3): scrub thinkingSignature from blocks
213
+ // whose text contains unpaired UTF-16 surrogates so pi-ai's
214
+ // sanitizeSurrogates() does not produce a sanitized-text-with-original-
215
+ // signature mismatch on replay. Always active — cost is one walk over
216
+ // thinking blocks with two regex tests per block, no I/O.
217
+ layers.push(createSignatureSurrogateGuard({
218
+ onGuarded: (stats) => { callbackState.signatureSurrogateGuard = stats; },
219
+ }));
193
220
  // Reasoning tag stripper: always active (not gated by model.reasoning) because
194
221
  // inline tags come from OTHER providers' responses persisted in session history --
195
222
  // the current model's capabilities are irrelevant.
@@ -285,6 +312,8 @@ export function createContextEngine(config, deps) {
285
312
  // Reset callback state for this invocation
286
313
  callbackState.masker = null;
287
314
  callbackState.thinking = null;
315
+ callbackState.signatureReplayScrubber = null;
316
+ callbackState.signatureSurrogateGuard = null;
288
317
  callbackState.reasoningTags = null;
289
318
  callbackState.compaction = null;
290
319
  callbackState.rehydration = null;
@@ -537,6 +566,18 @@ export function createContextEngine(config, deps) {
537
566
  thinkingFenceIndex: snap.thinking.cacheFenceIndex,
538
567
  }),
539
568
  ...(snap.reasoningTags && snap.reasoningTags.tagsStripped > 0 ? { reasoningTagsStripped: snap.reasoningTags.tagsStripped } : {}),
569
+ ...(snap.signatureReplayScrubber && snap.signatureReplayScrubber.dropped > 0
570
+ ? { signatureReplayScrubberDropped: snap.signatureReplayScrubber.dropped }
571
+ : {}),
572
+ ...(snap.signatureReplayScrubber && snap.signatureReplayScrubber.signaturesStripped > 0
573
+ ? { signatureReplayScrubberSignaturesStripped: snap.signatureReplayScrubber.signaturesStripped }
574
+ : {}),
575
+ ...(snap.signatureReplayScrubber?.reason !== undefined
576
+ ? { signatureReplayScrubberReason: snap.signatureReplayScrubber.reason }
577
+ : {}),
578
+ ...(snap.signatureSurrogateGuard && snap.signatureSurrogateGuard.signaturesStripped > 0
579
+ ? { signatureSurrogateGuardSignaturesStripped: snap.signatureSurrogateGuard.signaturesStripped }
580
+ : {}),
540
581
  budgetUtilization: Math.round(metrics.budgetUtilization * 100) / 100,
541
582
  evictionCategories: metrics.evictionCategories,
542
583
  rereadCount: metrics.rereadCount,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Signature replay scrubber context engine layer.
3
+ *
4
+ * Activates only when `getReplayDriftMode()` returns `{ drop: true }`. When
5
+ * active, drops every `type:"thinking"` block (signed and redacted alike)
6
+ * and strips `thoughtSignature` from every `type:"toolCall"` /
7
+ * `type:"tool_call"` block across the whole history. Respects
8
+ * `budget.cacheFenceIndex` exactly like `thinking-block-cleaner`: messages
9
+ * at or below the fence are passed through unchanged.
10
+ *
11
+ * Provider coverage rationale: the scrubber is NOT gated on `model.reasoning`
12
+ * because Gemini's `thoughtSignature` lives on toolCall blocks even when the
13
+ * model itself is not flagged as reasoning. Cost of running a no-op loop is
14
+ * negligible vs the savings of preventing a 400-rejection round trip.
15
+ *
16
+ * Immutability: never mutates input messages or arrays. Returns new arrays
17
+ * and shallow-copied messages only when changes are needed. When drift is
18
+ * detected but the history happens to contain no thinking blocks or signed
19
+ * toolCalls (e.g. fresh session), returns the original `messages` reference
20
+ * (zero allocation).
21
+ *
22
+ * @module
23
+ */
24
+ import type { ContextLayer } from "./types.js";
25
+ import type { DriftCheck } from "../executor/replay-drift-detector.js";
26
+ /** Stats reported via the `onScrubbed` callback. */
27
+ interface ScrubbedStats {
28
+ /** Number of thinking blocks dropped across the whole history. */
29
+ dropped: number;
30
+ /** Number of thoughtSignatures stripped from toolCall blocks. */
31
+ signaturesStripped: number;
32
+ /** Drift reason that triggered the scrub (forwarded for observability). */
33
+ reason?: string;
34
+ }
35
+ /** Dependencies for `createSignatureReplayScrubber`. */
36
+ export interface SignatureReplayScrubberDeps {
37
+ /** Getter for the per-execute() memoized replay drift decision. The
38
+ * layer no-ops when this returns undefined or `{ drop: false }`. */
39
+ getReplayDriftMode: () => DriftCheck | undefined;
40
+ /** Optional callback invoked exactly once at the end of `apply()` with
41
+ * the scrub counts and drift reason. */
42
+ onScrubbed?: (stats: ScrubbedStats) => void;
43
+ }
44
+ /**
45
+ * Create the signature-replay-scrubber pipeline layer.
46
+ *
47
+ * Layer ordering: runs AFTER `thinking-block-cleaner` and BEFORE
48
+ * `signature-surrogate-guard` (and well before `reasoning-tag-stripper`).
49
+ */
50
+ export declare function createSignatureReplayScrubber(deps: SignatureReplayScrubberDeps): ContextLayer;
51
+ export {};
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Signature replay scrubber context engine layer.
4
+ *
5
+ * Activates only when `getReplayDriftMode()` returns `{ drop: true }`. When
6
+ * active, drops every `type:"thinking"` block (signed and redacted alike)
7
+ * and strips `thoughtSignature` from every `type:"toolCall"` /
8
+ * `type:"tool_call"` block across the whole history. Respects
9
+ * `budget.cacheFenceIndex` exactly like `thinking-block-cleaner`: messages
10
+ * at or below the fence are passed through unchanged.
11
+ *
12
+ * Provider coverage rationale: the scrubber is NOT gated on `model.reasoning`
13
+ * because Gemini's `thoughtSignature` lives on toolCall blocks even when the
14
+ * model itself is not flagged as reasoning. Cost of running a no-op loop is
15
+ * negligible vs the savings of preventing a 400-rejection round trip.
16
+ *
17
+ * Immutability: never mutates input messages or arrays. Returns new arrays
18
+ * and shallow-copied messages only when changes are needed. When drift is
19
+ * detected but the history happens to contain no thinking blocks or signed
20
+ * toolCalls (e.g. fresh session), returns the original `messages` reference
21
+ * (zero allocation).
22
+ *
23
+ * @module
24
+ */
25
+ /**
26
+ * Create the signature-replay-scrubber pipeline layer.
27
+ *
28
+ * Layer ordering: runs AFTER `thinking-block-cleaner` and BEFORE
29
+ * `signature-surrogate-guard` (and well before `reasoning-tag-stripper`).
30
+ */
31
+ export function createSignatureReplayScrubber(deps) {
32
+ return {
33
+ name: "signature-replay-scrubber",
34
+ async apply(messages, budget) {
35
+ const drift = deps.getReplayDriftMode();
36
+ if (!drift || !drift.drop) {
37
+ // Gate closed → no-op, return same reference (zero allocation).
38
+ return messages;
39
+ }
40
+ if (messages.length === 0)
41
+ return messages;
42
+ let anyChanged = false;
43
+ let dropped = 0;
44
+ let signaturesStripped = 0;
45
+ const result = new Array(messages.length);
46
+ for (let i = 0; i < messages.length; i++) {
47
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
48
+ const original = messages[i];
49
+ // Cache fence: messages at or below the fence must not be modified.
50
+ if (i <= budget.cacheFenceIndex) {
51
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
52
+ result[i] = original;
53
+ continue;
54
+ }
55
+ const msg = original;
56
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
57
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
58
+ result[i] = original;
59
+ continue;
60
+ }
61
+ const content = msg.content;
62
+ let messageChanged = false;
63
+ const newContent = [];
64
+ for (let j = 0; j < content.length; j++) {
65
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
66
+ const block = content[j];
67
+ if (!block || typeof block !== "object") {
68
+ newContent.push(block);
69
+ continue;
70
+ }
71
+ const b = block;
72
+ if (b.type === "thinking") {
73
+ // Drop signed AND redacted alike — drift mode invalidates the
74
+ // entire prefix, so retaining redacted thinking just keeps a
75
+ // surface that the next replay can still reject.
76
+ dropped++;
77
+ messageChanged = true;
78
+ continue;
79
+ }
80
+ if ((b.type === "toolCall" || b.type === "tool_call") && b.thoughtSignature !== undefined) {
81
+ // Shallow-copy and drop only the thoughtSignature property.
82
+ const copy = { ...b };
83
+ delete copy.thoughtSignature;
84
+ newContent.push(copy);
85
+ signaturesStripped++;
86
+ messageChanged = true;
87
+ continue;
88
+ }
89
+ newContent.push(block);
90
+ }
91
+ if (messageChanged) {
92
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
93
+ result[i] = { ...msg, content: newContent };
94
+ anyChanged = true;
95
+ }
96
+ else {
97
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
98
+ result[i] = original;
99
+ }
100
+ }
101
+ // Always notify so observers see the drift reason even on zero-touch
102
+ // histories (drift fired but no signed state in the conversation yet).
103
+ deps.onScrubbed?.({ dropped, signaturesStripped, reason: drift.reason });
104
+ // Zero-allocation early return when nothing was actually changed.
105
+ if (!anyChanged)
106
+ return messages;
107
+ return result;
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Signature surrogate guard context engine layer.
3
+ *
4
+ * Defends against pi-ai's `sanitizeSurrogates(block.thinking)` mutating the
5
+ * thinking text while preserving the original signature. When that happens,
6
+ * Anthropic's signature validator rejects the assistant turn on replay with
7
+ * `400 invalid_request_error: ... cannot be modified` because the bytes the
8
+ * signature was computed over no longer match the bytes being sent.
9
+ *
10
+ * Reference: pi-ai upstream behavior in
11
+ * `node_modules/.pnpm/@mariozechner+pi-ai@0.67.68_HASH/node_modules/@mariozechner/pi-ai/dist/providers/anthropic.js`
12
+ * around line 661 calls `sanitizeSurrogates(block.thinking)` while preserving
13
+ * the original `signature`. A separate upstream PR is recommended (out of
14
+ * scope for this commit); Comis is protected by this guard regardless of
15
+ * upstream.
16
+ *
17
+ * Strategy: scan every type:"thinking" block on every assistant message
18
+ * for unpaired UTF-16 surrogates in `block.thinking`. When found AND the
19
+ * block carries a non-empty `thinkingSignature`, strip the signature so
20
+ * pi-ai's downstream serialization falls back to converting the block to
21
+ * plain text rather than sending sanitized-text + original-signature
22
+ * mismatch. Skips `redacted: true` blocks (no readable text to taint).
23
+ *
24
+ * Cache fence respected exactly like `thinking-block-cleaner` and
25
+ * `signature-replay-scrubber`.
26
+ *
27
+ * Immutability: never mutates input; shallow-copies the block and the
28
+ * containing message only when scrubbing is needed. When no scrub fires,
29
+ * returns the input array reference unchanged (zero allocation).
30
+ *
31
+ * @module
32
+ */
33
+ import type { ContextLayer } from "./types.js";
34
+ /** Stats reported via the `onGuarded` callback. */
35
+ interface GuardedStats {
36
+ /** Number of thinkingSignatures stripped due to unpaired surrogates. */
37
+ signaturesStripped: number;
38
+ }
39
+ /** Optional dependencies for `createSignatureSurrogateGuard`. */
40
+ export interface SignatureSurrogateGuardDeps {
41
+ /** Optional callback invoked exactly once at the end of `apply()` with
42
+ * the count of signatures stripped. */
43
+ onGuarded?: (stats: GuardedStats) => void;
44
+ }
45
+ /**
46
+ * Create the signature-surrogate-guard pipeline layer.
47
+ *
48
+ * Layer ordering: runs AFTER `signature-replay-scrubber` (Fix #2) and
49
+ * BEFORE `reasoning-tag-stripper`. Always added unconditionally — the cost
50
+ * is one walk over thinking blocks with two regex tests per block, which
51
+ * is negligible compared to a single 400-rejection round trip.
52
+ */
53
+ export declare function createSignatureSurrogateGuard(deps?: SignatureSurrogateGuardDeps): ContextLayer;
54
+ export {};
@@ -0,0 +1,145 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Signature surrogate guard context engine layer.
4
+ *
5
+ * Defends against pi-ai's `sanitizeSurrogates(block.thinking)` mutating the
6
+ * thinking text while preserving the original signature. When that happens,
7
+ * Anthropic's signature validator rejects the assistant turn on replay with
8
+ * `400 invalid_request_error: ... cannot be modified` because the bytes the
9
+ * signature was computed over no longer match the bytes being sent.
10
+ *
11
+ * Reference: pi-ai upstream behavior in
12
+ * `node_modules/.pnpm/@mariozechner+pi-ai@0.67.68_HASH/node_modules/@mariozechner/pi-ai/dist/providers/anthropic.js`
13
+ * around line 661 calls `sanitizeSurrogates(block.thinking)` while preserving
14
+ * the original `signature`. A separate upstream PR is recommended (out of
15
+ * scope for this commit); Comis is protected by this guard regardless of
16
+ * upstream.
17
+ *
18
+ * Strategy: scan every type:"thinking" block on every assistant message
19
+ * for unpaired UTF-16 surrogates in `block.thinking`. When found AND the
20
+ * block carries a non-empty `thinkingSignature`, strip the signature so
21
+ * pi-ai's downstream serialization falls back to converting the block to
22
+ * plain text rather than sending sanitized-text + original-signature
23
+ * mismatch. Skips `redacted: true` blocks (no readable text to taint).
24
+ *
25
+ * Cache fence respected exactly like `thinking-block-cleaner` and
26
+ * `signature-replay-scrubber`.
27
+ *
28
+ * Immutability: never mutates input; shallow-copies the block and the
29
+ * containing message only when scrubbing is needed. When no scrub fires,
30
+ * returns the input array reference unchanged (zero allocation).
31
+ *
32
+ * @module
33
+ */
34
+ // ---------------------------------------------------------------------------
35
+ // Surrogate detection regexes (mirror pi-ai sanitizeSurrogates internals)
36
+ // ---------------------------------------------------------------------------
37
+ // Reference: pi-ai sanitizeSurrogates internals, anthropic.js:661 area.
38
+ // High surrogate not followed by low surrogate.
39
+ const UNPAIRED_HIGH_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])/;
40
+ // Low surrogate not preceded by high surrogate.
41
+ const UNPAIRED_LOW_SURROGATE = /(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
42
+ /**
43
+ * Create the signature-surrogate-guard pipeline layer.
44
+ *
45
+ * Layer ordering: runs AFTER `signature-replay-scrubber` (Fix #2) and
46
+ * BEFORE `reasoning-tag-stripper`. Always added unconditionally — the cost
47
+ * is one walk over thinking blocks with two regex tests per block, which
48
+ * is negligible compared to a single 400-rejection round trip.
49
+ */
50
+ export function createSignatureSurrogateGuard(deps) {
51
+ return {
52
+ name: "signature-surrogate-guard",
53
+ async apply(messages, budget) {
54
+ if (messages.length === 0) {
55
+ deps?.onGuarded?.({ signaturesStripped: 0 });
56
+ return messages;
57
+ }
58
+ let anyChanged = false;
59
+ let signaturesStripped = 0;
60
+ const result = new Array(messages.length);
61
+ for (let i = 0; i < messages.length; i++) {
62
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
63
+ const original = messages[i];
64
+ // Cache fence: messages at or below the fence must not be modified.
65
+ if (i <= budget.cacheFenceIndex) {
66
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
67
+ result[i] = original;
68
+ continue;
69
+ }
70
+ const msg = original;
71
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
72
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
73
+ result[i] = original;
74
+ continue;
75
+ }
76
+ const content = msg.content;
77
+ let messageChanged = false;
78
+ const newContent = new Array(content.length);
79
+ for (let j = 0; j < content.length; j++) {
80
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
81
+ const block = content[j];
82
+ if (!block || typeof block !== "object") {
83
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
84
+ newContent[j] = block;
85
+ continue;
86
+ }
87
+ const b = block;
88
+ // Only inspect type:"thinking" blocks.
89
+ if (b.type !== "thinking") {
90
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
91
+ newContent[j] = block;
92
+ continue;
93
+ }
94
+ // Skip redacted blocks: they have no readable text to taint.
95
+ if (b.redacted === true) {
96
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
97
+ newContent[j] = block;
98
+ continue;
99
+ }
100
+ // Skip when there's no signature to strip in the first place.
101
+ if (!b.thinkingSignature) {
102
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
103
+ newContent[j] = block;
104
+ continue;
105
+ }
106
+ const text = b.thinking;
107
+ if (typeof text !== "string") {
108
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
109
+ newContent[j] = block;
110
+ continue;
111
+ }
112
+ if (UNPAIRED_HIGH_SURROGATE.test(text) || UNPAIRED_LOW_SURROGATE.test(text)) {
113
+ // Strip the signature: shallow-copy the block and set
114
+ // thinkingSignature to "" so pi-ai's downstream serialization
115
+ // falls back to converting the block to plain text rather than
116
+ // sending sanitized-text + original-signature mismatch.
117
+ const copy = { ...b, thinkingSignature: "" };
118
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
119
+ newContent[j] = copy;
120
+ signaturesStripped++;
121
+ messageChanged = true;
122
+ }
123
+ else {
124
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
125
+ newContent[j] = block;
126
+ }
127
+ }
128
+ if (messageChanged) {
129
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
130
+ result[i] = { ...msg, content: newContent };
131
+ anyChanged = true;
132
+ }
133
+ else {
134
+ // eslint-disable-next-line security/detect-object-injection -- numeric index
135
+ result[i] = original;
136
+ }
137
+ }
138
+ deps?.onGuarded?.({ signaturesStripped });
139
+ // Zero-allocation early return when nothing was scrubbed.
140
+ if (!anyChanged)
141
+ return messages;
142
+ return result;
143
+ },
144
+ };
145
+ }
@@ -92,6 +92,16 @@ export interface ContextEngineDeps {
92
92
  contextWindow: number;
93
93
  /** Maximum output tokens for the model. */
94
94
  maxTokens: number;
95
+ /** Optional model identifier (e.g. "claude-opus-4-7"). Used by replay
96
+ * drift detection downstream of this getter. */
97
+ id?: string;
98
+ /** Optional provider name (e.g. "anthropic"). Used by replay drift
99
+ * detection downstream of this getter. */
100
+ provider?: string;
101
+ /** Optional API family tag (e.g. "anthropic.messages",
102
+ * "google.generative_ai.responses"). Used by replay drift detection
103
+ * downstream of this getter. */
104
+ api?: string;
95
105
  };
96
106
  /** Channel type for history window per-channel overrides (e.g., "dm", "group"). */
97
107
  channelType?: string;
@@ -134,6 +144,13 @@ export interface ContextEngineDeps {
134
144
  * static value is used. Used by idle thinking clear to strip all thinking
135
145
  * blocks when the cache is cold (>1h idle). */
136
146
  getThinkingKeepTurnsOverride?: () => number | undefined;
147
+ /** Optional getter for the per-execute() memoized replay drift decision.
148
+ * When the getter returns a DriftCheck with `drop: true`, the
149
+ * signature-replay-scrubber layer activates for this pipeline run. When
150
+ * undefined or `drop: false`, the layer no-ops. The drift result is
151
+ * memoized at the executor layer so all pipeline runs within a single
152
+ * execute() see a consistent decision. */
153
+ getReplayDriftMode?: () => import("../executor/replay-drift-detector.js").DriftCheck | undefined;
137
154
  }
138
155
  /**
139
156
  * API-grounded token count anchor from the last LLM response.
@@ -8,7 +8,17 @@
8
8
  *
9
9
  * @module
10
10
  */
11
- export type ErrorCategory = "credit_exhausted" | "rate_limited" | "auth_invalid" | "overloaded" | "context_too_long" | "content_filtered" | "client_request" | "prompt_timeout"
11
+ export type ErrorCategory = "credit_exhausted" | "rate_limited" | "auth_invalid" | "overloaded" | "context_too_long" | "content_filtered"
12
+ /**
13
+ * Provider-agnostic signed-replay rejection: the model rejected stored
14
+ * signed thinking / reasoning state on the latest assistant turn during
15
+ * replay (Anthropic `cannot be modified`, Gemini `thought_signature
16
+ * mismatch`, OpenAI Responses `reasoning_item not found`, OpenAI
17
+ * Completions `reasoning_id expired`, Mistral `encrypted_content
18
+ * verification failed`, etc.). Self-healable: the runner scrubs the
19
+ * stored signed state in place and re-enters the model retry chain.
20
+ */
21
+ | "client_request_signed_replay" | "client_request" | "prompt_timeout"
12
22
  /**
13
23
  * Model produced an empty response (no text, no tool call). Almost always
14
24
  * caused by a malformed toolResult poisoning the next turn. Retryable once
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * @module
11
11
  */
12
+ import { isSignedReplayError } from "./signed-replay-detector.js";
12
13
  const ERROR_PATTERNS = [
13
14
  // Billing / credits
14
15
  {
@@ -45,6 +46,18 @@ const ERROR_PATTERNS = [
45
46
  userMessage: "The conversation has grown too long. Please start a new conversation.",
46
47
  retryable: false,
47
48
  },
49
+ // Provider-agnostic signed-replay rejection: must be tested BEFORE the
50
+ // plain client_request pattern because every signed-replay error string
51
+ // also matches `invalid_request_error` / `cannot be modified`. First match
52
+ // wins, so this more-specific subcategory has to be checked first.
53
+ // Retryable=true because the runner scrubs signed thinking state in place
54
+ // and re-enters the model retry chain (see executor-prompt-runner.ts).
55
+ {
56
+ test: { test: (s) => isSignedReplayError(s) },
57
+ category: "client_request_signed_replay",
58
+ userMessage: "Your request couldn't be processed due to a formatting issue. The AI agent will try again automatically.",
59
+ retryable: true,
60
+ },
48
61
  // Client-side validation (Anthropic 400 invalid_request_error, 422, malformed)
49
62
  // Placed BEFORE content_filtered so /refus|blocked/ in that rule cannot steal
50
63
  // matches. Placed AFTER billing/auth/rate/overloaded/context so those specific
@@ -49,6 +49,7 @@ export interface ContextEngineSetupParams {
49
49
  maxTokens?: number;
50
50
  id?: string;
51
51
  provider?: string;
52
+ api?: string;
52
53
  } | undefined;
53
54
  };
54
55
  };
@@ -17,6 +17,7 @@ import { createContextEngine } from "../context-engine/index.js";
17
17
  import { CHARS_PER_TOKEN_RATIO } from "../context-engine/constants.js";
18
18
  import { resolveOperationModel, resolveProviderFamily } from "../model/operation-model-resolver.js";
19
19
  import { getBreakpointIndex, getBreakpointIndexMapSize, getSessionLatches, } from "./executor-session-state.js";
20
+ import { shouldDropSignedFields } from "./replay-drift-detector.js";
20
21
  import { readFileSync } from "node:fs";
21
22
  // ---------------------------------------------------------------------------
22
23
  // Main function
@@ -41,6 +42,45 @@ export function setupContextEngine(params) {
41
42
  const agentId = deps.agentId;
42
43
  // contextEngineOverrides removed from ExecutionOverrides -- compaction model resolved via operationModels chain
43
44
  const contextEngineConfig = config.contextEngine ?? ContextEngineConfigSchema.parse({});
45
+ // --- Replay drift memo (Fix #2) -----------------------------------------
46
+ // Memoized per-execute() so all pipeline runs in a single execute() see a
47
+ // consistent decision (cleaner + scrubber must agree). The closure reads
48
+ // the latest model identity each time (handles cycleModel mid-execute).
49
+ let memoizedDrift;
50
+ const computeDriftIfNeeded = () => {
51
+ if (memoizedDrift !== undefined)
52
+ return memoizedDrift;
53
+ try {
54
+ const model = session.agent.state.model;
55
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SessionManager interop
56
+ const fileEntries = (sm?.fileEntries ?? []);
57
+ const idleMs = contextEngineConfig.replayDriftIdleMs ?? 30 * 60_000;
58
+ // Derive currentApi from model.api when present; otherwise fall back to
59
+ // the provider family (resolveProviderFamily strips -bedrock / -vertex).
60
+ const currentApi = model?.api ?? resolveProviderFamily(config.provider);
61
+ memoizedDrift = shouldDropSignedFields({
62
+ // Cast: shouldDropSignedFields tolerates malformed entries internally.
63
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
64
+ fileEntries: fileEntries,
65
+ currentModel: {
66
+ id: model?.id,
67
+ provider: model?.provider ?? config.provider,
68
+ api: currentApi,
69
+ },
70
+ idleMs,
71
+ });
72
+ return memoizedDrift;
73
+ }
74
+ catch (err) {
75
+ deps.logger.warn({
76
+ err,
77
+ hint: "Replay drift detection failed; defaulting to no scrub",
78
+ errorKind: "internal",
79
+ }, "Replay drift detection failed");
80
+ memoizedDrift = { drop: false };
81
+ return memoizedDrift;
82
+ }
83
+ };
44
84
  const contextEngine = createContextEngine(contextEngineConfig, {
45
85
  logger: deps.logger,
46
86
  eventBus: deps.eventBus,
@@ -53,6 +93,11 @@ export function setupContextEngine(params) {
53
93
  reasoning: model?.reasoning ?? false,
54
94
  contextWindow: model?.contextWindow ?? 128_000,
55
95
  maxTokens: model?.maxTokens ?? 8192,
96
+ id: model?.id,
97
+ provider: model?.provider,
98
+ // model.api is optional pi-ai metadata. Cast for the optional access
99
+ // since the structural type does not require it.
100
+ api: model?.api,
56
101
  };
57
102
  },
58
103
  channelType: msg.channelType,
@@ -70,8 +115,18 @@ export function setupContextEngine(params) {
70
115
  const latches = getSessionLatches(formattedKey);
71
116
  if (latches?.idleThinkingClear.get())
72
117
  return 0; // Strip all thinking when idle
118
+ // When replay drift fires, also clamp keepTurns=0 so the cleaner agrees
119
+ // with the new signature-replay-scrubber. Defense in depth: the scrubber
120
+ // drops everything beyond the cache fence, but a future refactor that
121
+ // narrows the scrubber's scope must not leave the cleaner inconsistent.
122
+ const drift = computeDriftIfNeeded();
123
+ if (drift?.drop)
124
+ return 0;
73
125
  return undefined; // Use default keepTurns
74
126
  },
127
+ // Replay drift mode getter (Fix #2): activates the
128
+ // signature-replay-scrubber pipeline layer when drift is detected.
129
+ getReplayDriftMode: () => computeDriftIfNeeded(),
75
130
  // LLM compaction deps
76
131
  getCompactionDeps: () => ({
77
132
  logger: deps.logger,