comisai 1.0.14 → 1.0.16

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 (79) hide show
  1. package/node_modules/@comis/agent/dist/bootstrap/index.d.ts +1 -1
  2. package/node_modules/@comis/agent/dist/bootstrap/index.js +1 -1
  3. package/node_modules/@comis/agent/dist/bootstrap/system-prompt-assembler.d.ts +26 -0
  4. package/node_modules/@comis/agent/dist/bootstrap/system-prompt-assembler.js +86 -54
  5. package/node_modules/@comis/agent/dist/bootstrap/types.d.ts +7 -1
  6. package/node_modules/@comis/agent/dist/bootstrap/workspace-loader.d.ts +12 -0
  7. package/node_modules/@comis/agent/dist/bootstrap/workspace-loader.js +21 -0
  8. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.js +12 -11
  9. package/node_modules/@comis/agent/dist/context-engine/constants.d.ts +11 -0
  10. package/node_modules/@comis/agent/dist/context-engine/constants.js +11 -0
  11. package/node_modules/@comis/agent/dist/context-engine/index.d.ts +1 -1
  12. package/node_modules/@comis/agent/dist/context-engine/index.js +1 -1
  13. package/node_modules/@comis/agent/dist/context-engine/llm-compaction.js +32 -18
  14. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.d.ts +2 -0
  15. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.js +36 -0
  16. package/node_modules/@comis/agent/dist/executor/executor-post-execution.d.ts +12 -0
  17. package/node_modules/@comis/agent/dist/executor/executor-post-execution.js +117 -27
  18. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.d.ts +1 -0
  19. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +16 -4
  20. package/node_modules/@comis/agent/dist/executor/executor-stream-setup.js +17 -1
  21. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +29 -1
  22. package/node_modules/@comis/agent/dist/executor/prompt-assembly.d.ts +8 -1
  23. package/node_modules/@comis/agent/dist/executor/prompt-assembly.js +22 -5
  24. package/node_modules/@comis/agent/dist/executor/session-snapshot-cleanup.js +3 -1
  25. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.d.ts +2 -0
  26. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.js +2 -0
  27. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.d.ts +9 -0
  28. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.js +65 -3
  29. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.d.ts +28 -0
  30. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.js +63 -0
  31. package/node_modules/@comis/agent/dist/executor/tool-deferral.d.ts +18 -1
  32. package/node_modules/@comis/agent/dist/executor/tool-deferral.js +275 -133
  33. package/node_modules/@comis/agent/dist/executor/ttl-guard.d.ts +6 -0
  34. package/node_modules/@comis/agent/dist/executor/ttl-guard.js +8 -0
  35. package/node_modules/@comis/agent/dist/session/comis-session-manager.js +7 -2
  36. package/node_modules/@comis/agent/package.json +1 -1
  37. package/node_modules/@comis/channels/dist/shared/typing-controller.d.ts +7 -0
  38. package/node_modules/@comis/channels/dist/shared/typing-controller.js +33 -0
  39. package/node_modules/@comis/channels/package.json +1 -1
  40. package/node_modules/@comis/cli/dist/commands/daemon.js +116 -28
  41. package/node_modules/@comis/cli/dist/doctor/checks/channel-health.js +2 -2
  42. package/node_modules/@comis/cli/dist/wizard/index.d.ts +2 -3
  43. package/node_modules/@comis/cli/dist/wizard/index.js +2 -3
  44. package/node_modules/@comis/cli/dist/wizard/steps/10-write-config.js +1 -1
  45. package/node_modules/@comis/cli/dist/wizard/types.d.ts +1 -2
  46. package/node_modules/@comis/cli/dist/wizard/types.js +1 -2
  47. package/node_modules/@comis/cli/package.json +1 -1
  48. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +64 -10
  49. package/node_modules/@comis/core/dist/config/schema-agent.js +19 -0
  50. package/node_modules/@comis/core/dist/config/schema-skills.d.ts +16 -2
  51. package/node_modules/@comis/core/dist/config/schema-skills.js +9 -2
  52. package/node_modules/@comis/core/dist/config/schema.d.ts +19 -11
  53. package/node_modules/@comis/core/dist/event-bus/events-infra.d.ts +8 -0
  54. package/node_modules/@comis/core/package.json +1 -1
  55. package/node_modules/@comis/daemon/dist/daemon.js +2 -1
  56. package/node_modules/@comis/daemon/dist/wiring/setup-channels.js +19 -2
  57. package/node_modules/@comis/daemon/dist/wiring/setup-heartbeat.js +10 -0
  58. package/node_modules/@comis/daemon/dist/wiring/setup-memory.d.ts +2 -0
  59. package/node_modules/@comis/daemon/dist/wiring/setup-memory.js +15 -0
  60. package/node_modules/@comis/daemon/dist/wiring/setup-schedulers.js +1 -0
  61. package/node_modules/@comis/daemon/dist/wiring/setup-shutdown.d.ts +1 -0
  62. package/node_modules/@comis/daemon/dist/wiring/setup-shutdown.js +4 -0
  63. package/node_modules/@comis/daemon/package.json +1 -1
  64. package/node_modules/@comis/gateway/package.json +1 -1
  65. package/node_modules/@comis/infra/package.json +1 -1
  66. package/node_modules/@comis/memory/package.json +1 -1
  67. package/node_modules/@comis/scheduler/dist/cron/cron-types.d.ts +11 -0
  68. package/node_modules/@comis/scheduler/dist/cron/cron-types.js +11 -0
  69. package/node_modules/@comis/scheduler/dist/heartbeat/agent-heartbeat-source.d.ts +26 -1
  70. package/node_modules/@comis/scheduler/dist/heartbeat/agent-heartbeat-source.js +23 -2
  71. package/node_modules/@comis/scheduler/dist/heartbeat/heartbeat-config.d.ts +7 -0
  72. package/node_modules/@comis/scheduler/dist/heartbeat/heartbeat-config.js +2 -0
  73. package/node_modules/@comis/scheduler/package.json +1 -1
  74. package/node_modules/@comis/shared/package.json +1 -1
  75. package/node_modules/@comis/skills/dist/index.d.ts +1 -1
  76. package/node_modules/@comis/skills/dist/index.js +1 -1
  77. package/node_modules/@comis/skills/dist/policy/tool-policy.js +25 -0
  78. package/node_modules/@comis/skills/package.json +1 -1
  79. package/package.json +12 -12
