@ziggs-ai/agent-sdk 0.1.11 → 0.1.12

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.
@@ -37,6 +37,32 @@ export type McpTransportConfig =
37
37
  baseUrl?: string;
38
38
  /** Agent to impersonate (X-Agent-Id) — defaults to the host agentId. */
39
39
  agentId?: string;
40
+ }
41
+ | {
42
+ /**
43
+ * Per-user brokered connection (ZIG-771): the agent holds NO
44
+ * connectionId/grantId. It speaks StreamableHTTP to
45
+ * `/connections/mcp/relay` and asserts the invoking end-user via
46
+ * `x-on-behalf-of-user`; the gateway resolves that user's own connection
47
+ * + grant and injects their token. One MCP client is created per invoking
48
+ * user so each keeps an isolated upstream session. Tool schemas cannot be
49
+ * discovered at boot (there is no connection yet) — pass `toolSchema`.
50
+ */
51
+ type: 'ziggs-per-user';
52
+ /** Operator key — defaults to ZIGGS_OPERATOR_KEY. */
53
+ operatorKey?: string;
54
+ /** Ziggs backend base URL — defaults to BACKEND_URL / ZIGGS_BACKEND_URL. */
55
+ baseUrl?: string;
56
+ /** Agent to impersonate (X-Agent-Id) — defaults to the host agentId. */
57
+ agentId?: string;
58
+ /**
59
+ * The MCP server URL this agent fronts. Not used for gateway calls (the
60
+ * gateway derives the server from the resolved connection), but needed to
61
+ * open a connection request when the invoking user hasn't connected yet
62
+ * (ZIG-771). Without it, a NO_USER_CONNECTION surfaces as an error rather
63
+ * than an auto-created consent prompt.
64
+ */
65
+ serverUrl?: string;
40
66
  };
41
67
 
42
68
  /**
@@ -123,6 +149,43 @@ export function resolveZiggsGatewayTarget(
123
149
  };
124
150
  }
125
151
 
152
+ /**
153
+ * Resolve the per-user gateway URL + headers (ZIG-771). No connectionId/grantId —
154
+ * the gateway resolves the invoking user's own connection + grant from
155
+ * `x-on-behalf-of-user` + the impersonated agent.
156
+ */
157
+ export function resolvePerUserGatewayTarget(
158
+ config: Extract<McpTransportConfig, { type: 'ziggs-per-user' }>,
159
+ agentId: string,
160
+ userId: string,
161
+ onBehalfOfAgreementId?: string,
162
+ ): { url: URL; headers: Record<string, string> } {
163
+ const operatorKey = config.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
164
+ if (!operatorKey) {
165
+ throw new Error(
166
+ 'ziggs-per-user MCP transport requires an operatorKey (or ZIGGS_OPERATOR_KEY)',
167
+ );
168
+ }
169
+ const baseUrl = (
170
+ config.baseUrl ||
171
+ process.env.BACKEND_URL ||
172
+ process.env.ZIGGS_BACKEND_URL ||
173
+ 'http://localhost:3000'
174
+ ).replace(/\/$/, '');
175
+ return {
176
+ url: new URL(`${baseUrl}/connections/mcp/relay`),
177
+ headers: {
178
+ authorization: `Bearer ${operatorKey}`,
179
+ 'x-agent-id': agentId,
180
+ 'x-on-behalf-of-user': userId,
181
+ // ZIG-831: when this call runs under a delegation, userId is the delegating
182
+ // agent (senderId), not the end-user who owns the connection. The gateway
183
+ // uses this agreement to resolve the delegation root's user.
184
+ ...(onBehalfOfAgreementId ? { 'x-on-behalf-of-agreement': onBehalfOfAgreementId } : {}),
185
+ },
186
+ };
187
+ }
188
+
126
189
  export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' | 'workflow' | 'states' | 'initial'> {
127
190
  /** MCP server connection — stdio (local process) or HTTP (remote). */
128
191
  transport: McpTransportConfig;
@@ -138,6 +201,26 @@ export interface CreateMcpAgentOptions extends Omit<AgentHostOptions, 'tools' |
138
201
  * See ZIG-759; Phase 2 will replace truncation with query-aware selection.
139
202
  */
