comisai 1.0.15 → 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 (47) hide show
  1. package/node_modules/@comis/agent/dist/bootstrap/types.d.ts +1 -1
  2. package/node_modules/@comis/agent/dist/bridge/pi-event-bridge.js +12 -11
  3. package/node_modules/@comis/agent/dist/context-engine/constants.d.ts +11 -0
  4. package/node_modules/@comis/agent/dist/context-engine/constants.js +11 -0
  5. package/node_modules/@comis/agent/dist/context-engine/index.d.ts +1 -1
  6. package/node_modules/@comis/agent/dist/context-engine/index.js +1 -1
  7. package/node_modules/@comis/agent/dist/context-engine/llm-compaction.js +32 -18
  8. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.d.ts +2 -0
  9. package/node_modules/@comis/agent/dist/executor/cache-break-diff-writer.js +36 -0
  10. package/node_modules/@comis/agent/dist/executor/executor-post-execution.d.ts +12 -0
  11. package/node_modules/@comis/agent/dist/executor/executor-post-execution.js +117 -27
  12. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.d.ts +1 -0
  13. package/node_modules/@comis/agent/dist/executor/executor-prompt-runner.js +16 -4
  14. package/node_modules/@comis/agent/dist/executor/executor-stream-setup.js +17 -1
  15. package/node_modules/@comis/agent/dist/executor/executor-tool-assembly.js +27 -1
  16. package/node_modules/@comis/agent/dist/executor/session-snapshot-cleanup.js +3 -1
  17. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.d.ts +2 -0
  18. package/node_modules/@comis/agent/dist/executor/stream-wrappers/index.js +2 -0
  19. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.d.ts +9 -0
  20. package/node_modules/@comis/agent/dist/executor/stream-wrappers/request-body-injector.js +65 -3
  21. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.d.ts +28 -0
  22. package/node_modules/@comis/agent/dist/executor/stream-wrappers/stub-filter-injector.js +63 -0
  23. package/node_modules/@comis/agent/dist/executor/tool-deferral.d.ts +18 -1
  24. package/node_modules/@comis/agent/dist/executor/tool-deferral.js +275 -133
  25. package/node_modules/@comis/agent/dist/executor/ttl-guard.d.ts +6 -0
  26. package/node_modules/@comis/agent/dist/executor/ttl-guard.js +8 -0
  27. package/node_modules/@comis/agent/package.json +1 -1
  28. package/node_modules/@comis/channels/dist/shared/typing-controller.d.ts +7 -0
  29. package/node_modules/@comis/channels/dist/shared/typing-controller.js +33 -0
  30. package/node_modules/@comis/channels/package.json +1 -1
  31. package/node_modules/@comis/cli/dist/commands/daemon.js +116 -28
  32. package/node_modules/@comis/cli/package.json +1 -1
  33. package/node_modules/@comis/core/dist/config/schema-agent.d.ts +10 -0
  34. package/node_modules/@comis/core/dist/config/schema-agent.js +8 -0
  35. package/node_modules/@comis/core/dist/config/schema-skills.d.ts +16 -2
  36. package/node_modules/@comis/core/dist/config/schema-skills.js +9 -2
  37. package/node_modules/@comis/core/dist/config/schema.d.ts +6 -3
  38. package/node_modules/@comis/core/package.json +1 -1
  39. package/node_modules/@comis/daemon/package.json +1 -1
  40. package/node_modules/@comis/gateway/package.json +1 -1
  41. package/node_modules/@comis/infra/package.json +1 -1
  42. package/node_modules/@comis/memory/package.json +1 -1
  43. package/node_modules/@comis/scheduler/package.json +1 -1
  44. package/node_modules/@comis/shared/package.json +1 -1
  45. package/node_modules/@comis/skills/dist/builtin/platform/gateway-tool.d.ts +1 -1
  46. package/node_modules/@comis/skills/package.json +1 -1
  47. package/package.json +12 -12
@@ -302,9 +302,14 @@ export function applyToolDeferral(tools, _contextWindow, deferralContext, logger
302
302
  remainingDeferred.push(entry);
303
303
  }
304
304
  }
305
- // Create discover_tools only when remaining deferred entries exist
305
+ // Create discover_tools only when remaining deferred entries exist.
306
+ // Thread active-tool names through so "already active" guidance works.
307
+ // NOTE: executor-tool-assembly.ts rebuilds this tool a second time with the
308
+ // post-deferral active set (active + discovered) since the final active set
309
+ // isn't known until after this function returns.
310
+ const activeNamesForDiscover = deferralContext.activeToolNames ?? new Set();
306
311
  const discoverTool = remainingDeferred.length > 0