@@ -19,7 +19,7 @@ import { mergeSessionStats } from "./pi-executor.js";
19
19
  import { recordLastResponseTs } from "./ttl-guard.js";
20
20
  import { stripDiscoverySchemas } from "./schema-stripping.js";
21
21
  import { getWorkspaceStatus } from "../workspace/index.js";
22
- import { randomUUID } from "node:crypto";
22
+ import { createHash, randomUUID } from "node:crypto";
23
23
  // ---------------------------------------------------------------------------
24
24
  // Helpers
25
25
  // ---------------------------------------------------------------------------
@@ -61,6 +61,69 @@ export function shouldStorePairedMemory(userText, agentResponse) {
61
61
  return false;
62
62
  return true;
63
63
  }
64
+ /**
65
+ * Operation types that must NOT create paired memories.
66
+ *
67
+ * Cron and heartbeat executions are stateless and repetitive -- storing their
68
+ * prompts pollutes the vector space and degrades RAG recall for interactive
69
+ * conversations. Compaction / taskExtraction / condensation are system-internal
70
+ * operations that have their own dedicated memory paths (compaction summaries,
71
+ * extracted tasks) and should not additionally create paired conversation
72
+ * entries. Interactive and subagent are deliberately excluded.
73
+ */
74
+ const MEMORY_SKIP_OPERATIONS = new Set([
75
+ "cron",
76
+ "heartbeat",
77
+ "compaction",
78
+ "taskExtraction",
79
+ "condensation",
80
+ ]);
81
+ /**
82
+ * In-memory dedup cache for paired memory content.
83
+ *
84
+ * Defense-in-depth for interactive conversations where the user sends the same
85
+ * message multiple times (retries, reconnects). The Layer-1 operationType gate
86
+ * is the primary defense against cron/heartbeat duplication; this Layer-2 hash
87
+ * dedup catches residual duplicates that slip through.
88
+ *
89
+ * Keyed by a 64-bit truncation of sha256(agentId || content). Single-process
90
+ * daemon deployment (pm2, no cluster) means this cache is always complete.
91
+ */
92
+ const DEDUP_TTL_MS = 10 * 60 * 1000;
93
+ const DEDUP_MAX_ENTRIES = 500;
94
+ const pairedMemoryDedup = new Map();
95
+ /**
96
+ * Check whether paired memory content was stored recently for this agent.
97
+ *
98
+ * Exact-content hashing (not semantic similarity) -- targets the cron pattern
99
+ * of identical prompt + identical NO_REPLY response. Lazy eviction keeps the
100
+ * cache bounded without a timer.
101
+ *
102
+ * Exported for unit tests.
103
+ */
104
+ export function isDuplicatePairedMemory(content, agentId) {
105
+ const now = Date.now();
106
+ if (pairedMemoryDedup.size > DEDUP_MAX_ENTRIES) {
107
+ for (const [key, ts] of pairedMemoryDedup) {
108
+ if (now - ts > DEDUP_TTL_MS)
109
+ pairedMemoryDedup.delete(key);
110
+ }
111
+ }
112
+ const hash = createHash("sha256")
113
+ .update(agentId)
114
+ .update(content)
115
+ .digest("hex")
116
+ .slice(0, 16);
117
+ const existing = pairedMemoryDedup.get(hash);
118
+ if (existing != null && now - existing <= DEDUP_TTL_MS)
119
+ return true;
120
+ pairedMemoryDedup.set(hash, now);
121
+ return false;
122
+ }
123
+ /** Reset the paired-memory dedup cache. Exported for unit tests. */
124
+ export function resetPairedMemoryDedupForTests() {
125
+ pairedMemoryDedup.clear();
126
+ }
64
127
  // ---------------------------------------------------------------------------
65
128
  // Implementation
66
129
  // ---------------------------------------------------------------------------