140
203
  maxTools?: number;
204
+ /**
205
+ * Restrict the bound tool set to only tools whose name matches one of these
206
+ * case-insensitive substrings, applied BEFORE the maxTools cap. Lets a large
207
+ * MCP server (e.g. PostHog's 146 tools) be narrowed to the handful this agent
208
+ * actually needs — cutting per-turn cost and stopping the model from thrashing
209
+ * across (or never binding) the tool it should call. Empty/undefined = bind
210
+ * all (unchanged behavior). ZIG-759 Phase 2 = name-based selection.
211
+ */
212
+ allowedTools?: string[];
213
+ /**
214
+ * Pre-discovered MCP tool schemas. REQUIRED for `type: 'ziggs-per-user'`
215
+ * (ZIG-771) — there is no boot connection to call `tools/list` on, so the
216
+ * schema is baked at provision time and passed here. Ignored for the other
217
+ * transports, which discover tools live at boot.
218
+ */
219
+ toolSchema?: Array<{
220
+ name: string;
221
+ description?: string;
222
+ inputSchema?: Record<string, unknown>;
223
+ }>;
141
224
  }
142
225
 
143
226
  /** Provider `tools` array cap (OpenAI's is 128). */
@@ -177,6 +260,11 @@ export function buildTransport(config: McpTransportConfig, defaults: { agentId?:
177
260
  fetch: wrapGatewayFetch(),
178
261
  });
179
262
  }
263
+ if (config.type === 'ziggs-per-user') {
264
+ // Per-user transports are built lazily per invoking user in createMcpAgent
265
+ // (each needs a different x-on-behalf-of-user header), not here.
266
+ throw new Error('ziggs-per-user transport is created per invoking user, not via buildTransport');
267
+ }
180
268
  return new StreamableHTTPClientTransport(
181
269
  new URL(config.url),
182
270
  config.headers && Object.keys(config.headers).length > 0
@@ -204,7 +292,17 @@ function mcpContentToResult(content: McpContent[]): unknown {
204
292
  }
205
293
  }
206
294
 
