discoclaw 1.2.3 → 1.3.0

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 (43) hide show
  1. package/.context/voice.md +30 -2
  2. package/.env.example +6 -0
  3. package/dist/cli/dashboard.js +7 -1
  4. package/dist/config.js +7 -0
  5. package/dist/cron/executor.js +72 -1
  6. package/dist/dashboard/api/metrics.js +7 -0
  7. package/dist/dashboard/api/metrics.test.js +16 -0
  8. package/dist/dashboard/api/traces.js +14 -0
  9. package/dist/dashboard/api/traces.test.js +40 -0
  10. package/dist/dashboard/page.js +187 -8
  11. package/dist/dashboard/server.js +81 -14
  12. package/dist/dashboard/server.test.js +120 -4
  13. package/dist/discord/deferred-runner.js +306 -219
  14. package/dist/discord/message-coordinator.js +1 -28
  15. package/dist/discord/reaction-handler.js +81 -3
  16. package/dist/index.js +15 -1
  17. package/dist/observability/trace-store.js +56 -0
  18. package/dist/observability/trace-utils.js +31 -0
  19. package/dist/runtime/codex-cli.js +3 -2
  20. package/dist/runtime/codex-cli.test.js +33 -0
  21. package/dist/runtime/model-tiers.js +1 -1
  22. package/dist/runtime/model-tiers.test.js +9 -0
  23. package/dist/runtime/openai-tool-schemas.js +17 -0
  24. package/dist/voice/audio-pipeline.js +246 -6
  25. package/dist/voice/audio-pipeline.test.js +481 -0
  26. package/dist/voice/audio-receiver.js +8 -0
  27. package/dist/voice/audio-receiver.test.js +16 -0
  28. package/dist/voice/conversation-buffer.js +16 -6
  29. package/dist/voice/providers/gemini-live-provider.js +481 -0
  30. package/dist/voice/providers/gemini-live-provider.test.js +834 -0
  31. package/dist/voice/providers/gemini-live-responder.js +267 -0
  32. package/dist/voice/providers/gemini-live-responder.test.js +615 -0
  33. package/dist/voice/providers/gemini-live-token-estimator.js +100 -0
  34. package/dist/voice/providers/gemini-live-token-estimator.test.js +160 -0
  35. package/dist/voice/providers/gemini-live-types.js +32 -0
  36. package/dist/voice/providers/gemini-tool-mapper.js +91 -0
  37. package/dist/voice/providers/gemini-tool-mapper.test.js +253 -0
  38. package/dist/voice/providers/index.js +3 -0
  39. package/dist/voice/types.test.js +6 -0
  40. package/dist/voice/voice-prompt-builder.js +26 -17
  41. package/dist/voice/voice-prompt-builder.test.js +16 -1
  42. package/package.json +1 -1
  43. package/templates/instructions/SYSTEM_DEFAULTS.md +8 -0
@@ -21,6 +21,8 @@ import { downloadMessageImages, resolveMediaType } from './image-download.js';
21
21
  import { downloadTextAttachments, classifyAttachments, downloadDocumentAttachments } from './file-download.js';
22
22
  import { mapRuntimeErrorToUserMessage } from './user-errors.js';
23
23
  import { globalMetrics } from '../observability/metrics.js';
24
+ import { globalTraceStore } from '../observability/trace-store.js';
25
+ import { summarizeTraceValue } from '../observability/trace-utils.js';
24
26
  import { resolveModel } from '../runtime/model-tiers.js';
25
27
  import { resolveGroundedToolCapabilities } from '../runtime/tool-capabilities.js';
26
28
  import { adaptRuntimeEventText } from './runtime-event-text-adapter.js';