307
- ? createDiscoverTool(remainingDeferred, logger, embeddingPort, scoreConfig)
312
+ ? createDiscoverTool(remainingDeferred, logger, embeddingPort, scoreConfig, activeNamesForDiscover)
308
313
  : null;
309
314
  const deferredNames = [...deferredSet];
310
315
  // Log deferral
@@ -400,10 +405,20 @@ const DEFAULT_TOOL_DISCOVERY_SCORES = {
400
405
  * Receives DeferredToolEntry[] (with original schemas and display descriptions)
401
406
  * so it can serve full schemas and lean descriptions when queried.
402
407
  *
408
+ * BM25 scores are normalized to [0, 1] (fraction of top match) BEFORE the
409
+ * score-floor filter applies, matching the semantics of hybrid-mode scoring.
410
+ * This ensures the top match always clears any floor <= 1.0 whenever any
411
+ * positive signal exists (fixes the srv1593437 08:06:39Z "install MCP"
412
+ * regression where raw BM25 ~0.74 was dropped by the 0.8 raw-score floor).
413
+ *
403
414
  * @param scoreConfig Optional score-floor override (defaults to 0.8 BM25 /
404
415
  * 0.35 hybrid). Zero or negative floors disable the filter.
416
+ * @param activeToolNames Names of tools currently ACTIVE in this session
417
+ * (post-deferral: active + discovered). Used to return "already active"
418
+ * guidance when queries re-ask for loaded MCPs. Must NOT include names
419
+ * that were deferred. Default empty set keeps callers backward-compatible.
405
420
  */
406
- export function createDiscoverTool(deferredEntries, logger, embeddingPort, scoreConfig = DEFAULT_TOOL_DISCOVERY_SCORES) {
421
+ export function createDiscoverTool(deferredEntries, logger, embeddingPort, scoreConfig = DEFAULT_TOOL_DISCOVERY_SCORES, activeToolNames = new Set()) {
407
422
  // Build ToolDefinition[] view for structuredSearch compatibility
408
423
  const deferredTools = deferredEntries.map(e => e.original);
409
424
  /**
@@ -436,14 +451,10 @@ export function createDiscoverTool(deferredEntries, logger, embeddingPort, score
436
451
  async execute(_toolCallId, params) {
437
452
  const p = params;
438
453
  const query = String(p.query ?? "");
439
- // Try structured search first (deterministic modes)
454
+ // ---------- Path 1: structured deterministic modes ----------
440
455
  const structuredResults = structuredSearch(deferredTools, query, 10);
441
- let matches;
442
- let searchMode;
443
456
  if (structuredResults.length > 0) {
444
- // Structured search matched -- skip BM25/embedding entirely
445
- matches = structuredResults;
446
- searchMode = query.toLowerCase().trim().startsWith("select:")
457
+ const searchMode = query.toLowerCase().trim().startsWith("select:")
447
458
  ? "select"
448
459
  : query.toLowerCase().trim().startsWith("mcp__") || query.toLowerCase().trim().startsWith("mcp:")
449
460
  ? "prefix"
@@ -453,151 +464,248 @@ export function createDiscoverTool(deferredEntries, logger, embeddingPort, score
453
464
  query,
454
465
  searchMode,
455
466
  candidateCount: deferredEntries.length,
456
- structuredMatchCount: matches.length,
457
- topMatch: matches[0]?.name ?? "none",
467
+ structuredMatchCount: structuredResults.length,
468
+ topMatch: structuredResults[0]?.name ?? "none",
458
469
  }, "discover_tools search completed");
470
+ return formatDiscoveryResponse(structuredResults, deferredEntries);
459
471
  }
460
- else {
461
- // No structured match -- fall through to BM25 + optional embedding
462
- const documents = deferredTools.map(t => ({
463
- name: t.name,
464
- text: resolveBM25Text(t),
465
- }));
466
- let ranked = bm25Score(query, documents);
467
- // Optional: semantic re-ranking via EmbeddingPort
468
- let embeddingUsed = false;
469
- if (embeddingPort && ranked.length > 0) {
470
- try {
471
- const queryResult = await embeddingPort.embed(query);
472
- if (queryResult.ok) {
473
- const queryVec = queryResult.value;
474
- const textsToEmbed = ranked.map(r => {
475
- const doc = documents.find(d => d.name === r.name);
476
- return doc?.text ?? r.name;
477
- });
478
- const batchResult = await embeddingPort.embedBatch(textsToEmbed);
479
- if (batchResult.ok) {
480
- const docVecs = batchResult.value;
481
- const maxBm25 = ranked[0].score;
482
- const combined = ranked.map((r, i) => {
483
- const bm25Norm = maxBm25 > 0 ? r.score / maxBm25 : 0;
484
- const cosineSim = cosine(queryVec, docVecs[i]);
485
- return { name: r.name, score: 0.5 * bm25Norm + 0.5 * cosineSim };
486
- });
487
- combined.sort((a, b) => b.score - a.score);
488
- ranked = combined;
489
- embeddingUsed = true;
490
- }
472
+ // ---------- Path 2: BM25 (+ optional hybrid) fallback ----------
473
+ const documents = deferredTools.map(t => ({
474
+ name: t.name,
475
+ text: resolveBM25Text(t),
476
+ }));
477
+ const rankedRaw = bm25Score(query, documents);
478
+ const rawTopScore = rankedRaw[0]?.score ?? 0;
479
+ // NORMALIZE UP FRONT -- both modes now operate in [0, 1] space.
480
+ // This makes `minBm25Score` semantically equivalent to `minHybridScore`:
481
+ // a fraction of the top match. After this step, ranked[0].score === 1.0
482
+ // whenever rawTopScore > 0, so the top match always clears any floor <= 1.0.
483
+ let ranked = rawTopScore > 0
484
+ ? rankedRaw.map(r => ({ name: r.name, score: r.score / rawTopScore }))
485
+ : rankedRaw.map(r => ({ name: r.name, score: 0 }));
486
+ // Optional: semantic re-ranking via EmbeddingPort
487
+ let embeddingUsed = false;
488
+ if (embeddingPort && ranked.length > 0) {
489
+ try {
490
+ const queryResult = await embeddingPort.embed(query);
491
+ if (queryResult.ok) {
492
+ const queryVec = queryResult.value;
493
+ const textsToEmbed = ranked.map(r => {
494
+ const doc = documents.find(d => d.name === r.name);
495
+ return doc?.text ?? r.name;
496
+ });
497
+ const batchResult = await embeddingPort.embedBatch(textsToEmbed);
498
+ if (batchResult.ok) {
499
+ const docVecs = batchResult.value;
500
+ // `ranked` is already BM25-normalized; combine with cosine.
501
+ // NO second normalization -- that would double-normalize and
502
+ // change the scoring contract.
503
+ const combined = ranked.map((r, i) => ({
504
+ name: r.name,
505
+ score: 0.5 * r.score + 0.5 * cosine(queryVec, docVecs[i]),
506
+ }));
507
+ combined.sort((a, b) => b.score - a.score);
508
+ ranked = combined;
509
+ embeddingUsed = true;
491
510
  }
492
511
  }
493
- catch (embeddingErr) {
494
- logger.warn({
495
- err: embeddingErr,
496
- hint: "discover_tools falling back to BM25-only search; check embedding provider",
497
- errorKind: "dependency",
498
- }, "discover_tools embedding re-ranking failed");
499
- }
500
512
  }
501
- // Score-floor filter: zero-signal queries produce spurious BM25
502
- // matches (token overlap on common words) and cosine-noise in hybrid
503
- // mode. Filter below the floor before slicing so "no matches" is
504
- // reported honestly instead of surfacing misleading top-10 noise.
505
- const floor = embeddingUsed ? scoreConfig.minHybridScore : scoreConfig.minBm25Score;
506
- const rawTop = ranked[0];
507
- const filtered = ranked.filter(r => r.score >= floor);
508
- const topResults = filtered.slice(0, 10);
509
- searchMode = embeddingUsed ? "hybrid" : "bm25";
510
- logger.debug({
511
- toolName: "discover_tools",
512
- query,
513
- searchMode,
514
- candidateCount: deferredEntries.length,
515
- resultCount: topResults.length,
516
- topScore: topResults[0]?.score ?? 0,
517
- topMatch: topResults[0]?.name ?? "none",
518
- floor,
519
- filteredOut: ranked.length - filtered.length,
520
- }, "discover_tools search completed");
521
- if (topResults.length === 0) {
513
+ catch (embeddingErr) {
522
514
  logger.warn({
515
+ err: embeddingErr,
516
+ hint: "discover_tools falling back to BM25-only search; check embedding provider health",
517
+ errorKind: "dependency",
518
+ }, "discover_tools embedding re-ranking failed");
519
+ }
520
+ }
521
+ // ---------- Floor check ----------
522
+ const floor = embeddingUsed ? scoreConfig.minHybridScore : scoreConfig.minBm25Score;
523
+ const normalizedTopScore = ranked[0]?.score ?? 0;
524
+ const filtered = ranked.filter(r => r.score >= floor);
525
+ const topResults = filtered.slice(0, 10);
526
+ const searchMode = embeddingUsed ? "hybrid" : "bm25";
527
+ logger.debug({
528
+ toolName: "discover_tools",
529
+ query,
530
+ searchMode,
531
+ candidateCount: deferredEntries.length,
532
+ resultCount: topResults.length,
533
+ normalizedTopScore,
534
+ rawTopScore,
535
+ topMatch: topResults[0]?.name ?? "none",
536
+ floor,
537
+ filteredOut: ranked.length - filtered.length,
538
+ }, "discover_tools search completed");
539
+ // ---------- No BM25 match -- check active tools before giving up ----------
540
+ if (topResults.length === 0) {
541
+ const activeMatches = findActiveToolMatches(query, activeToolNames);
542
+ if (activeMatches.length > 0) {
543
+ logger.info({
544
+ toolName: "discover_tools",
523
545
  query,
524
- searchMode,
525
- floor,
526
- topScore: rawTop?.score ?? 0,
527
- topCandidate: rawTop?.name ?? "none",
528
- filteredOut: ranked.length - filtered.length,
529
- hint: "No tool scored above the discover_tools floor; lower skills.toolDiscovery.minBm25Score / minHybridScore if this query should have matched, or retry with an exact tool name or 'select:...' syntax",
530
- errorKind: "validation",
531
- }, "discover_tools: no good matches found");
546
+ activeMatchCount: activeMatches.length,
547
+ topActiveMatch: activeMatches[0],
548
+ }, "discover_tools: query matches already-active tools");
532
549
  return {
533
550
  content: [{
534
551
  type: "text",
535
- text: "No matching tools found. Try an exact tool name, MCP server name (e.g. 'yfinance'), or select:tool1,tool2 syntax.",
552
+ text: `Tool(s) already active -- call directly, no discovery needed:\n${activeMatches.slice(0, 20).map(n => ` - ${n}`).join("\n")}${activeMatches.length > 20 ? `\n ... (${activeMatches.length} total)` : ""}`,
536
553
  }],
537
554
  isError: false,
538
555
  details: undefined,
539
556
  sideEffects: { discoveredTools: [] },
540
557
  };
541
558
  }
542
- // Resolve BM25 results back to ToolDefinition objects
543
- matches = topResults
544
- .map(r => deferredTools.find(t => t.name === r.name))
545
- .filter((t) => t !== undefined);
546
- }
547
- const discoveredNames = matches.map(m => m.name);
548
- // Server-level activation: expand to all tools from same MCP server(s)
549
- const serverNames = new Set();
550
- for (const name of discoveredNames) {
551
- const server = extractMcpServerName(name);
552
- if (server)
553
- serverNames.add(server);
559
+ // Distinguish "corpus has signal but filtered" vs "query terms absent from corpus".
560
+ // After normalization, the former is only reachable in hybrid mode with adversarial
561
+ // cosine (combined < floor). In BM25-only mode it's unreachable because the top
562
+ // match always normalizes to 1.0 >= any floor <= 1.0.
563
+ const warnMsg = rawTopScore > 0
564
+ ? "discover_tools: no matches above floor"
565
+ : "discover_tools: query tokens absent from deferred corpus";
566
+ logger.warn({
567
+ query,
568
+ searchMode,
569
+ floor,
570
+ rawTopScore,
571
+ normalizedTopScore,
572
+ topCandidate: ranked[0]?.name ?? "none",
573
+ filteredOut: ranked.length - filtered.length,
574
+ activeCorpusSize: activeToolNames.size,
575
+ hint: rawTopScore > 0
576
+ ? "No tool scored above the discover_tools floor. Lower skills.toolDiscovery.minHybridScore, retry with an exact tool name, or use 'select:<name>' syntax."
577
+ : "Query tokens do not appear in any deferred tool description. Use 'select:<name>' for exact match, or reconsider whether the tool you want is already active.",
578
+ errorKind: "validation",
579
+ }, warnMsg);
580
+ return {
581
+ content: [{
582
+ type: "text",
583
+ text: "No matching tools found. Try an exact tool name, MCP server name (e.g. 'yfinance'), or select:tool1,tool2 syntax.",
584
+ }],
585
+ isError: false,
586
+ details: undefined,
587
+ sideEffects: { discoveredTools: [] },
588
+ };
554
589
  }
555
- if (serverNames.size > 0) {
556
- for (const entry of deferredEntries) {
557
- const server = extractMcpServerName(entry.name);
558
- if (server && serverNames.has(server) && !discoveredNames.includes(entry.name)) {
559
- discoveredNames.push(entry.name);
560
- }
561
- }
590
+ // ---------- Resolve matches, expand, format ----------
591
+ const matches = topResults
592
+ .map(r => deferredTools.find(t => t.name === r.name))
593
+ .filter((t) => t !== undefined);
594
+ return formatDiscoveryResponse(matches, deferredEntries);
595
+ },
596
+ };
597
+ }
598
+ // ---------------------------------------------------------------------------
599
+ // Helpers: active-tool match + output formatting
600
+ // ---------------------------------------------------------------------------
601
+ /**
602
+ * Check whether `query` refers to any already-active tool.
603
+ * Used to return "already active" guidance instead of "no matches" when
604
+ * the agent re-discovers a previously-installed MCP or active builtin.
605
+ *
606
+ * Match modes (checked in order, first non-empty wins):
607
+ * 1. Exact name match (case-insensitive) against the full query.
608
+ * 2. `mcp__` / `mcp:` prefix match against the full query.
609
+ * 3. Bare server-name match on full query (`"yfinance"` -> all `mcp__yfinance--*`).
610
+ * 4. Per-token server-name fallback: for multi-word queries like
611
+ * `"yfinance get_stock"`, check each whitespace-separated token as a
612
+ * potential MCP server name. Catches the srv1593437 08:06:39Z scenario
613
+ * where the agent emits `{query: "yfinance get_stock"}` rather than just
614
+ * `{query: "yfinance"}`.
615
+ */
616
+ function findActiveToolMatches(query, activeToolNames) {
617
+ const q = query.toLowerCase().trim();
618
+ if (!q || activeToolNames.size === 0)
619
+ return [];
620
+ const names = [...activeToolNames];
621
+ const lowerMap = new Map(names.map(n => [n.toLowerCase(), n]));
622
+ // Mode 1: exact match
623
+ const exact = lowerMap.get(q);
624
+ if (exact)
625
+ return [exact];
626
+ // Mode 2: prefix match (mcp__ or mcp:)
627
+ if ((q.startsWith("mcp__") || q.startsWith("mcp:")) && q.length > 5) {
628
+ const prefix = names.filter(n => n.toLowerCase().startsWith(q));
629
+ if (prefix.length > 0)
630
+ return prefix;
631
+ }
632
+ // Mode 3: bare server name -> mcp__<server>--*
633
+ const serverPrefix = `mcp__${q}--`;
634
+ const server = names.filter(n => n.toLowerCase().startsWith(serverPrefix));
635
+ if (server.length > 0)
636
+ return server;
637
+ // Mode 4: per-token server fallback for multi-word queries.
638
+ // Each whitespace-separated token is probed as a server name. The first
639
+ // token that resolves to >= 1 active tool wins. This handles the common
640
+ // "{server} {verb}" pattern like "yfinance get_stock".
641
+ const tokens = q.split(/\s+/).filter(t => /^[a-z0-9_-]+$/.test(t));
642
+ for (const token of tokens) {
643
+ const tokenServerPrefix = `mcp__${token}--`;
644
+ const tokenMatches = names.filter(n => n.toLowerCase().startsWith(tokenServerPrefix));
645
+ if (tokenMatches.length > 0)
646
+ return tokenMatches;
647
+ }
648
+ return [];
649
+ }
650
+ /**
651
+ * Format matched tool definitions as a `<functions>` block with full JSON schemas,
652
+ * applying server-expansion and co-discovery.
653
+ *
654
+ * Extracted from the inline block in `createDiscoverTool.execute()` so the two
655
+ * return paths (structured + BM25) share one formatter.
656
+ */
657
+ function formatDiscoveryResponse(matches, deferredEntries) {
658
+ const discoveredNames = matches.map(m => m.name);
659
+ // Server-level activation: expand to all tools from same MCP server(s)
660
+ const serverNames = new Set();
661
+ for (const name of discoveredNames) {
662
+ const server = extractMcpServerName(name);
663
+ if (server)
664
+ serverNames.add(server);
665
+ }
666
+ if (serverNames.size > 0) {
667
+ for (const entry of deferredEntries) {
668
+ const server = extractMcpServerName(entry.name);
669
+ if (server && serverNames.has(server) && !discoveredNames.includes(entry.name)) {
670
+ discoveredNames.push(entry.name);
562
671
  }
563
- // Co-discovery: expand to related tools via ComisToolMetadata.coDiscoverWith
564
- const coDiscoveryNames = [];
565
- for (const name of discoveredNames) {
566
- const meta = getToolMetadata(name);
567
- if (meta?.coDiscoverWith) {
568
- for (const coName of meta.coDiscoverWith) {
569
- if (!discoveredNames.includes(coName) && !coDiscoveryNames.includes(coName)) {
570
- // Only add if the tool exists in the deferred set
571
- if (deferredEntries.some(e => e.name === coName)) {
572
- coDiscoveryNames.push(coName);
573
- }
574
- }
672
+ }
673
+ }
674
+ // Co-discovery: expand to related tools via ComisToolMetadata.coDiscoverWith
675
+ const coDiscoveryNames = [];
676
+ for (const name of discoveredNames) {
677
+ const meta = getToolMetadata(name);
678
+ if (meta?.coDiscoverWith) {
679
+ for (const coName of meta.coDiscoverWith) {
680
+ if (!discoveredNames.includes(coName) && !coDiscoveryNames.includes(coName)) {
681
+ // Only add if the tool exists in the deferred set
682
+ if (deferredEntries.some(e => e.name === coName)) {
683
+ coDiscoveryNames.push(coName);
575
684
  }
576
685
  }
577
686
  }
578
- discoveredNames.push(...coDiscoveryNames);
579
- // Add co-discovered tool schemas to the display output
580
- for (const coName of coDiscoveryNames) {
581
- const coEntry = deferredEntries.find(e => e.name === coName);
582
- if (coEntry && !matches.some(m => m.name === coName)) {
583
- matches.push(coEntry.original);
584
- }
585
- }
586
- // Format output as <functions> block with full JSON schemas (after all expansions)
587
- const functionsBlock = matches.map(m => {
588
- return `<function>${JSON.stringify({
589
- name: m.name,
590
- description: resolveToolDescription(m),
591
- parameters: m.parameters,
592
- })}</function>`;
593
- }).join("\n");
594
- return {
595
- content: [{ type: "text", text: `<functions>\n${functionsBlock}\n</functions>` }],
596
- isError: false,
597
- details: undefined,
598
- sideEffects: { discoveredTools: discoveredNames },
599
- };
600
- },
687
+ }
688
+ }
689
+ discoveredNames.push(...coDiscoveryNames);
690
+ // Add co-discovered tool schemas to the display output
691
+ const expandedMatches = [...matches];
692
+ for (const coName of coDiscoveryNames) {
693
+ const coEntry = deferredEntries.find(e => e.name === coName);
694
+ if (coEntry && !expandedMatches.some(m => m.name === coName)) {
695
+ expandedMatches.push(coEntry.original);
696
+ }
697
+ }
698
+ // Format output as <functions> block with full JSON schemas (after all expansions)
699
+ const functionsBlock = expandedMatches.map(m => `<function>${JSON.stringify({
700
+ name: m.name,
701
+ description: resolveToolDescription(m),
702
+ parameters: m.parameters,
703
+ })}</function>`).join("\n");
704
+ return {
705
+ content: [{ type: "text", text: `<functions>\n${functionsBlock}\n</functions>` }],
706
+ isError: false,
707
+ details: undefined,
708
+ sideEffects: { discoveredTools: discoveredNames },
601
709
  };
602
710
  }
603
711
  // ---------------------------------------------------------------------------
@@ -615,3 +723,37 @@ function cosine(a, b) {
615
723
  const denom = Math.sqrt(magA) * Math.sqrt(magB);
616
724
  return denom === 0 ? 0 : dot / denom;
617
725
  }
726
+ // ---------------------------------------------------------------------------
727
+ // Auto-discovery stubs
728
+ // ---------------------------------------------------------------------------
729
+ export const DEFERRAL_STUB_MARKER = "__comis_deferral_stub__";
730
+ export function createAutoDiscoveryStubs(deferredEntries, discoveryTracker, logger) {
731
+ return deferredEntries.map(entry => {
732
+ // `label` is a required field on ToolDefinition
733
+ // (pi-coding-agent/core/extensions/types.d.ts). Some existing code paths
734
+ // dereference it (pi-executor.ts mid-turn injection at line ~826), so
735
+ // copy from the original rather than leave undefined.
736
+ const originalLabel = entry.original.label;
737
+ const stub = {
738
+ name: entry.name,
739
+ label: originalLabel ?? entry.name,
740
+ description: entry.description,
741
+ parameters: entry.original.parameters,
742
+ [DEFERRAL_STUB_MARKER]: true,
743
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
744
+ const result = await entry.original.execute(toolCallId, params, signal, onUpdate, ctx);
745
+ // Mark discovered only after a SUCCESSFUL execution. An MCP tool can
746
+ // return `{ isError: true, content: [...] }` without throwing -- those
747
+ // results must not promote the tool to the active set, or a broken tool
748
+ // would persist across turns and keep wasting discovery budget.
749
+ const isError = result?.isError === true;
750
+ if (!isError) {
751
+ discoveryTracker.markDiscovered([entry.name]);
752
+ }
753
+ logger.info({ toolName: entry.name, toolCallId, isError }, "Auto-discovery stub triggered — forwarding to real tool");
754
+ return result;
755
+ },
756
+ };
757
+ return stub;
758
+ });
759
+ }
@@ -48,6 +48,12 @@ export declare function clearSessionLastResponseTs(sessionKey: string): void;
48
48
  * Used by the idle-based thinking clear to determine if cache is cold.
49
49
  */
50
50
  export declare function getElapsedSinceLastResponse(sessionKey: string): number | undefined;
51
+ /**
52
+ * Get the raw timestamp of the last recorded response for a session.
53
+ * Returns undefined if no response has been recorded (cold-start).
54
+ * Used by the cadence tracker to detect turn boundaries.
55
+ */
56
+ export declare function getLastResponseTs(sessionKey: string): number | undefined;
51
57
  /** Expose the internal Map for test assertions. Underscore prefix per project convention. */
52
58
  export declare function _getSessionLastResponseTsForTest(): Map<string, {
53
59
  ts: number;
@@ -94,6 +94,14 @@ export function getElapsedSinceLastResponse(sessionKey) {
94
94
  return undefined;
95
95
  return Date.now() - entry.ts;
96
96
  }
97
+ /**
98
+ * Get the raw timestamp of the last recorded response for a session.
99
+ * Returns undefined if no response has been recorded (cold-start).
100
+ * Used by the cadence tracker to detect turn boundaries.
101
+ */
102
+ export function getLastResponseTs(sessionKey) {
103
+ return sessionLastResponseTs.get(sessionKey)?.ts;
104
+ }
97
105
  // ---------------------------------------------------------------------------
98
106
  // Test-only export
99
107
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comis/agent",
3
3
  "private": true,
4
- "version": "1.0.15",
4
+ "version": "1.0.16",
5
5
  "author": "Moshe Anconina",
6
6
  "license": "Apache-2.0",
7
7
  "description": "AI agent executor, budget control, and session management for Comis",
@@ -33,6 +33,13 @@
33
33
  * text deltas from the model). Active generation keeps the indicator
34
34
  * alive beyond the initial TTL window.
35
35
  *
36
+ * 6. **Internal TTL refresh (liveness watchdog)**: While active and not
37
+ * sealed, an internal 30s interval calls resetTtl() unconditionally.
38
+ * This is a belt-and-braces mechanism for long silences (extended
39
+ * thinking, slow tools, inter-turn gaps). External refreshTtl() calls
40
+ * from content signals remain the primary mechanism; this timer is
41
+ * additive, not a replacement.
42
+ *
36
43
  * Typing failures are non-fatal: errors from `sendTyping` are caught
37
44
  * and logged but never propagated to the caller.
38
45
  *
@@ -34,6 +34,13 @@
34
34
  * text deltas from the model). Active generation keeps the indicator
35
35
  * alive beyond the initial TTL window.
36
36
  *
37
+ * 6. **Internal TTL refresh (liveness watchdog)**: While active and not
38
+ * sealed, an internal 30s interval calls resetTtl() unconditionally.
39
+ * This is a belt-and-braces mechanism for long silences (extended
40
+ * thinking, slow tools, inter-turn gaps). External refreshTtl() calls
41
+ * from content signals remain the primary mechanism; this timer is
42
+ * additive, not a replacement.
43
+ *
37
44
  * Typing failures are non-fatal: errors from `sendTyping` are caught
38
45
  * and logged but never propagated to the caller.
39
46
  *
@@ -42,6 +49,11 @@
42
49
  // ---------------------------------------------------------------------------
43
50
  // Factory
44
51
  // ---------------------------------------------------------------------------
52
+ /** Internal liveness refresh interval. MUST be strictly less than default
53
+ * ttlMs (60_000) so an active, non-sealed controller cannot have its TTL
54
+ * expire without an explicit stop() or circuit-breaker trip. Matches the
55
+ * tool-active refresh cadence in execution-execute.ts for consistency. */
56
+ const INTERNAL_TTL_REFRESH_MS = 30_000;
45
57
  /**
46
58
  * Create a typing controller that sends platform typing indicators at a
47
59
  * configurable refresh interval.
@@ -59,6 +71,7 @@ export function createTypingController(config, sendTyping, logger) {
59
71
  let consecutiveFailures = 0;
60
72
  let tickInFlight = false;
61
73
  let ttlTimer = null;
74
+ let ttlRefreshTimer = null;
62
75
  const threshold = config.circuitBreakerThreshold ?? 3;
63
76
  /** Fire-and-forget typing send with tick serialization and circuit breaker. */
64
77
  function doSendTyping(chatId) {
@@ -83,6 +96,10 @@ export function createTypingController(config, sendTyping, logger) {
83
96
  clearTimeout(ttlTimer);
84
97
  ttlTimer = null;
85
98
  }
99
+ if (ttlRefreshTimer !== null) {
100
+ clearInterval(ttlRefreshTimer);
101
+ ttlRefreshTimer = null;
102
+ }
86
103
  }
87
104
  else {
88
105
  logger?.warn({ err, chatId, hint: "Typing indicator delivery failed; non-blocking", errorKind: "platform" }, "Typing indicator send failed");
@@ -106,6 +123,10 @@ export function createTypingController(config, sendTyping, logger) {
106
123
  clearInterval(timer);
107
124
  timer = null;
108
125
  }
126
+ if (ttlRefreshTimer !== null) {
127
+ clearInterval(ttlRefreshTimer);
128
+ ttlRefreshTimer = null;
129
+ }
109
130
  }
110
131
  }, config.ttlMs ?? 60_000);
111
132
  }