@@ -292,38 +355,65 @@ export async function postExecution(params) {
292
355
  // Pairing user message with agent response creates entries that carry enough
293
356
  // context for meaningful RAG retrieval. Standalone user messages like "Hello"
294
357
  // or "you choose" have no semantic value without the agent's response.
295
- // Runs for ALL execution paths: gateway, channels, cron.
358
+ //
359
+ // Two-layer dedup defense:
360
+ // Layer 1: operationType gate -- skip cron/heartbeat/system-internal ops
361
+ // which are stateless/repetitive and would pollute the vector
362
+ // space with near-identical entries.
363
+ // Layer 2: content-hash dedup -- safety net for interactive retries.
364
+ //
296
365
  // Non-blocking, non-fatal -- execution never fails due to memory store errors.
297
- if (deps.memoryPort && result.response && msg.text && shouldStorePairedMemory(msg.text, result.response)) {
298
- try {
299
- const now = Date.now();
300
- const userEntryId = randomUUID();
301
- const pairedContent = buildPairedMemoryContent(msg.text, result.response);
302
- const userStoreResult = await deps.memoryPort.store({
303
- id: userEntryId,
304
- tenantId: sessionKey.tenantId,
305
- agentId: agentId ?? "default",
306
- userId: sessionKey.userId,
307
- content: pairedContent,
308
- trustLevel: "learned",
309
- source: { who: sessionKey.userId, channel: msg.channelType ?? "unknown" },
310
- tags: ["conversation", "paired"],
311
- createdAt: now,
312
- });
313
- if (!userStoreResult.ok) {
314
- deps.logger.warn({ err: userStoreResult.error.message, hint: "Check database connectivity and disk space", errorKind: "dependency" }, "Memory store failed for user message");
366
+ const operationType = params.executionOverrides?.operationType;
367
+ const skipMemoryForOperation = operationType != null && MEMORY_SKIP_OPERATIONS.has(operationType);
368
+ if (deps.memoryPort &&
369
+ result.response &&
370
+ msg.text &&
371
+ !skipMemoryForOperation &&
372
+ shouldStorePairedMemory(msg.text, result.response)) {
373
+ const now = Date.now();
374
+ const pairedContent = buildPairedMemoryContent(msg.text, result.response);
375
+ const effectiveAgentId = agentId ?? "default";
376
+ if (isDuplicatePairedMemory(pairedContent, effectiveAgentId)) {
377
+ deps.logger.debug({ agentId: effectiveAgentId, sessionKey: formattedKey }, "Paired memory skipped: duplicate content within dedup window");
378
+ }
379
+ else {
380
+ try {
381
+ const userEntryId = randomUUID();
382
+ const userStoreResult = await deps.memoryPort.store({
383
+ id: userEntryId,
384
+ tenantId: sessionKey.tenantId,
385
+ agentId: effectiveAgentId,
386
+ userId: sessionKey.userId,
387
+ content: pairedContent,
388
+ trustLevel: "learned",
389
+ source: {
390
+ who: sessionKey.userId,
391
+ channel: msg.channelType ?? "unknown",
392
+ sessionKey: formattedKey,
393
+ },
394
+ tags: ["conversation", "paired"],
395
+ createdAt: now,
396
+ });
397
+ if (!userStoreResult.ok) {
398
+ deps.logger.warn({ err: userStoreResult.error.message, hint: "Check database connectivity and disk space", errorKind: "dependency" }, "Memory store failed for user message");
399
+ }
400
+ else if (deps.embeddingEnqueue) {
401
+ deps.embeddingEnqueue(userEntryId, pairedContent);
402
+ }
315
403
  }
316
- else if (deps.embeddingEnqueue) {
317
- deps.embeddingEnqueue(userEntryId, pairedContent);
404
+ catch {
405
+ // Memory storage failure is non-fatal -- errors already logged per-entry
318
406
  }
319
407
  }
320
- catch {
321
- // Memory storage failure is non-fatal -- errors already logged per-entry
322
- }
323
408
  }
324
409
  else if (deps.memoryPort && result.response && msg.text) {
325
- // Quality gate filtered this turn -- log for observability
326
- deps.logger.debug({ userLen: msg.text.trim().length, minUserChars: PAIRED_MIN_USER_CHARS, minCombinedChars: PAIRED_MIN_COMBINED_CHARS }, "Paired memory skipped: content below quality threshold");
410
+ // Memory not stored -- distinguish the two skip reasons for observability.
411
+ if (skipMemoryForOperation) {
412
+ deps.logger.debug({ operationType, sessionKey: formattedKey }, "Paired memory skipped: non-interactive operation type");
413
+ }
414
+ else {
415
+ deps.logger.debug({ userLen: msg.text.trim().length, minUserChars: PAIRED_MIN_USER_CHARS, minCombinedChars: PAIRED_MIN_COMBINED_CHARS }, "Paired memory skipped: content below quality threshold");
416
+ }
327
417
  }
328
418
  // Deregister active run before dispose
329
419
  if (deps.activeRunRegistry) {
@@ -37,6 +37,7 @@ export interface PromptRunnerBridge {
37
37
  output?: number;
38
38
  };
39
39
  stepsExecuted?: number;
40
+ toolCallHistory?: string[];
40
41
  };
41
42
  }
42
43
  /** Parameters for runPrompt(). */
@@ -515,6 +515,17 @@ export async function runPrompt(params) {
515
515
  if (plan) {
516
516
  executionPlanRef.current = plan;
517
517
  deps.logger.debug({ agentId }, "SEP plan extracted (post-loop fallback)");
518
+ // Inline backfill: post-loop extraction means no mid-loop step tracking
519
+ // ran, so completedCount is stuck at 0 and the nudge cannot fire. Use
520
+ // the bridge's recorded tool history as a proxy for work done and mark
521
+ // the first N steps as "done" (N = min(toolHistoryLen, stepCount)).
522
+ // Tool-to-step attribution is advisory/observability only; over-counting
523
+ // is strictly better than the 0/N deadlock.
524
+ const toolHistoryLen = bridge.getResult().toolCallHistory?.length ?? 0;
525
+ const doneCount = Math.min(toolHistoryLen, plan.steps.length);
526
+ for (let i = 0; i < doneCount; i++)
527
+ plan.steps[i].status = "done";
528
+ plan.completedCount = doneCount;
518
529
  }
519
530
  }
520
531
  if (sepEnabled && !executionPlanRef.current && extractedResponse && toolCallCount === 0) {
@@ -676,11 +687,12 @@ export async function runPrompt(params) {
676
687
  // Estimated cache write cost for the system prompt portion.
677
688
  // System prompt is sent as cacheable prefix; on first request it incurs cache write cost.
678
689
  const estimatedCacheWriteTokens = Math.ceil(sysPromptChars / CHARS_PER_TOKEN_RATIO);
679
- const pricing = resolveModelPricing(config.provider, config.model);
690
+ const effectiveModelId = resolvedModel?.id ?? config.model;
691
+ const pricing = resolveModelPricing(config.provider, effectiveModelId);
680
692
  if (pricing.input === 0) {
681
693
  deps.logger.warn({
682
694
  provider: config.provider,
683
- model: config.model,
695
+ model: effectiveModelId,
684
696
  hint: "Model not found in pricing catalog; timeout cost estimate is $0 -- actual provider billing may differ",
685
697
  errorKind: "config",
686
698
  }, "Unknown model for timeout cost estimation");
@@ -699,7 +711,7 @@ export async function runPrompt(params) {
699
711
  channelId: msg.channelId,
700
712
  executionId,
701
713
  provider: config.provider,
702
- model: config.model,
714
+ model: effectiveModelId,
703
715
  tokens: {
704
716
  prompt: estimatedPromptTokens,
705
717
  completion: 0,
@@ -717,7 +729,7 @@ export async function runPrompt(params) {
717
729
  cacheWriteTokens: estimatedCacheWriteTokens,
718
730
  sessionKey: formatSessionKey(sessionKey),
719
731
  savedVsUncached: 0,
720
- cacheEligible: getCacheProviderInfo(config.provider).cacheEligible,
732
+ cacheEligible: getCacheProviderInfo(config.provider, effectiveModelId).cacheEligible,
721
733
  });
722
734
  // Include ghost cost estimate in result for bridge accumulation
723
735
  ghostCost = pricing.input > 0 ? {
@@ -29,8 +29,9 @@
29
29
  import { safePath, } from "@comis/core";
30
30
  import { createToolResultSizeBouncer, createTurnResultBudgetWrapper, createConfigResolver, createCacheTraceWriter, createApiPayloadTraceWriter, createRequestBodyInjector, createValidationErrorFormatter, } from "./stream-wrappers/index.js";
31
31
  import { resolveToolCallingTemperature } from "./tool-deferral.js";
32
+ import { createStubFilterInjector } from "./stream-wrappers/stub-filter-injector.js";
32
33
  import { computeFeatureFlagHash } from "./prompt-assembly.js";
33
- import { createTtlGuard, getElapsedSinceLastResponse } from "./ttl-guard.js";
34
+ import { createTtlGuard, getElapsedSinceLastResponse, getLastResponseTs } from "./ttl-guard.js";
34
35
  import { isAnthropicFamily, isGoogleFamily } from "../provider/capabilities.js";
35
36
  import { createGeminiCacheInjector } from "./gemini-cache-injector.js";
36
37
  import { extractAnthropicPromptState, extractGeminiPromptState } from "./cache-break-detection.js";
@@ -142,6 +143,8 @@ export function setupStreamWrappers(params) {
142
143
  parentCacheRetention: executionOverrides?.spawnPacket?.cacheSafeParams?.cacheRetention,
143
144
  getCacheFenceIndex: () => getBreakpointIndex(formattedKey) ?? -1,
144
145
  getElapsedSinceLastResponse: () => getElapsedSinceLastResponse(formattedKey),
146
+ getLastResponseTs: () => getLastResponseTs(formattedKey),
147
+ promoteRecentZoneOnSlowCadence: config.advancedCacheOptimization?.enableRecentZonePromotion ?? true,
145
148
  observationKeepWindow: 25,
146
149
  microcompactTokenCeiling: 180_000,
147
150
  onContentModification: () => cacheBreakDetector.notifyContentModification(formattedKey),
@@ -231,6 +234,19 @@ export function setupStreamWrappers(params) {
231
234
  deps.logger.info({ outputDir, cacheTracePath, apiPayloadPath }, "JSONL tracing enabled");
232
235
  }
233
236
  }
237
+ // MUST be the last wrapper pushed (innermost). Runs its onPayload FIRST in
238
+ // the chain so all downstream wrappers operate on stub-free tools. Violating
239
+ // this ordering would (a) include stub schemas in the Anthropic rendered-tool
240
+ // cache hash, (b) persist stubs into the Gemini CachedContent entry for its
241
+ // whole lifetime, and (c) cause deferCount > 0 in the DEFER-TOOL block,
242
+ // unintentionally flipping Anthropic sessions to server-side tool_search.
243
+ wrappers.push(createStubFilterInjector({
244
+ getStubToolNames: () => {
245
+ if (!deferralResult?.deferredEntries.length)
246
+ return new Set();
247
+ return new Set(deferralResult.deferredEntries.map(e => e.name));
248
+ },
249
+ }, deps.logger));
234
250
  return {
235
251
  wrappers,
236
252
  contextEngineRef,
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { SettingsManager, } from "@mariozechner/pi-coding-agent";
17
17
  import { formatSessionKey, } from "@comis/core";
18
- import { applyToolDeferral, buildDeferredToolsContext, extractRecentlyUsedToolNames, resolveModelTier, CORE_TOOLS } from "./tool-deferral.js";
18
+ import { applyToolDeferral, buildDeferredToolsContext, createDiscoverTool, createAutoDiscoveryStubs, extractRecentlyUsedToolNames, resolveModelTier, CORE_TOOLS } from "./tool-deferral.js";
19
19
  import { getOrCreateDiscoveryTracker } from "./discovery-tracker.js";
20
20
  import { getOrCreateTracker, DEFAULT_LIFECYCLE_CONFIG } from "./tool-lifecycle.js";
21
21
  import { isAnthropicFamily, isGoogleFamily } from "../provider/capabilities.js";
@@ -169,6 +169,8 @@ export async function assembleTools(params) {
169
169
  resolvedModelId: resolvedModel?.id,
170
170
  resolvedModelProvider: resolvedModel?.provider,
171
171
  resolvedModelReasoning: resolvedModel?.reasoning,
172
+ // "interactive" default guards the optional overrides; mirrors pi-executor.ts:1077.
173
+ operationType: executionOverrides?.operationType ?? "interactive",
172
174
  });
173
175
  // -------------------------------------------------------------------
174
176
  // 5. System token estimate
@@ -287,10 +289,36 @@ export async function assembleTools(params) {
287
289
  : undefined,
288
290
  };
289
291
  const deferralResult = applyToolDeferral(mergedCustomTools, contextWindow, deferralCtx, deps.logger, deps.embeddingPort, config.skills?.toolDiscovery);
292
+ // Rebuild discover_tools with the now-known active set so it can answer
293
+ // "already active" queries correctly. Post-deferral set (active +
294
+ // discovered) is the only factually-accurate set -- using mergedCustomTools
295
+ // (pre-deferral) would false-positive on currently-deferred tools and tell
296
+ // the agent "call directly, no discovery needed" for tools that aren't
297
+ // actually loaded. See design §4.2, §5.5, decision log row 4:
298
+ // .planning/design/discover-tools-bm25-fallback-fix.md
299
+ if (deferralResult.discoverTool) {
300
+ const activeAfterDeferral = new Set([
301
+ ...deferralResult.activeTools.map(t => t.name),
302
+ ...deferralResult.discoveredTools.map(t => t.name),
303
+ ]);
304
+ deferralResult.discoverTool = createDiscoverTool(deferralResult.deferredEntries, deps.logger, deps.embeddingPort, config.skills?.toolDiscovery, activeAfterDeferral);
305
+ }
290
306
  mergedCustomTools = [...deferralResult.activeTools, ...deferralResult.discoveredTools];
291
307
  if (deferralResult.discoverTool) {
292
308
  mergedCustomTools.push(deferralResult.discoverTool);
293
309
  }
310
+ // 7b. Auto-discovery stubs for deferred tools.
311
+ // Lightweight stubs so the SDK's agent-loop finds deferred tools during
312
+ // tool resolution. Prevents "Tool X not found" errors when skills
313
+ // reference deferred MCP tools directly. Stubs are filtered from the
314
+ // API request by createStubFilterInjector (see stream-wrappers/
315
+ // stub-filter-injector.ts) -- zero token cost guarantee.
316
+ // Position: BEFORE JIT guide wrapping / schema pruning / sideEffects
317
+ // wrapping -- stubs receive all downstream wrappers automatically.
318
+ if (deferralResult.deferredEntries.length > 0) {
319
+ const stubs = createAutoDiscoveryStubs(deferralResult.deferredEntries, discoveryTracker, deps.logger);
320
+ mergedCustomTools.push(...stubs);
321
+ }
294
322
  // Build deferred context for dynamic preamble injection
295
323
  let deferredContext = "";
296
324
  if (deferralResult.deferredEntries.length > 0) {
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module
10
10
  */
11
- import type { SessionKey, NormalizedMessage, PerAgentConfig, MemoryPort, HookRunner, SecretManager, EnvelopeConfig, WrapExternalContentOptions, TypedEventBus, SenderTrustDisplayConfig, SpawnPacket, DeliveryMirrorPort } from "@comis/core";
11
+ import type { SessionKey, NormalizedMessage, PerAgentConfig, MemoryPort, HookRunner, SecretManager, EnvelopeConfig, WrapExternalContentOptions, TypedEventBus, SenderTrustDisplayConfig, SpawnPacket, DeliveryMirrorPort, ModelOperationType } from "@comis/core";
12
12
  import type { ComisLogger } from "@comis/infra";
13
13
  import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
14
14
  import type { BootstrapContextFile } from "../bootstrap/types.js";
@@ -151,6 +151,13 @@ export interface PromptAssemblyParams {
151
151
  /** Whether the resolved model has native reasoning support (e.g. encrypted thinking blocks).
152
152
  * When true, the `<think>`/`<final>` tag hint is suppressed to avoid double-reasoning. */
153
153
  resolvedModelReasoning?: boolean;
154
+ /** Operation type from ExecutionOverrides. Resolves promptMode and bootstrap filter.
155
+ * When omitted by callers at the TypeScript level, executor-tool-assembly supplies
156
+ * "interactive" as the default before invoking this function, so this is required
157
+ * at the call-site contract level. Values of "cron" or "heartbeat" auto-upgrade
158
+ * the promptMode from "full" to "operational" and dispatch operation-specific
159
+ * bootstrap filters. */
160
+ operationType: ModelOperationType;
154
161
  }
155
162
  /**
156
163
  * Assemble the full system prompt for a PiExecutor execution cycle.
@@ -12,7 +12,7 @@
12
12
  import * as fs from "node:fs/promises";
13
13
  import { wrapExternalContent, safePath, formatSessionKey, generateCanaryToken } from "@comis/core";
14
14
  import { suppressError } from "@comis/shared";
15
- import { loadWorkspaceBootstrapFiles, buildBootstrapContextFiles, assembleRichSystemPrompt, assembleRichSystemPromptBlocks, filterBootstrapFilesForLightContext, filterBootstrapFilesForGroupChat, resolveSenderDisplay, buildDateTimeSection, buildInboundMetadataSection, buildSenderTrustSection, buildSubagentRoleSection, // for dynamic preamble injection
15
+ import { loadWorkspaceBootstrapFiles, buildBootstrapContextFiles, assembleRichSystemPrompt, assembleRichSystemPromptBlocks, filterBootstrapFilesForLightContext, filterBootstrapFilesForCron, filterBootstrapFilesForGroupChat, resolveSenderDisplay, buildDateTimeSection, buildInboundMetadataSection, buildSenderTrustSection, buildSubagentRoleSection, // for dynamic preamble injection
16
16
  buildVerbosityHintSection, resolveVerbosityProfile, } from "../bootstrap/index.js";
17
17
  import { deduplicateResults } from "../rag/rag-retriever.js";
18
18
  import { createHybridMemoryInjector } from "../rag/hybrid-memory-injector.js";
@@ -320,7 +320,19 @@ export async function assembleExecutionPrompt(params) {
320
320
  return { systemPrompt: parentCache.frozenSystemPrompt, systemPromptBlocks: parentCache.frozenSystemPromptBlocks, dynamicPreamble, inlineMemory: undefined };
321
321
  }
322
322
  // 1. Resolve promptMode
323
- const promptMode = config.bootstrap?.promptMode ?? "full";
323
+ // Cron and heartbeat auto-upgrade from "full" -> "operational" to trim
324
+ // interactive-only sections (compaction recovery, silent replies, reactions,
325
+ // media, SEP, sender trust). An explicit `config.bootstrap?.promptMode` wins
326
+ // -- operators can still force "minimal"/"none"/"full" if they have reason.
327
+ const baseMode = config.bootstrap?.promptMode ?? "full";
328
+ const promptMode = (params.operationType === "cron" || params.operationType === "heartbeat") && baseMode === "full"
329
+ ? "operational"
330
+ : baseMode;
331
+ // Consolidated lightContext flag: heartbeat implies light-context regardless
332
+ // of the explicit msg.metadata.lightContext flag. Callers that only set the
333
+ // metadata flag OR only set operationType="heartbeat" produce identical
334
+ // prompt output (design-doc §Risks: "Heartbeat lightContext and operationType drift").
335
+ const effectiveLightContext = msg.metadata?.lightContext === true || params.operationType === "heartbeat";
324
336
  // 2. Load workspace bootstrap files (skip for "none" mode)
325
337
  let bootstrapContextFiles = [];
326
338
  if (promptMode !== "none") {
@@ -337,11 +349,16 @@ export async function assembleExecutionPrompt(params) {
337
349
  bootstrapFiles = await loadWorkspaceBootstrapFiles(deps.workspaceDir, bootstrapMaxChars);
338
350
  sessionBootstrapFileSnapshots.set(bsSnapKey, bootstrapFiles);
339
351
  }
340
- // Heartbeat light context -- keep only HEARTBEAT.md
341
- if (msg.metadata?.lightContext === true) {
352
+ // Bootstrap filter dispatch:
353
+ // - effectiveLightContext (heartbeat / explicit flag) -> HEARTBEAT.md only
354
+ // - operationType === "cron" -> SOUL.md + ROLE.md only
355
+ // - group chat context -> strip USER.md for privacy
356
+ if (effectiveLightContext) {
342
357
  bootstrapFiles = filterBootstrapFilesForLightContext(bootstrapFiles);
343
358
  }
344
- // Group chat privacy -- exclude USER.md
359
+ else if (params.operationType === "cron") {
360
+ bootstrapFiles = filterBootstrapFilesForCron(bootstrapFiles);
361
+ }
345
362
  else if (config.bootstrap?.groupChatFiltering !== false &&
346
363
  isGroupContext(msg)) {
347
364
  bootstrapFiles = filterBootstrapFilesForGroupChat(bootstrapFiles);
@@ -20,7 +20,7 @@ import { clearSessionTracker } from "./tool-lifecycle.js";
20
20
  import { clearDiscoveryTracker } from "./discovery-tracker.js";
21
21
  import { clearCacheBreakDetectorSession } from "./cache-break-detection.js";
22
22
  import { clearSessionLastResponseTs } from "./ttl-guard.js";
23
- import { clearSessionBetaHeaderLatches } from "./stream-wrappers/request-body-injector.js";
23
+ import { clearSessionBetaHeaderLatches, clearSessionPrefixStability, clearSessionCadenceTracker } from "./stream-wrappers/request-body-injector.js";
24
24
  import { clearSessionRenderedToolCache, clearSessionPerToolCache } from "./stream-wrappers/tool-schema-cache.js";
25
25
  import { clearSessionBlockStability } from "./block-stability-tracker.js";
26
26
  /**
@@ -49,6 +49,8 @@ export function clearSessionState(formattedKey) {
49
49
  clearSessionBlockStability(formattedKey);
50
50
  clearSessionEvictionCooldown(formattedKey);
51
51
  clearSessionCacheSavings(formattedKey);
52
+ clearSessionPrefixStability(formattedKey);
53
+ clearSessionCadenceTracker(formattedKey);
52
54
  }
53
55
  /**
54
56
  * Subscribe to `session:expired` on the provided event bus and clean up
@@ -24,3 +24,5 @@ export { createRequestBodyInjector, addCacheControlToLastBlock, CACHEABLE_BLOCK_
24
24
  export type { RequestBodyInjectorConfig } from "./request-body-injector.js";
25
25
  export { sessionRenderedToolCache, getOrCacheRenderedTool, clearSessionRenderedToolCache, clearSessionPerToolCache, } from "./tool-schema-cache.js";
26
26
  export type { RenderedToolCacheEntry, PerToolCacheEntry } from "./tool-schema-cache.js";
27
+ export { createStubFilterInjector } from "./stub-filter-injector.js";
28
+ export type { StubFilterInjectorConfig } from "./stub-filter-injector.js";
@@ -20,3 +20,5 @@ export { createApiPayloadTraceWriter } from "./api-payload-trace-writer.js";
20
20
  export { createRequestBodyInjector, addCacheControlToLastBlock, CACHEABLE_BLOCK_TYPES, getMinCacheableTokens, resolveCacheRetention, clearSessionBetaHeaderLatches, } from "./request-body-injector.js";
21
21
  // Tool schema cache extracted to leaf module
22
22
  export { sessionRenderedToolCache, getOrCacheRenderedTool, clearSessionRenderedToolCache, clearSessionPerToolCache, } from "./tool-schema-cache.js";
23
+ // Stub filter injector (auto-discovery stubs)
24
+ export { createStubFilterInjector } from "./stub-filter-injector.js";
@@ -105,6 +105,14 @@ export interface RequestBodyInjectorConfig {
105
105
  /** Getter for elapsed ms since last assistant response.
106
106
  * Used for time-based microcompact to detect cold-start scenarios. */
107
107
  getElapsedSinceLastResponse?: () => number | undefined;
108
+ /** When true, the recent-zone message breakpoint may be promoted from
109
+ * "short" to "long" TTL based on observed inter-turn timing.
110
+ * Requires sessionKey, getElapsedSinceLastResponse, AND getLastResponseTs. */
111
+ promoteRecentZoneOnSlowCadence?: boolean;
112
+ /** Getter for the raw `sessionLastResponseTs.ts` value (ms since epoch)
113
+ * for this session, or undefined on cold-start. Used by the cadence
114
+ * tracker to detect turn boundaries within a single execute(). */
115
+ getLastResponseTs?: () => number | undefined;
108
116
  /** Number of recent tool results to preserve during microcompact.
109
117
  * Defaults to 25 (matches observation masker keep window). */
110
118
  observationKeepWindow?: number;
@@ -141,6 +149,7 @@ export { clearSessionRenderedToolCache, getOrCacheRenderedTool, clearSessionPerT
141
149
  export type { RenderedToolCacheEntry } from "./tool-schema-cache.js";
142
150
  export declare function clearSessionPrefixStability(sessionKey: string): void;
143
151
  export declare function clearSessionBetaHeaderLatches(sessionKey: string): void;
152
+ export declare function clearSessionCadenceTracker(sessionKey: string): void;
144
153
  /**
145
154
  * Resolve effective cache retention for a model, considering per-model overrides.
146
155
  * Uses longest-prefix-first matching: "claude-sonnet-4-6" wins over "claude-sonnet".
@@ -43,6 +43,13 @@ export function clearSessionPrefixStability(sessionKey) {
43
43
  export function clearSessionBetaHeaderLatches(sessionKey) {
44
44
  sessionBetaHeaderLatches.delete(sessionKey);
45
45
  }
46
+ const sessionCadenceTracker = new Map();
47
+ export function clearSessionCadenceTracker(sessionKey) {
48
+ sessionCadenceTracker.delete(sessionKey);
49
+ }
50
+ const SLOW_CADENCE_PROMOTION_THRESHOLD = 3;
51
+ const FAST_CADENCE_DEMOTION_THRESHOLD = 5;
52
+ const SLOW_CADENCE_MS = 5 * 60 * 1000; // 5 minutes
46
53
  // ---------------------------------------------------------------------------
47
54
  // Per-model cache retention override resolution.
48
55
  // Enables selectively disabling or changing prompt caching for specific model
@@ -402,7 +409,17 @@ function placeCacheBreakpoints(messages, options) {
402
409
  const startFrom = semiStableIdx >= 0 ? semiStableIdx + 1 : 0;
403
410
  const tokensInRange = estimateTokensInRange(startFrom, secondToLastUserIdx);
404
411
  if (tokensInRange >= minTokens) {
405
- addCacheControlToLastBlock(messages[secondToLastUserIdx], retention);
412
+ // Promote recent-zone to "long" when cadence indicates user pauses exceed 5m.
413
+ // Monotonicity guard: recent zone can only be promoted when
414
+ // resolvedRetention (tool/system) is already "long".
415
+ let recentRetention = retention;
416
+ if (options.promoteRecentZoneOnSlowCadence && options.sessionKey) {
417
+ const cadence = sessionCadenceTracker.get(options.sessionKey);
418
+ if (cadence?.promoted && resolvedRetention === "long") {
419
+ recentRetention = "long";
420
+ }
421
+ }
422
+ addCacheControlToLastBlock(messages[secondToLastUserIdx], recentRetention);
406
423
  placed++;
407
424
  }
408
425
  }
@@ -434,7 +451,7 @@ function placeCacheBreakpoints(messages, options) {
434
451
  // The Anthropic API uses a 20-block lookback window for cache prefix matching.
435
452
  // If any gap exceeds the window and slots remain, place a bridging breakpoint
436
453
  // at the midpoint of the gap to prevent silent cache misses.
437
- if (placed > 0 && placed < Math.min(maxBreakpoints, 3)) {
454
+ if (placed > 0 && placed < maxBreakpoints) {
438
455
  const breakpointPositions = [];
439
456
  for (let i = 0; i < messages.length; i++) {
440
457
  const content = messages[i].content;
@@ -448,7 +465,7 @@ function placeCacheBreakpoints(messages, options) {
448
465
  }
449
466
  }
450
467
  // Check gaps between consecutive breakpoints
451
- for (let g = 1; g < breakpointPositions.length && placed < Math.min(maxBreakpoints, 3); g++) {
468
+ for (let g = 1; g < breakpointPositions.length && placed < maxBreakpoints; g++) {
452
469
  const gap = breakpointPositions[g] - breakpointPositions[g - 1];
453
470
  if (gap > CACHE_LOOKBACK_WINDOW) {
454
471
  // Find a user message near the midpoint of the gap
@@ -1338,6 +1355,8 @@ export function createRequestBodyInjector(config, logger) {
1338
1355
  resolvedRetention: inCooldown ? "short" : messageRetention, // Force "short" during cooldown
1339
1356
  strategy: resolveBreakpointStrategy(config.cacheBreakpointStrategy, model.provider),
1340
1357
  skipCacheWrite: effectiveSkipCacheWrite,
1358
+ promoteRecentZoneOnSlowCadence: config.promoteRecentZoneOnSlowCadence,
1359
+ sessionKey: config.sessionKey,
1341
1360
  });
1342
1361
  if (placed > 0) {
1343
1362
  logger.debug({
@@ -1478,6 +1497,49 @@ export function createRequestBodyInjector(config, logger) {
1478
1497
  if (config.onPayloadForCacheDetection) {
1479
1498
  config.onPayloadForCacheDetection(result, model, mergedHeaders);
1480
1499
  }
1500
+ // Track cadence for recent-zone promotion (symmetric: promote slow, demote on fast).
1501
+ // Runs after onPayloadForCacheDetection so the detection snapshot reflects the
1502
+ // pre-mutation state. Mutation takes effect on the next turn's placeCacheBreakpoints().
1503
+ if (config.promoteRecentZoneOnSlowCadence && config.sessionKey
1504
+ && config.getElapsedSinceLastResponse && config.getLastResponseTs) {
1505
+ const lastResponseTs = config.getLastResponseTs();
1506
+ if (lastResponseTs !== undefined) {
1507
+ let tracker = sessionCadenceTracker.get(config.sessionKey);
1508
+ if (!tracker) {
1509
+ tracker = {
1510
+ consecutiveSlowTurns: 0,
1511
+ consecutiveFastTurns: 0,
1512
+ promoted: false,
1513
+ lastObservedResponseTs: undefined,
1514
+ };
1515
+ sessionCadenceTracker.set(config.sessionKey, tracker);
1516
+ }
1517
+ // Same-turn guard: successive onPayload calls inside one execute() all
1518
+ // observe the same lastResponseTs. Only count once per turn boundary.
1519
+ if (lastResponseTs !== tracker.lastObservedResponseTs) {
1520
+ tracker.lastObservedResponseTs = lastResponseTs;
1521
+ const elapsed = config.getElapsedSinceLastResponse();
1522
+ if (elapsed !== undefined) {
1523
+ if (elapsed > SLOW_CADENCE_MS) {
1524
+ tracker.consecutiveSlowTurns++;
1525
+ tracker.consecutiveFastTurns = 0;
1526
+ if (!tracker.promoted && tracker.consecutiveSlowTurns >= SLOW_CADENCE_PROMOTION_THRESHOLD) {
1527
+ tracker.promoted = true;
1528
+ logger.info({ sessionKey: config.sessionKey, consecutiveSlowTurns: tracker.consecutiveSlowTurns }, "Recent-zone TTL promoted to long: slow cadence detected");
1529
+ }
1530
+ }
1531
+ else {
1532
+ tracker.consecutiveFastTurns++;
1533
+ tracker.consecutiveSlowTurns = 0;
1534
+ if (tracker.promoted && tracker.consecutiveFastTurns >= FAST_CADENCE_DEMOTION_THRESHOLD) {
1535
+ tracker.promoted = false;
1536
+ logger.info({ sessionKey: config.sessionKey, consecutiveFastTurns: tracker.consecutiveFastTurns }, "Recent-zone TTL demoted to short: fast cadence resumed");
1537
+ }
1538
+ }
1539
+ }
1540
+ }
1541
+ }
1542
+ }
1481
1543
  // SDK-UPGRADE: Upgrade SDK auto-placed 5m markers to 1h when retention is long.
1482
1544
  // The pi-ai SDK places cache_control: { type: "ephemeral" } (5m TTL) on the last
1483
1545
  // user message. When the session uses "long" retention, these 5m writes waste money
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Stub-filter injector stream wrapper.
3
+ *
4
+ * Removes auto-discovery stub tools (see tool-deferral.ts createAutoDiscoveryStubs)
5
+ * from the API-ready payload. Stubs are present in the SDK's tools array so
6
+ * agent-loop.js tool lookup succeeds, but must not reach the API:
7
+ * - They consume input tokens (~100 tokens per stub schema).
8
+ * - For Anthropic models where supportsToolSearch() is true, unfiltered stub
9
+ * names would match in the DEFER-TOOL block (request-body-injector.ts
10
+ * lines 1599-1643), causing deferCount > 0 which REMOVES client-side
11
+ * discover_tools and APPENDS server-side tool_search_tool_regex —
12
+ * flipping the session to a different control path unintentionally.
13
+ * - For Google AI Studio, unfiltered stubs would be persisted into the
14
+ * Gemini CachedContent entry (gemini-cache-injector.ts line 191-195),
15
+ * bloating the cache for its entire lifetime.
16
+ *
17
+ * Provider-agnostic: filters both top-level `params.tools` (Anthropic,
18
+ * OpenAI, xAI) and nested `params.config.tools` (Google AI Studio).
19
+ *
20
+ * @module
21
+ */
22
+ import type { ComisLogger } from "@comis/infra";
23
+ import type { StreamFnWrapper } from "./types.js";
24
+ export interface StubFilterInjectorConfig {
25
+ /** Getter for stub tool names. Filtered from the rendered API payload. */
26
+ getStubToolNames: () => ReadonlySet<string>;
27
+ }
28
+ export declare function createStubFilterInjector(config: StubFilterInjectorConfig, logger: ComisLogger): StreamFnWrapper;