207
- function mapMcpTools(client: Client, tools: McpTool[]): ToolDefinition[] {
295
+ /**
296
+ * Calls one MCP tool. The single-client path closes over a fixed client; the
297
+ * per-user path (ZIG-771) uses `context.senderId` to pick that user's client.
298
+ */
299
+ type McpToolCaller = (
300
+ toolName: string,
301
+ args: Record<string, unknown>,
302
+ context: Record<string, unknown>,
303
+ ) => Promise<{ isError?: boolean; content: McpContent[] }>;
304
+
305
+ function mapMcpTools(callTool: McpToolCaller, tools: McpTool[]): ToolDefinition[] {
208
306
  return tools.map(tool => ({
209
307
  schema: {
210
308
  type: 'function' as const,
@@ -214,11 +312,11 @@ function mapMcpTools(client: Client, tools: McpTool[]): ToolDefinition[] {
214
312
  parameters: tool.inputSchema ?? { type: 'object', properties: {} },
215
313
  },
216
314
  },
217
- handler: async (args: Record<string, unknown>) => {
218
- const result = await client.callTool({ name: tool.name, arguments: args }) as {
219
- isError?: boolean;
220
- content: McpContent[];
221
- };
315
+ handler: async (
316
+ args: Record<string, unknown>,
317
+ context: Record<string, unknown>,
318
+ ) => {
319
+ const result = await callTool(tool.name, args, context);
222
320
  if (result.isError) {
223
321
  throw new Error(mcpContentToResult(result.content) as string);
224
322
  }
@@ -279,6 +377,90 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
279
377
  };
280
378
  }
281
379
 
380
+ /**
381
+ * Max chars for a single tool description bound to the LLM. Verbose MCP servers
382
+ * embed whole manuals in their descriptions (PostHog's `execute-sql` alone is
383
+ * ~30K chars of HogQL docs; its 146 tools total ~54K tokens of descriptions) and
384
+ * that full block rides every turn. Keep the lead — what the tool does + how to
385
+ * call it — and drop the embedded reference; agents fetch detail via dedicated
386
+ * schema/help tools at call time. Tunable; big enough to preserve real guidance.
387
+ * ZIG-759.
388
+ */
389
+ export const MAX_TOOL_DESC_CHARS = 2000;
390
+
391
+ /** Truncate one description at a sentence/newline boundary near the cap. */
392
+ export function trimDescription(desc: string | undefined): string | undefined {
393
+ if (!desc || desc.length <= MAX_TOOL_DESC_CHARS) return desc;
394
+ const slice = desc.slice(0, MAX_TOOL_DESC_CHARS);
395
+ const boundary = Math.max(slice.lastIndexOf('\n'), slice.lastIndexOf('. '));
396
+ const head = boundary > MAX_TOOL_DESC_CHARS * 0.6 ? slice.slice(0, boundary + 1) : slice;
397
+ return `${head.trimEnd()} […description truncated; use the server's schema/help tools for full detail]`;
398
+ }
399
+
400
+ /** Trim oversized descriptions across the tool set (cost control, ZIG-759). */
401
+ function trimToolDescriptions(tools: McpTool[], agentId: string | undefined): McpTool[] {
402
+ let trimmedCount = 0;
403
+ let savedChars = 0;
404
+ const out = tools.map((t) => {
405
+ const nextDesc = trimDescription(t.description);
406
+ if (nextDesc === t.description) return t;
407
+ trimmedCount += 1;
408
+ savedChars += (t.description?.length ?? 0) - (nextDesc?.length ?? 0);
409
+ return { ...t, description: nextDesc };
410
+ });
411
+ if (trimmedCount > 0) {
412
+ console.warn(
413
+ `[McpAdapter] ${agentId}: trimmed ${trimmedCount} oversized tool description(s) to ${MAX_TOOL_DESC_CHARS} chars, saving ~${Math.round(savedChars / 4 / 1000)}K tokens/turn. (ZIG-759)`,
414
+ );
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /**
420
+ * Keep only tools whose name matches one of the allowlist substrings
421
+ * (case-insensitive). No allowlist → pass through unchanged. Applied before the
422
+ * maxTools cap so a targeted agent binds exactly the tools it needs. ZIG-759.
423
+ */
424
+ function filterToolsByAllowlist(
425
+ tools: McpTool[],
426
+ allowedTools: string[] | undefined,
427
+ agentId: string | undefined,
428
+ ): McpTool[] {
429
+ if (!allowedTools || allowedTools.length === 0) return tools;
430
+ const patterns = allowedTools.map((p) => p.toLowerCase());
431
+ const kept = tools.filter((t) =>
432
+ patterns.some((p) => t.name.toLowerCase().includes(p)),
433
+ );
434
+ console.warn(
435
+ `[McpAdapter] ${agentId}: tool allowlist active — bound ${kept.length}/${tools.length} tools matching [${allowedTools.join(', ')}]${kept.length === 0 ? ' ⚠️ NONE MATCHED — check patterns against the server\'s tool names' : ': ' + kept.map((t) => t.name).join(', ')}.`,
436
+ );
437
+ return kept;
438
+ }
439
+
440
+ /**
441
+ * Cap the tool set bound to the LLM at the provider limit (OpenAI 128) —
442
+ * truncate and log rather than crash — and trim oversized descriptions so the
443
+ * bound schema doesn't ship a manual every turn. ZIG-759 Phase 1. Shared by the
444
+ * boot-time (single-client) and per-user paths. When `allowedTools` is set the
445
+ * server's tools are narrowed by name first, so the cap rarely bites.
446
+ */
447
+ function capTools(
448
+ tools: McpTool[],
449
+ agentId: string | undefined,
450
+ maxTools: number,
451
+ extraToolCount: number,
452
+ allowedTools?: string[],
453
+ ): McpTool[] {
454
+ const allowed = filterToolsByAllowlist(tools, allowedTools, agentId);
455
+ const trimmed = trimToolDescriptions(allowed, agentId);
456
+ const budget = mcpToolBudget(maxTools, extraToolCount);
457
+ if (trimmed.length <= budget) return trimmed;
458
+ console.warn(
459
+ `[McpAdapter] ${agentId}: MCP server exposes ${trimmed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${budget}, dropping ${trimmed.length - budget}. (ZIG-759 Phase 2 will select per query.)`,
460
+ );
461
+ return trimmed.slice(0, budget);
462
+ }
463
+
282
464
  /**
283
465
  * Wraps any MCP server as a Ziggs AgentHost.
284
466
  *
@@ -313,27 +495,175 @@ function buildDefaultWorkflow(description: string, mcpTools: McpTool[]): Workflo
313
495
  * });
314
496
  */
315
497
  export async function createMcpAgent(options: CreateMcpAgentOptions): Promise<McpAgentHandle> {
316
- const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, ...hostOptions } = options;
498
+ const { transport: transportConfig, extraTools = [], workflow, maxTools = DEFAULT_MAX_TOOLS, allowedTools, toolSchema, ...hostOptions } = options;
499
+
500
+ // ── Per-user path (ZIG-771): no boot connection; one MCP client per invoking
501
+ // user, each carrying that user's on-behalf-of header for an isolated session.
502
+ if (transportConfig.type === 'ziggs-per-user') {
503
+ const agentId = transportConfig.agentId || hostOptions.agentId;
504
+ if (!agentId) {
505
+ throw new Error('ziggs-per-user MCP transport requires an agentId to impersonate');
506
+ }
507
+ if (!toolSchema || toolSchema.length === 0) {
508
+ throw new Error('ziggs-per-user MCP transport requires toolSchema (no boot connection to discover tools)');
509
+ }
510
+ const tools = capTools(toolSchema as McpTool[], agentId, maxTools, extraTools.length, allowedTools);
317
511
 
512
+ const clients = new Map<string, Promise<Client>>();
513
+ const clientFor = (
514
+ userId: string,
515
+ onBehalfOfAgreementId?: string,
516
+ ): Promise<Client> => {
517
+ // Key by user + agreement: the gateway may resolve a different end-user
518
+ // per agreement (delegation), so sessions must not be shared across them.
519
+ const cacheKey = `${userId}|${onBehalfOfAgreementId ?? ''}`;
520
+ let p = clients.get(cacheKey);
521
+ if (!p) {
522
+ p = (async () => {
523
+ const target = resolvePerUserGatewayTarget(transportConfig, agentId, userId, onBehalfOfAgreementId);
524
+ const transport = new StreamableHTTPClientTransport(target.url, {
525
+ requestInit: { headers: target.headers },
526
+ fetch: wrapGatewayFetch(),
527
+ });
528
+ const c = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
529
+ await c.connect(transport);
530
+ return c;
531
+ })().catch((err: unknown) => {
532
+ // Don't cache a failed connect (e.g. NO_USER_CONNECTION before the
533
+ // user consents) — the next turn must be able to retry.
534
+ clients.delete(cacheKey);
535
+ throw err;
536
+ });
537
+ clients.set(cacheKey, p);
538
+ }
539
+ return p;
540
+ };
541
+
542
+ // When the invoking user hasn't connected this server, open a connection
543
+ // request targeting THEM (X-On-Behalf-Of-User) so the consent prompt lands
544
+ // in their inbox. The backend dedups pending requests, so retrying every
545
+ // turn is safe. ZIG-771.
546
+ const backendBase = (
547
+ transportConfig.baseUrl ||
548
+ process.env.BACKEND_URL ||
549
+ process.env.ZIGGS_BACKEND_URL ||
550
+ 'http://localhost:3000'
551
+ ).replace(/\/$/, '');
552
+ // App URL for the user-facing connect link (ZIG-790): api.ziggsai.com → ziggsai.com.
553
+ const appUrl = backendBase.includes('localhost')
554
+ ? 'http://localhost:1234'
555
+ : backendBase.replace(/\/\/api\./, '//');
556
+ const serverLabel = transportConfig.serverUrl
557
+ ? new URL(transportConfig.serverUrl).host
558
+ : 'the external service';
559
+ // Open a connection request for this user; returns its id so the caller can
560
+ // deep-link the user straight to it on the Connections page (ZIG-790).
561
+ const triggerConnectionRequest = async (userId: string): Promise<string | null> => {
562
+ if (!transportConfig.serverUrl) return null;
563
+ const operatorKey = transportConfig.operatorKey || process.env.ZIGGS_OPERATOR_KEY;
564
+ const res = await fetch(`${backendBase}/connections/mcp/requests`, {
565
+ method: 'POST',
566
+ headers: {
567
+ 'content-type': 'application/json',
568
+ authorization: `Bearer ${operatorKey}`,
569
+ 'x-agent-id': agentId,
570
+ 'x-on-behalf-of-user': userId,
571
+ },
572
+ body: JSON.stringify({
573
+ serverUrl: transportConfig.serverUrl,
574
+ tools: tools.map(t => t.name),
575
+ }),
576
+ });
577
+ const body = (await res.json().catch(() => null)) as { request?: { requestId?: string } } | null;
578
+ return body?.request?.requestId ?? null;
579
+ };
580
+
581
+ const caller: McpToolCaller = async (name, args, context) => {
582
+ const userId = typeof context.senderId === 'string' ? context.senderId : undefined;
583
+ if (!userId) {
584
+ throw new Error('per-user MCP tool call requires an invoking user (context.senderId is missing)');
585
+ }
586
+ // ZIG-831: for a delegated wake, senderId is the delegating agent, not the
587
+ // connection-owning end-user; pass the agreement so the gateway resolves
588
+ // the delegation root's user.
589
+ const onBehalfOfAgreementId =
590
+ typeof context.onBehalfOfAgreementId === 'string' ? context.onBehalfOfAgreementId : undefined;
591
+ // ZIG-832 (B): per-user resolution is intermittently flaky even when the
592
+ // connection is valid and resolvable — the same on-behalf call fails with
593
+ // NO_USER_CONNECTION on one wake and succeeds on the next. clientFor never
594
+ // caches a failed connect, so each attempt re-resolves from scratch. Retry
595
+ // a few times (with backoff) before surfacing the connect prompt, so a
596
+ // transient miss no longer fails the whole fetch step.
597
+ const MAX_ATTEMPTS = 3;
598
+ let lastErr: unknown;
599
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
600
+ try {
601
+ const c = await clientFor(userId, onBehalfOfAgreementId);
602
+ return (await c.callTool({ name, arguments: args })) as {
603
+ isError?: boolean;
604
+ content: McpContent[];
605
+ };
606
+ } catch (err) {
607
+ lastErr = err;
608
+ const resolutionMiss =
609
+ err instanceof ZiggsGatewayError && err.code === 'NO_USER_CONNECTION';
610
+ const transient = resolutionMiss || !(err instanceof ZiggsGatewayError);
611
+ if (attempt < MAX_ATTEMPTS && transient) {
612
+ console.warn(
613
+ `[McpAdapter] per-user ${name} attempt ${attempt}/${MAX_ATTEMPTS} failed (${(err as Error).message}); retrying with a fresh connection`,
614
+ );
615
+ await new Promise((r) => setTimeout(r, 500 * attempt));
616
+ continue;
617
+ }
618
+ break;
619
+ }
620
+ }
621
+ if (
622
+ lastErr instanceof ZiggsGatewayError &&
623
+ lastErr.code === 'NO_USER_CONNECTION' &&
624
+ transportConfig.serverUrl
625
+ ) {
626
+ const requestId = await triggerConnectionRequest(userId).catch(() => null);
627
+ const link = `${appUrl}/app/settings/connections${requestId ? `?request=${encodeURIComponent(requestId)}` : ''}`;
628
+ return {
629
+ content: [
630
+ {
631
+ type: 'text',
632
+ text: `ACTION NEEDED: the user has not connected ${serverLabel}. Ask them to approve the connection here: ${link} — then retry.`,
633
+ },
634
+ ],
635
+ };
636
+ }
637
+ throw lastErr;
638
+ };
639
+
640
+ const host = new AgentHost({
641
+ ...hostOptions,
642
+ workflow: workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', tools),
643
+ tools: [...mapMcpTools(caller, tools), ...extraTools],
644
+ });
645
+ return {
646
+ host,
647
+ closeMcp: async () => {
648
+ await Promise.all(
649
+ [...clients.values()].map(p => p.then(c => c.close()).catch(() => {})),
650
+ );
651
+ },
652
+ toolNames: tools.map(t => t.name),
653
+ };
654
+ }
655
+
656
+ // ── Single-client path (stdio / http / brokered ziggs): discover tools at boot.
318
657
  const transport = buildTransport(transportConfig, { agentId: hostOptions.agentId });
319
658
  const client = new Client({ name: 'ziggs-mcp-agent', version: '1.0.0' });
320
-
321
659
  await client.connect(transport);
322
660
 
323
- const listed = (await client.listTools()).tools as McpTool[];
324
- // Cap the tool set bound to the LLM: providers reject a `tools` array longer
325
- // than their limit (OpenAI 128). Truncate — never crash — and log what dropped
326
- // so a large server (e.g. PostHog: 146 tools) still answers. ZIG-759 Phase 1.
327
- const budget = mcpToolBudget(maxTools, extraTools.length);
328
- let rawTools = listed;
329
- if (listed.length > budget) {
330
- rawTools = listed.slice(0, budget);
331
- console.warn(`[McpAdapter] ${hostOptions.agentId}: MCP server exposes ${listed.length} tools, over the ${maxTools}-tool LLM cap; binding the first ${rawTools.length}, dropping ${listed.length - rawTools.length}. (ZIG-759 Phase 2 will select per query.)`);
332
- }
333
- const mcpTools = mapMcpTools(client, rawTools);
661
+ const rawTools = capTools((await client.listTools()).tools as McpTool[], hostOptions.agentId, maxTools, extraTools.length, allowedTools);
662
+ const caller: McpToolCaller = (name, args) =>
663
+ client.callTool({ name, arguments: args }) as Promise<{ isError?: boolean; content: McpContent[] }>;
664
+ const mcpTools = mapMcpTools(caller, rawTools);
334
665
 
335
666
  const resolvedWorkflow = workflow ?? buildDefaultWorkflow(hostOptions.description ?? '', rawTools);
336
-
337
667
  const host = new AgentHost({
338
668
  ...hostOptions,
339
669
  workflow: resolvedWorkflow,
@@ -167,21 +167,28 @@ export class OpenAIAdapter {
167
167
  async chatMessagesStream(
168
168
  messages: OpenAI.Chat.ChatCompletionMessageParam[],
169
169
  onChunk: (text: string) => void,
170
- ): Promise<string> {
170
+ ): Promise<{ text: string; usage: OpenAI.CompletionUsage | undefined }> {
171
171
  const stream = await this.client.chat.completions.create({
172
172
  model: this.model,
173
173
  messages,
174
174
  stream: true,
175
+ // Without this, a streamed completion never returns a usage block — the
176
+ // streamed reply (often the largest output) would bill tokens that are
177
+ // counted nowhere (ZIG-813). The usage arrives on a final chunk whose
178
+ // `choices` is empty.
179
+ stream_options: { include_usage: true },
175
180
  max_completion_tokens: maxCompletionTokensForModel(this.model),
176
181
  });
177
182
  let text = '';
183
+ let usage: OpenAI.CompletionUsage | undefined;
178
184
  for await (const chunk of stream) {
185
+ if (chunk.usage) usage = chunk.usage;
179
186
  const delta = chunk.choices[0]?.delta?.content;
180
187
  if (delta) {
181
188
  text += delta;
182
189
  onChunk(delta);
183
190
  }
184
191
  }
185
- return text;
192
+ return { text, usage };
186
193
  }
187
194
  }
@@ -42,8 +42,16 @@ export interface TickOutput {
42
42
  parked: boolean;
43
43
  /** True when a state-timeout outcome matched no transition this tick — the host must NOT re-arm. */
44
44
  stateTimeoutUnhandled: boolean;
45
- /** Accumulated LLM call count and token usage for this tick. */
46
- metrics: { llmCalls: number; tokens: { total: number; input: number; output: number } };
45
+ /**
46
+ * Accumulated LLM call count and token usage for this tick.
47
+ * `cacheRead` / `cacheCreation` are populated by the Claude brain (its usage
48
+ * exposes cache traffic, which is the bulk of real cost — ZIG-759/813); the
49
+ * FSM path leaves them undefined. `total` includes cache tokens when present.
50
+ */
51
+ metrics: {
52
+ llmCalls: number;
53
+ tokens: { total: number; input: number; output: number; cacheRead?: number; cacheCreation?: number };
54
+ };
47
55
  }
48
56
 
49
57
  /**