@@ -125,6 +146,14 @@ export function createTypingController(config, sendTyping, logger) {
125
146
  }, config.refreshMs);
126
147
  // Arm the TTL timer.
127
148
  resetTtl();
149
+ // Belt-and-braces liveness watchdog: refresh the TTL on a fixed cadence so
150
+ // the indicator survives long silences (extended thinking, slow tools,
151
+ // inter-turn gaps) even when no external signal calls refreshTtl().
152
+ ttlRefreshTimer = setInterval(() => {
153
+ if (active && !sealed) {
154
+ resetTtl();
155
+ }
156
+ }, INTERNAL_TTL_REFRESH_MS);
128
157
  },
129
158
  stop() {
130
159
  active = false;
@@ -137,6 +166,10 @@ export function createTypingController(config, sendTyping, logger) {
137
166
  clearTimeout(ttlTimer);
138
167
  ttlTimer = null;
139
168
  }
169
+ if (ttlRefreshTimer !== null) {
170
+ clearInterval(ttlRefreshTimer);
171
+ ttlRefreshTimer = null;
172
+ }
140
173
  },
141
174
  refreshTtl() {
142
175
  if (!active || sealed)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comis/channels",
3
3
  "private": true,
4
- "version": "1.0.15",
4
+ "version": "1.0.16",
5
5
  "author": "Moshe Anconina",
6
6
  "license": "Apache-2.0",
7
7
  "description": "Chat platform adapters — Discord, Telegram, Slack, WhatsApp, Signal, iMessage, IRC, LINE",