@@ -522,6 +524,9 @@ function createReactionHandler(mode, params, queue, statusRef) {
522
524
  let currentPrompt = prompt;
523
525
  let pendingFollowUp = null;
524
526
  const actionHistory = [];
527
+ const traceId = `reaction_${randomUUID()}`;
528
+ let traceOutcome = 'success';
529
+ globalTraceStore.startTrace(traceId, sessionKey, 'reaction', reaction.message.channelId);
525
530
  try {
526
531
  // -- auto-follow-up loop --
527
532
  while (true) {
@@ -606,8 +611,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
606
611
  const t0 = Date.now();
607
612
  const previewMode = params.streamPreviewMode ?? 'compact';
608
613
  const debugStreamPreviewLines = Boolean(params.debugStreamPreviewLines);
614
+ const toolStartTimesByName = new Map();
609
615
  metrics.recordInvokeStart('reaction');
610
616
  params.log?.info({ flow: 'reaction', sessionKey }, 'obs.invoke.start');
617
+ globalTraceStore.addEvent(traceId, {
618
+ type: 'invoke_start',
619
+ at: t0,
620
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
621
+ promptPreview: summarizeTraceValue(currentPrompt, 220),
622
+ });
611
623
  let invokeError = null;
612
624
  let lastEditAt = 0;
613
625
  let streamEditTimeoutStreak = 0;
@@ -819,10 +831,39 @@ function createReactionHandler(mode, params, queue, statusRef) {
819
831
  lastEventAt = Date.now();
820
832
  stallWarned = false;
821
833
  lastStallProgressAt = 0;
822
- if (evt.type === 'tool_start')
834
+ if (evt.type === 'tool_start') {
823
835
  activeToolCount++;
824
- else if (evt.type === 'tool_end')
836
+ const startedAt = Date.now();
837
+ const existingStarts = toolStartTimesByName.get(evt.name) ?? [];
838
+ existingStarts.push(startedAt);
839
+ toolStartTimesByName.set(evt.name, existingStarts);
840
+ globalTraceStore.addEvent(traceId, {
841
+ type: 'tool_start',
842
+ at: startedAt,
843
+ toolName: evt.name,
844
+ inputSummary: summarizeTraceValue(evt.input),
845
+ });
846
+ }
847
+ else if (evt.type === 'tool_end') {
825
848
  activeToolCount = Math.max(0, activeToolCount - 1);
849
+ const endedAt = Date.now();
850
+ const existingStarts = toolStartTimesByName.get(evt.name) ?? [];
851
+ const startedAt = existingStarts.shift();
852
+ if (existingStarts.length > 0) {
853
+ toolStartTimesByName.set(evt.name, existingStarts);
854
+ }
855
+ else {
856
+ toolStartTimesByName.delete(evt.name);
857
+ }
858
+ globalTraceStore.addEvent(traceId, {
859
+ type: 'tool_end',
860
+ at: endedAt,
861
+ toolName: evt.name,
862
+ ok: evt.ok,
863
+ durationMs: startedAt == null ? undefined : Math.max(0, endedAt - startedAt),
864
+ outputSummary: summarizeTraceValue(evt.output),
865
+ });
866
+ }
826
867
  if (evt.type === 'text_final') {
827
868
  hadTextFinal = true;
828
869
  finalText = evt.text;
@@ -845,6 +886,14 @@ function createReactionHandler(mode, params, queue, statusRef) {
845
886
  }
846
887
  else if (evt.type === 'error') {
847
888
  invokeError = evt.message;
889
+ traceOutcome = 'error';
890
+ globalTraceStore.addEvent(traceId, {
891
+ type: 'error',
892
+ at: Date.now(),
893
+ message: evt.message,
894
+ stage: 'runtime',
895
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
896
+ });
848
897
  metrics.recordInvokeResult('reaction', Date.now() - t0, false, evt.message);
849
898
  params.log?.error({ sessionKey, error: evt.message }, `${logPrefix}:runtime error`);
850
899
  params.log?.warn({ flow: 'reaction', sessionKey, error: evt.message }, 'obs.invoke.error');
@@ -864,6 +913,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
864
913
  streamEditQueue = Promise.resolve();
865
914
  }
866
915
  metrics.recordInvokeResult('reaction', Date.now() - t0, !invokeError, invokeError ?? undefined);
916
+ if (invokeError) {
917
+ traceOutcome = 'error';
918
+ }
919
+ globalTraceStore.addEvent(traceId, {
920
+ type: 'invoke_end',
921
+ at: Date.now(),
922
+ ok: !invokeError,
923
+ summary: followUpDepth === 0 ? 'initial invoke' : `follow-up ${followUpDepth}`,
924
+ });
867
925
  params.log?.info({ flow: 'reaction', sessionKey, ms: Date.now() - t0, ok: !invokeError }, 'obs.invoke.end');
868
926
  let processedText = finalText || deltaText || (collectedImages.length > 0 ? '' : '(no output)');
869
927
  params.log?.info({ sessionKey, sessionId, ms: Date.now() - t0, hadError: Boolean(invokeError) }, `${logPrefix}:invoke:end`);
@@ -939,8 +997,18 @@ function createReactionHandler(mode, params, queue, statusRef) {
939
997
  spawnCtx: params.spawnCtx,
940
998
  });
941
999
  actionResults = results;
942
- for (const result of results) {
1000
+ for (let i = 0; i < results.length; i++) {
1001
+ const result = results[i];
943
1002
  metrics.recordActionResult(result.ok);
1003
+ globalTraceStore.addEvent(traceId, {
1004
+ type: 'action_result',
1005
+ at: Date.now(),
1006
+ action: parsed.actions[i]?.type ?? 'unknown',
1007
+ ok: result.ok,
1008
+ detail: result.ok
1009
+ ? summarizeTraceValue(result.summary, 220)
1010
+ : summarizeTraceValue(result.error, 220),
1011
+ });
944
1012
  params.log?.info({ flow: 'reaction', sessionKey, ok: result.ok }, 'obs.action.result');
945
1013
  }
946
1014
  // Record action history for follow-up dedup.
@@ -1089,6 +1157,15 @@ function createReactionHandler(mode, params, queue, statusRef) {
1089
1157
  } // end while (true)
1090
1158
  }
1091
1159
  catch (innerErr) {
1160
+ traceOutcome = 'error';
1161
+ globalTraceStore.addEvent(traceId, {
1162
+ type: 'error',
1163
+ at: Date.now(),
1164
+ message: innerErr instanceof Error ? innerErr.message : String(innerErr),
1165
+ name: innerErr instanceof Error ? innerErr.name : undefined,
1166
+ stack: innerErr instanceof Error ? summarizeTraceValue(innerErr.stack, 400) : undefined,
1167
+ stage: 'reaction_flow',
1168
+ });
1092
1169
  // Inner catch: attempt to show the error in the reply before the finally
1093
1170
  // block runs dispose(). Setting replyFinalized = true on success prevents
1094
1171
  // the finally's safety-net delete from removing the error message.
@@ -1107,6 +1184,7 @@ function createReactionHandler(mode, params, queue, statusRef) {
1107
1184
  throw innerErr;
1108
1185
  }
1109
1186
  finally {
1187
+ globalTraceStore.endTrace(traceId, traceOutcome);
1110
1188
  // Safety net runs before dispose() so cold-start recovery can still see
1111
1189
  // the in-flight entry if the delete fails.
1112
1190
  if (!replyFinalized && reply && !isShuttingDown()) {
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import { buildDurableMemorySection } from './discord/prompt-common.js';
19
19
  import { parseDiscordActions, executeDiscordActions, buildTieredDiscordActionsPromptSection, buildAllResultLines, appendActionResults } from './discord/actions.js';
20
20
  import { DiscordTransportClient } from './discord/transport-client.js';
21
21
  import { buildVoiceActionFlags } from './voice/voice-action-flags.js';
22
- import { loadVoiceIdentity, buildVoicePrompt, buildVoiceFollowUpPrompt, buildVoicePromptSectionEstimates } from './voice/voice-prompt-builder.js';
22
+ import { loadVoiceIdentity, buildVoicePrompt, buildVoiceFollowUpPrompt, buildVoicePromptSectionEstimates, buildVoiceSystemInstruction, } from './voice/voice-prompt-builder.js';
23
23
  import { sanitizeForVoice, sanitizeVoiceReplyForSpeech } from './voice/voice-sanitize.js';
24
24
  import { shouldTriggerFollowUp } from './discord/action-categories.js';
25
25
  import { configureDeferredScheduler } from './discord/deferred-runner.js';
@@ -2077,6 +2077,15 @@ if (taskCtx) {
2077
2077
  }
2078
2078
  };
2079
2079
  }
2080
+ const buildGeminiSystemInstruction = async () => {
2081
+ const identity = await loadVoiceIdentity(workspaceCwd);
2082
+ return buildVoiceSystemInstruction({
2083
+ identity,
2084
+ durableMemory: '',
2085
+ voiceSystemPrompt: cfg.voiceSystemPrompt,
2086
+ actionsSection: '',
2087
+ });
2088
+ };
2080
2089
  audioPipeline = new AudioPipelineManager({
2081
2090
  log,
2082
2091
  voiceConfig: {
@@ -2093,14 +2102,19 @@ if (taskCtx) {
2093
2102
  },
2094
2103
  allowedUserIds: allowUserIds,
2095
2104
  createDecoder: opusDecoderFactory,
2105
+ voiceProvider: cfg.voicePipelineProvider,
2106
+ geminiApiKey: cfg.geminiApiKey,
2107
+ enabledTools: runtimeTools,
2096
2108
  invokeAi: voiceInvokeAi,
2097
2109
  runtime: voiceRuntimeRef.runtime.id,
2098
2110
  runtimeModel: voiceModelRef.model,
2099
2111
  runtimeCwd: workspaceCwd,
2100
2112
  runtimeTimeoutMs,
2113
+ sessionRotationMs: cfg.geminiSessionRotationMs,
2101
2114
  transcriptMirror,
2102
2115
  botDisplayName,
2103
2116
  backfill,
2117
+ buildGeminiSystemInstruction,
2104
2118
  onTranscription: (guildId, result) => {
2105
2119
  if (result.isFinal && result.text.trim()) {
2106
2120
  log.info({ guildId, text: result.text, confidence: result.confidence }, 'voice:transcription');
@@ -88,6 +88,62 @@ export class TraceStore {
88
88
  .slice(0, limit)
89
89
  .map(cloneTrace);
90
90
  }
91
+ get size() {
92
+ return this.traces.size;
93
+ }
94
+ summary() {
95
+ const allTraces = [...this.traces.values()];
96
+ const flows = ['message', 'reaction', 'cron', 'defer'];
97
+ const byFlow = {};
98
+ for (const flow of flows) {
99
+ const matching = allTraces.filter((t) => t.flow === flow);
100
+ const completed = matching.filter((t) => t.outcome !== 'in_progress');
101
+ const succeeded = completed.filter((t) => t.outcome === 'success').length;
102
+ const totalDuration = completed.reduce((sum, t) => sum + t.durationMs, 0);
103
+ byFlow[flow] = {
104
+ total: matching.length,
105
+ succeeded,
106
+ failed: completed.length - succeeded,
107
+ inProgress: matching.length - completed.length,
108
+ avgDurationMs: completed.length > 0 ? Math.round(totalDuration / completed.length) : 0,
109
+ };
110
+ }
111
+ const byOutcome = {};
112
+ for (const trace of allTraces) {
113
+ byOutcome[trace.outcome] = (byOutcome[trace.outcome] ?? 0) + 1;
114
+ }
115
+ const MAX_RECENT_ERRORS = 10;
116
+ const recentErrors = [];
117
+ const sorted = [...allTraces].sort((a, b) => b.startedAt - a.startedAt);
118
+ for (const trace of sorted) {
119
+ if (recentErrors.length >= MAX_RECENT_ERRORS)
120
+ break;
121
+ if (trace.outcome === 'success' || trace.outcome === 'in_progress')
122
+ continue;
123
+ const lastError = [...trace.events].reverse().find((e) => e.type === 'error');
124
+ recentErrors.push({
125
+ traceId: trace.traceId,
126
+ flow: trace.flow,
127
+ message: lastError && 'message' in lastError ? lastError.message : trace.outcome,
128
+ at: trace.startedAt,
129
+ });
130
+ }
131
+ let oldestAt = null;
132
+ let newestAt = null;
133
+ if (allTraces.length > 0) {
134
+ oldestAt = Math.min(...allTraces.map((t) => t.startedAt));
135
+ newestAt = Math.max(...allTraces.map((t) => t.startedAt));
136
+ }
137
+ return {
138
+ total: allTraces.length,
139
+ inProgress: allTraces.filter((t) => t.outcome === 'in_progress').length,
140
+ oldestAt,
141
+ newestAt,
142
+ byFlow,
143
+ byOutcome,
144
+ recentErrors,
145
+ };
146
+ }
91
147
  makeRoomForNewTrace() {
92
148
  this.pruneToLimit(this.maxEntries - 1);
93
149
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared trace-event helpers for summarizing values before they enter trace events.
3
+ */
4
+ export function summarizeTraceText(value, maxChars = 160) {
5
+ const normalized = value.replace(/\s+/g, ' ').trim();
6
+ if (!normalized) {
7
+ return undefined;
8
+ }
9
+ if (normalized.length <= maxChars) {
10
+ return normalized;
11
+ }
12
+ return `${normalized.slice(0, Math.max(1, maxChars - 1))}…`;
13
+ }
14
+ export function summarizeTraceValue(value, maxChars = 160) {
15
+ if (value == null) {
16
+ return undefined;
17
+ }
18
+ if (typeof value === 'string') {
19
+ return summarizeTraceText(value, maxChars);
20
+ }
21
+ try {
22
+ const serialized = JSON.stringify(value);
23
+ if (serialized) {
24
+ return summarizeTraceText(serialized, maxChars);
25
+ }
26
+ }
27
+ catch {
28
+ // Fall through to String(value).
29
+ }
30
+ return summarizeTraceText(String(value), maxChars);
31
+ }
@@ -21,7 +21,8 @@ function mergeSystemPrompt(systemPrompt, appendSystemPrompt) {
21
21
  function normalizeInvokeParams(params, opts) {
22
22
  const requestedModel = params.model || opts.defaultModel;
23
23
  const remappedModel = remapCrossRuntimeTierModel(requestedModel, 'codex');
24
- const effectiveModel = remappedModel?.model ?? requestedModel;
24
+ const normalizedModel = remappedModel?.model ?? requestedModel;
25
+ const effectiveModel = normalizedModel || opts.defaultModel;
25
26
  const effectiveReasoningEffort = params.reasoningEffort
26
27
  ?? (remappedModel ? resolveReasoningEffort(remappedModel.sourceTier, 'codex') : undefined);
27
28
  if (remappedModel) {
@@ -34,7 +35,7 @@ function normalizeInvokeParams(params, opts) {
34
35
  }
35
36
  return {
36
37
  ...params,
37
- model: effectiveModel,
38
+ model: normalizedModel,
38
39
  ...(effectiveReasoningEffort ? { reasoningEffort: effectiveReasoningEffort } : {}),
39
40
  systemPrompt: mergeSystemPrompt(params.systemPrompt, opts.appendSystemPrompt),
40
41
  ...(opts.disableSessions ? { sessionKey: undefined } : {}),
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { CODEX_RUNTIME_CAPABILITIES } from './tool-capabilities.js';
5
+ import { initTierOverrides } from './model-tiers.js';
5
6
  const { mockExeca } = vi.hoisted(() => {
6
7
  return {
7
8
  mockExeca: vi.fn(),
@@ -129,10 +130,12 @@ describe('Codex CLI runtime adapter', () => {
129
130
  const originalStableHome = process.env.DISCOCLAW_CODEX_STABLE_HOME;
130
131
  beforeEach(() => {
131
132
  mockExeca.mockReset();
133
+ initTierOverrides({});
132
134
  delete process.env.DISCOCLAW_CLI_LAUNCHER_STATE_HARDENING;
133
135
  delete process.env.DISCOCLAW_CODEX_STABLE_HOME;
134
136
  });
135
137
  afterEach(() => {
138
+ initTierOverrides({});
136
139
  if (originalHardening === undefined) {
137
140
  delete process.env.DISCOCLAW_CLI_LAUNCHER_STATE_HARDENING;
138
141
  }
@@ -294,6 +297,36 @@ describe('Codex CLI runtime adapter', () => {
294
297
  sourceTier: 'fast',
295
298
  }), 'codex:model remapped to codex-compatible tier default');
296
299
  });
300
+ it('falls back to the codex default model when the codex fast tier is configured as adapter-default sentinel', async () => {
301
+ initTierOverrides({ DISCOCLAW_TIER_CODEX_FAST: '' });
302
+ const log = {
303
+ debug: vi.fn(),
304
+ warn: vi.fn(),
305
+ };
306
+ mockExeca.mockReturnValue(createMockSubprocess({
307
+ stdout: 'cli remap ok',
308
+ exitCode: 0,
309
+ }));
310
+ const rt = createCodexCliRuntime({
311
+ codexBin: 'codex',
312
+ defaultModel: 'gpt-5.4',
313
+ log,
314
+ });
315
+ await collectEvents(rt.invoke({
316
+ prompt: 'Summarize',
317
+ model: 'gpt-5-mini',
318
+ cwd: '/tmp/non-default-cwd',
319
+ }));
320
+ const callArgs = mockExeca.mock.calls[0][1];
321
+ const modelIdx = callArgs.indexOf('-m');
322
+ expect(callArgs[modelIdx + 1]).toBe('gpt-5.4');
323
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({
324
+ requestedModel: 'gpt-5-mini',
325
+ effectiveModel: 'gpt-5.4',
326
+ sourceRuntimeId: 'openai',
327
+ sourceTier: 'fast',
328
+ }), 'codex:model remapped to codex-compatible tier default');
329
+ });
297
330
  it('large prompt uses stdin instead of positional arg', async () => {
298
331
  const largePrompt = 'x'.repeat(200_000);
299
332
  mockExeca.mockReturnValue(createMockSubprocess({
@@ -168,7 +168,7 @@ export function remapCrossRuntimeTierModel(model, targetRuntimeId) {
168
168
  if (!sourceTier)
169
169
  return null;
170
170
  const targetModel = tierMap[targetRuntimeId]?.[sourceTier];
171
- if (!targetModel || normalizeModelLookup(targetModel) === normalized)
171
+ if (targetModel === undefined || normalizeModelLookup(targetModel) === normalized)
172
172
  return null;
173
173
  return {
174
174
  sourceRuntimeId,
@@ -238,6 +238,15 @@ describe('remapCrossRuntimeTierModel', () => {
238
238
  model: 'gpt-5.4',
239
239
  });
240
240
  });
241
+ it('preserves adapter-default sentinels when the target runtime tier is intentionally blank', () => {
242
+ initTierOverrides({ DISCOCLAW_TIER_CODEX_FAST: '' });
243
+ expect(remapCrossRuntimeTierModel('gpt-5-mini', 'codex')).toEqual({
244
+ sourceRuntimeId: 'openai',
245
+ sourceTier: 'fast',
246
+ targetRuntimeId: 'codex',
247
+ model: '',
248
+ });
249
+ });
241
250
  it('does not remap ambiguous cross-runtime models', () => {
242
251
  expect(remapCrossRuntimeTierModel('gpt-5.4', 'codex')).toBeNull();
243
252
  });
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * Maps internal tool names (Read, Write, …) to OpenAI function names
5
5
  * (read_file, write_file, …) and provides JSON Schema parameter definitions.
6
+ *
7
+ * Also provides `buildGeminiToolDeclarations()` to produce Gemini-compatible
8
+ * function declarations for the Multimodal Live API (Phase 2.1).
6
9
  */
10
+ import { toGeminiTools } from '../voice/providers/gemini-tool-mapper.js';
7
11
  /** Discoclaw tool name → OpenAI function names */
8
12
  const DISCO_TO_OPENAI_NAMES = {
9
13
  Read: ['read_file'],
@@ -369,3 +373,16 @@ export function buildToolSchemas(enabledTools) {
369
373
  }
370
374
  return schemas;
371
375
  }
376
+ /**
377
+ * Build Gemini-compatible function declarations for the given enabled tools.
378
+ *
379
+ * Composes `buildToolSchemas()` → `toGeminiTools()` so callers (e.g.
380
+ * AudioPipelineManager) can get Gemini Live–ready declarations in one call.
381
+ * Returns `undefined` when no tools match (same semantics as `toGeminiTools`).
382
+ */
383
+ export function buildGeminiToolDeclarations(enabledTools, opts = {}) {
384
+ const schemas = buildToolSchemas(enabledTools);
385
+ return toGeminiTools(schemas, opts.nonBlocking
386
+ ? { nonBlockingFunctionNames: schemas.map((schema) => schema.function.name) }
387
+ : undefined);
388
+ }