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
@@ -0,0 +1,63 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Stub-filter injector stream wrapper.
4
+ *
5
+ * Removes auto-discovery stub tools (see tool-deferral.ts createAutoDiscoveryStubs)
6
+ * from the API-ready payload. Stubs are present in the SDK's tools array so
7
+ * agent-loop.js tool lookup succeeds, but must not reach the API:
8
+ * - They consume input tokens (~100 tokens per stub schema).
9
+ * - For Anthropic models where supportsToolSearch() is true, unfiltered stub
10
+ * names would match in the DEFER-TOOL block (request-body-injector.ts
11
+ * lines 1599-1643), causing deferCount > 0 which REMOVES client-side
12
+ * discover_tools and APPENDS server-side tool_search_tool_regex —
13
+ * flipping the session to a different control path unintentionally.
14
+ * - For Google AI Studio, unfiltered stubs would be persisted into the
15
+ * Gemini CachedContent entry (gemini-cache-injector.ts line 191-195),
16
+ * bloating the cache for its entire lifetime.
17
+ *
18
+ * Provider-agnostic: filters both top-level `params.tools` (Anthropic,
19
+ * OpenAI, xAI) and nested `params.config.tools` (Google AI Studio).
20
+ *
21
+ * @module
22
+ */
23
+ export function createStubFilterInjector(config, logger) {
24
+ return function stubFilterInjector(next) {
25
+ return (model, context, options) => {
26
+ const existingOnPayload = options?.onPayload;
27
+ const enhancedOptions = {
28
+ ...options,
29
+ onPayload: async (payload, payloadModel) => {
30
+ // Let any upstream onPayload run first (we are the innermost wrapper,
31
+ // so upstream here is the SDK's own or a user-provided one).
32
+ const resolvedParams = (existingOnPayload
33
+ ? await Promise.resolve(existingOnPayload(payload, payloadModel))
34
+ : payload);
35
+ const stubNames = config.getStubToolNames();
36
+ if (stubNames.size === 0)
37
+ return resolvedParams;
38
+ let removed = 0;
39
+ // Top-level tools (Anthropic, OpenAI, xAI)
40
+ if (Array.isArray(resolvedParams.tools)) {
41
+ const before = resolvedParams.tools.length;
42
+ resolvedParams.tools = resolvedParams.tools
43
+ .filter(t => !stubNames.has(t.name));
44
+ removed += before - resolvedParams.tools.length;
45
+ }
46
+ // Nested config.tools (Google AI Studio / Gemini)
47
+ const cfg = resolvedParams.config;
48
+ if (cfg && Array.isArray(cfg.tools)) {
49
+ const before = cfg.tools.length;
50
+ cfg.tools = cfg.tools
51
+ .filter(t => !stubNames.has(t.name));
52
+ removed += before - cfg.tools.length;
53
+ }
54
+ if (removed > 0) {
55
+ logger.debug({ removed, provider: payloadModel?.provider }, "Stub filter removed auto-discovery stubs from payload");
56
+ }
57
+ return resolvedParams;
58
+ },
59
+ };
60
+ return next(model, context, enhancedOptions);
61
+ };
62
+ };
63
+ }
@@ -50,6 +50,11 @@ export interface DeferralContext {
50
50
  * "default") do not inject mid-turn, so MCP tools must be active from the
51
51
  * start. When undefined, defaults to deferring (backward compat). */
52
52
  providerFamily?: string;
53
+ /** Names of tools currently ACTIVE in this session (post-deferral).
54
+ * Consumed by discover_tools to return "already active" guidance when
55
+ * queries re-ask for loaded MCPs. Must NOT include names that were
56
+ * deferred -- pass the post-deferral set, not mergedCustomTools. */
57
+ activeToolNames?: ReadonlySet<string>;
53
58
  }
54
59
  /** Entry describing a deferred tool with its display description and original definition. */
55
60
  export interface DeferredToolEntry {
@@ -153,7 +158,19 @@ export interface ToolDiscoveryScoreConfig {
153
158
  * Receives DeferredToolEntry[] (with original schemas and display descriptions)
154
159
  * so it can serve full schemas and lean descriptions when queried.
155
160
  *
161
+ * BM25 scores are normalized to [0, 1] (fraction of top match) BEFORE the
162
+ * score-floor filter applies, matching the semantics of hybrid-mode scoring.
163
+ * This ensures the top match always clears any floor <= 1.0 whenever any
164
+ * positive signal exists (fixes the srv1593437 08:06:39Z "install MCP"
165
+ * regression where raw BM25 ~0.74 was dropped by the 0.8 raw-score floor).
166
+ *
156
167
  * @param scoreConfig Optional score-floor override (defaults to 0.8 BM25 /
157
168
  * 0.35 hybrid). Zero or negative floors disable the filter.
169
+ * @param activeToolNames Names of tools currently ACTIVE in this session
170
+ * (post-deferral: active + discovered). Used to return "already active"
171
+ * guidance when queries re-ask for loaded MCPs. Must NOT include names
172
+ * that were deferred. Default empty set keeps callers backward-compatible.
158
173
  */
159
- export declare function createDiscoverTool(deferredEntries: DeferredToolEntry[], logger: ComisLogger, embeddingPort?: EmbeddingPort, scoreConfig?: ToolDiscoveryScoreConfig): ToolDefinition;
174
+ export declare function createDiscoverTool(deferredEntries: DeferredToolEntry[], logger: ComisLogger, embeddingPort?: EmbeddingPort, scoreConfig?: ToolDiscoveryScoreConfig, activeToolNames?: ReadonlySet<string>): ToolDefinition;
175
+ export declare const DEFERRAL_STUB_MARKER: "__comis_deferral_stub__";
176
+ export declare function createAutoDiscoveryStubs(deferredEntries: DeferredToolEntry[], discoveryTracker: DiscoveryTracker, logger: ComisLogger): ToolDefinition[];
@@ -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
  // ---------------------------------------------------------------------------
@@ -18,7 +18,7 @@
18
18
  import { SessionManager as SdkSessionManager } from "@mariozechner/pi-coding-agent";
19
19
  import { formatSessionKey, safePath } from "@comis/core";
20
20
  import { suppressError } from "@comis/shared";
21
- import { mkdir, unlink, rm } from "node:fs/promises";
21
+ import { mkdir, unlink, rm, rmdir } from "node:fs/promises";
22
22
  import { existsSync, writeFileSync, readFileSync } from "node:fs";
23
23
  import { dirname } from "node:path";
24
24
  import { sessionKeyToPath } from "./session-key-mapper.js";
@@ -61,8 +61,13 @@ export function createComisSessionManager(deps) {
61
61
  await unlink(sessionPath);
62
62
  }
63
63
  catch { /* ENOENT ok */ }
64
- const toolResultsDir = safePath(dirname(sessionPath), "tool-results");
64
+ const sessionDir = dirname(sessionPath);
65
+ const toolResultsDir = safePath(sessionDir, "tool-results");
65
66
  await suppressError(rm(toolResultsDir, { recursive: true, force: true }), "tool-results dir may not exist");
67
+ try {
68
+ await rmdir(sessionDir);
69
+ }
70
+ catch { /* non-empty or already gone */ }
66
71
  }, { retries: 10, retryMinTimeout: 500 });
67
72
  },
68
73
  writeSessionMetadata(sessionKey, metadata) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@comis/agent",
3
3
  "private": true,
4
- "version": "1.0.14",
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",