melchizedek-agents 0.9.2 → 0.9.4

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 (39) hide show
  1. package/AGENT_SETUP.md +2 -2
  2. package/DOCUMENTATION.md +17 -24
  3. package/QUICKSTART.md +3 -2
  4. package/README.md +11 -9
  5. package/config/agents/examples/ares.yaml +0 -2
  6. package/config/agents/examples/augustin.yaml +0 -2
  7. package/config/agents/examples/cartographers.yaml +2 -5
  8. package/config/agents/examples/council.yaml +1 -1
  9. package/config/agents/examples/image_production.yaml +0 -1
  10. package/config/agents/examples/librarian.yaml +2 -4
  11. package/config/agents/examples/patient_advocate.yaml +2 -4
  12. package/config/agents/examples/scriptorium.yaml +2 -5
  13. package/config/agents/examples/style_council.yaml +2 -6
  14. package/config/agents/examples/syndicate.yaml +0 -3
  15. package/config/agents/examples/tutor.yaml +62 -32
  16. package/config/agents/syndicateSchema.yaml +4 -2
  17. package/dist/lib/compile.d.ts +64 -0
  18. package/dist/lib/compile.js +105 -0
  19. package/dist/lib/models/claudeLlm.js +34 -4
  20. package/dist/lib/models/gptLlm.js +6 -3
  21. package/dist/lib/models/ollamaLlm.d.ts +2 -0
  22. package/dist/lib/models/ollamaLlm.js +5 -1
  23. package/dist/lib/models/openAiCompatibleLlm.d.ts +49 -0
  24. package/dist/lib/models/openAiCompatibleLlm.js +233 -9
  25. package/dist/lib/models/registry.js +1 -1
  26. package/dist/lib/models/schemaNormalize.d.ts +9 -0
  27. package/dist/lib/models/schemaNormalize.js +34 -0
  28. package/dist/lib/observability/embeddings.d.ts +21 -0
  29. package/dist/lib/observability/embeddings.js +47 -0
  30. package/dist/lib/observability/lineage.d.ts +35 -0
  31. package/dist/lib/observability/lineage.js +96 -0
  32. package/dist/lib/observability/supabaseSpanExporter.d.ts +191 -18
  33. package/dist/lib/observability/supabaseSpanExporter.js +388 -80
  34. package/dist/lib/observability/tracer.d.ts +82 -0
  35. package/dist/lib/observability/tracer.js +348 -4
  36. package/dist/scripts/a2a_server.d.ts +10 -0
  37. package/dist/scripts/a2a_server.js +132 -75
  38. package/dist/scripts/syndicate_chat.js +41 -10
  39. package/package.json +8 -4
@@ -1,4 +1,6 @@
1
1
  import { trace, context } from '@opentelemetry/api';
2
+ import { createRequire } from 'node:module';
3
+ import { TELEMETRY_SCHEMA_VERSION, engineVersion } from "./lineage.js";
2
4
  import sdkNode from '@opentelemetry/sdk-trace-node';
3
5
  const { NodeTracerProvider } = sdkNode;
4
6
  import sdkBase from '@opentelemetry/sdk-trace-base';
@@ -6,6 +8,38 @@ const { SimpleSpanProcessor } = sdkBase;
6
8
  import core from '@opentelemetry/core';
7
9
  const { ExportResultCode } = core;
8
10
  import { SupabaseSpanExporter, telemetrySinkEnabled, } from "./supabaseSpanExporter.js";
11
+ /**
12
+ * Optional OTLP export for a live trace viewer (Phoenix, Langfuse, Grafana
13
+ * Tempo, Jaeger — anything that speaks OTLP/HTTP). Set
14
+ * OTEL_EXPORTER_OTLP_ENDPOINT (e.g. http://localhost:6006 for Phoenix) and
15
+ * every span — ADK's included, so the viewer gets the waterfall — is also
16
+ * sent there. OTEL_EXPORTER_OTLP_HEADERS is honoured by the exporter itself
17
+ * (api keys); OTEL_SERVICE_NAME names the service (default "melchizedek").
18
+ * Supabase stays the system of record; this is a viewer.
19
+ */
20
+ export function otlpEndpoint() {
21
+ const raw = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
22
+ if (!raw)
23
+ return undefined;
24
+ return /\/v1\/traces\/?$/.test(raw) ? raw : `${raw.replace(/\/$/, '')}/v1/traces`;
25
+ }
26
+ function otlpProcessor() {
27
+ const url = otlpEndpoint();
28
+ if (!url)
29
+ return undefined;
30
+ try {
31
+ // Synchronous load: NodeTracerProvider wants its processors at construction.
32
+ const mod = createRequire(import.meta.url)('@opentelemetry/exporter-trace-otlp-http');
33
+ const OTLPTraceExporter = mod.OTLPTraceExporter ?? mod.default?.OTLPTraceExporter;
34
+ const { BatchSpanProcessor } = sdkBase;
35
+ console.log(`[TELEMETRY] OTLP export enabled → ${url}`);
36
+ return new BatchSpanProcessor(new OTLPTraceExporter({ url }));
37
+ }
38
+ catch (err) {
39
+ console.warn(`[TELEMETRY] OTLP exporter unavailable (${err instanceof Error ? err.message : err}); spans not sent to ${url}`);
40
+ return undefined;
41
+ }
42
+ }
9
43
  let isOtelInitialized = false;
10
44
  let tracerProvider;
11
45
  let telemetryExporter;
@@ -27,6 +61,24 @@ export function onSpanEnd(listener) {
27
61
  spanEndListeners.add(listener);
28
62
  return () => spanEndListeners.delete(listener);
29
63
  }
64
+ // ADK's own spans (scope "gcp.vertex.agent": invocation, invoke_agent,
65
+ // call_llm, execute_tool) carry the FULL model request and response as
66
+ // attributes. They reach the in-process listeners — that is how per-agent
67
+ // attribution and the observatory work — but are not printed unless asked:
68
+ // on a server they would put every prompt into the log stream.
69
+ const ADK_SCOPE = 'gcp.vertex.agent';
70
+ const PRINT_ALL_SPANS = process.env.OTEL_CONSOLE_ALL_SPANS === 'true';
71
+ // OTEL_CONSOLE_SPANS=false silences the [OTEL_SPAN_JSON] lines entirely.
72
+ // The in-process listeners below still fire and the Supabase sink still
73
+ // records, so this costs no telemetry — it only unclutters a chat session.
74
+ // Read at export time rather than module load, so an entry point can set
75
+ // its own default in main() (syndicate_chat.ts defaults to silent) after
76
+ // loadEnv() has run and before the first span ends.
77
+ const printConsoleSpans = () => process.env.OTEL_CONSOLE_SPANS !== 'false';
78
+ function spanScopeName(span) {
79
+ const s = span;
80
+ return s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? '';
81
+ }
30
82
  class JsonConsoleExporter {
31
83
  export(spans, resultCallback) {
32
84
  for (const span of spans) {
@@ -38,6 +90,10 @@ class JsonConsoleExporter {
38
90
  /* a listener bug must not break the export pipeline */
39
91
  }
40
92
  }
93
+ if (!printConsoleSpans())
94
+ continue;
95
+ if (!PRINT_ALL_SPANS && spanScopeName(span) === ADK_SCOPE)
96
+ continue;
41
97
  // Create a clean JSON representation of the span
42
98
  const jsonSpan = {
43
99
  traceId: span.spanContext().traceId,
@@ -54,7 +110,7 @@ class JsonConsoleExporter {
54
110
  })),
55
111
  status: span.status,
56
112
  };
57
- // Print to stdout with a specific prefix so test_local.py can easily extract it
113
+ // Print to stdout with a fixed prefix so a wrapper can extract spans from a transcript
58
114
  console.log(`[OTEL_SPAN_JSON] ${JSON.stringify(jsonSpan)}`);
59
115
  }
60
116
  resultCallback({ code: ExportResultCode.SUCCESS });
@@ -63,6 +119,131 @@ class JsonConsoleExporter {
63
119
  return Promise.resolve();
64
120
  }
65
121
  }
122
+ // ── Span lineage (per-agent attribution) ─────────────────────────────────────
123
+ // ADK wraps every agent turn in an `invoke_agent <name>` span and every model
124
+ // call in a `call_llm` child of it; our llm.request span is a child of THAT.
125
+ // A span cannot read its parent's name through the OTEL API, so a processor
126
+ // records each span's name and parent at start, and llm.request walks up the
127
+ // chain to find the agent it belongs to. This is what fills the `agent`
128
+ // column of adk_telemetry — per-subagent cost and latency attribution — and
129
+ // what lets the observatory say which agent made which call.
130
+ const spanLineage = new Map();
131
+ const SPAN_LINEAGE_CAP = 10_000;
132
+ const traceStats = new Map();
133
+ const TRACE_STATS_TTL_MS = 60 * 60 * 1000;
134
+ function statsFor(traceId) {
135
+ let s = traceStats.get(traceId);
136
+ if (!s) {
137
+ s = { modelMs: 0, toolMs: 0, llmCalls: 0, models: new Set(), startedAt: Date.now() };
138
+ traceStats.set(traceId, s);
139
+ if (traceStats.size > 2_000) {
140
+ const cutoff = Date.now() - TRACE_STATS_TTL_MS;
141
+ for (const [id, st] of traceStats)
142
+ if (st.startedAt < cutoff)
143
+ traceStats.delete(id);
144
+ }
145
+ }
146
+ return s;
147
+ }
148
+ /** Read-and-forget the stats for a finished trace. */
149
+ export function takeTraceStats(traceId) {
150
+ const s = traceStats.get(traceId);
151
+ traceStats.delete(traceId);
152
+ return s;
153
+ }
154
+ function spanDurationMs(span) {
155
+ return span.duration[0] * 1000 + span.duration[1] / 1e6;
156
+ }
157
+ class SpanLineageProcessor {
158
+ onStart(span) {
159
+ const ctx = span.spanContext();
160
+ const parent = span.parentSpanContext?.spanId ?? span.parentSpanId ?? undefined;
161
+ spanLineage.set(ctx.spanId, { name: span.name, parent });
162
+ // ADK's call_llm span (the one carrying the full request/response
163
+ // payloads) learns its agent the same way llm.request does, so a
164
+ // payload row can be attributed without re-walking the tree later.
165
+ if (span.name === 'call_llm') {
166
+ const agent = agentForSpan(parent);
167
+ if (agent)
168
+ span.setAttribute('llm.agent', agent);
169
+ }
170
+ if (spanLineage.size > SPAN_LINEAGE_CAP) {
171
+ // A runaway process must never grow this without bound; parents end
172
+ // after their children, so evicting the oldest entry is safe.
173
+ const oldest = spanLineage.keys().next().value;
174
+ if (oldest !== undefined)
175
+ spanLineage.delete(oldest);
176
+ }
177
+ }
178
+ onEnd(span) {
179
+ spanLineage.delete(span.spanContext().spanId);
180
+ const traceId = span.spanContext().traceId;
181
+ if (span.name === 'llm.request') {
182
+ const s = statsFor(traceId);
183
+ s.modelMs += spanDurationMs(span);
184
+ s.llmCalls += 1;
185
+ const model = span.attributes['llm.model'];
186
+ if (typeof model === 'string' && model)
187
+ s.models.add(model);
188
+ }
189
+ else if (span.name.startsWith('execute_tool ')) {
190
+ statsFor(traceId).toolMs += spanDurationMs(span);
191
+ }
192
+ }
193
+ shutdown() { return Promise.resolve(); }
194
+ forceFlush() { return Promise.resolve(); }
195
+ }
196
+ /** Name of the nearest enclosing ADK `invoke_agent` span, or null. */
197
+ export function agentForSpan(spanId) {
198
+ let cursor = spanId;
199
+ for (let depth = 0; cursor && depth < 12; depth++) {
200
+ const entry = spanLineage.get(cursor);
201
+ if (!entry)
202
+ return null;
203
+ if (entry.name.startsWith('invoke_agent '))
204
+ return entry.name.slice('invoke_agent '.length);
205
+ cursor = entry.parent;
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * Hands the provider to ADK's private copy of @opentelemetry/api.
211
+ *
212
+ * WHY: @google/adk pins its own @opentelemetry/api (nested under its
213
+ * node_modules) and creates its tracer at module-import time —
214
+ * `trace.getTracer("gcp.vertex.agent")` in telemetry/tracing.js. That call
215
+ * runs BEFORE any entrypoint can register a provider, so it binds to that
216
+ * copy's ProxyTracerProvider, whose delegate is only ever set by a
217
+ * `setGlobalTracerProvider` call made through the SAME copy. Ours goes
218
+ * through the top-level copy; the global registration is shared (it lives
219
+ * on a well-known Symbol), but the proxy's delegate is not. Result: every
220
+ * ADK span — invoke_agent, call_llm, execute_tool — was a no-op, and
221
+ * llm.request spans had no parent. Setting the delegate on ADK's proxy
222
+ * fixes both: ADK spans now export, and the context manager (global,
223
+ * shared) threads our llm.request spans under ADK's call_llm.
224
+ *
225
+ * `_proxyTracerProvider` is a private field of TraceAPI; this is the only
226
+ * place that touches it, it is guarded, and a failure just means ADK spans
227
+ * stay invisible — as they were.
228
+ */
229
+ function adoptAdkTracerApi(provider) {
230
+ try {
231
+ const requireHere = createRequire(import.meta.url);
232
+ const adkEntry = requireHere.resolve('@google/adk');
233
+ const adkApi = createRequire(adkEntry)('@opentelemetry/api');
234
+ if (adkApi?.trace === trace)
235
+ return true;
236
+ const proxy = adkApi?.trace?._proxyTracerProvider;
237
+ if (proxy && typeof proxy.setDelegate === 'function') {
238
+ proxy.setDelegate(provider);
239
+ return true;
240
+ }
241
+ }
242
+ catch {
243
+ /* ADK not installed or its api copy changed shape — see WHY above */
244
+ }
245
+ return false;
246
+ }
66
247
  export function initializeTracing() {
67
248
  if (isOtelInitialized)
68
249
  return;
@@ -70,6 +251,7 @@ export function initializeTracing() {
70
251
  // Since we register this globally, any internal ADK spans (e.g. tool calls, model calls)
71
252
  // will also automatically route to this provider and show up in the console.
72
253
  const spanProcessors = [
254
+ new SpanLineageProcessor(),
73
255
  new SimpleSpanProcessor(new JsonConsoleExporter()),
74
256
  ];
75
257
  // Optional durable sink (TELEMETRY_SUPABASE=true + Supabase credentials):
@@ -82,12 +264,27 @@ export function initializeTracing() {
82
264
  spanProcessors.push(new BatchSpanProcessor(telemetryExporter));
83
265
  console.log('[TELEMETRY] Supabase sink enabled → adk_telemetry');
84
266
  }
85
- const provider = new NodeTracerProvider({ spanProcessors });
267
+ const otlp = otlpProcessor();
268
+ if (otlp)
269
+ spanProcessors.push(otlp);
270
+ let resource;
271
+ try {
272
+ const res = createRequire(import.meta.url)('@opentelemetry/resources');
273
+ const make = res.resourceFromAttributes ?? res.Resource?.default?.bind(res.Resource);
274
+ resource = make ? make({ 'service.name': process.env.OTEL_SERVICE_NAME ?? 'melchizedek' }) : undefined;
275
+ }
276
+ catch {
277
+ resource = undefined;
278
+ }
279
+ const provider = new NodeTracerProvider({ spanProcessors, ...(resource ? { resource } : {}) });
86
280
  provider.register();
281
+ adoptAdkTracerApi(provider);
87
282
  tracerProvider = provider;
88
283
  isOtelInitialized = true;
89
284
  }
90
285
  const tracer = trace.getTracer('melchizedek-tracer');
286
+ /** Turn identity by trace id, so llm.request children can carry it too. */
287
+ const turnContexts = new Map();
91
288
  /**
92
289
  * Wraps an ADK `Runner.runAsync` stream with a root OpenTelemetry span.
93
290
  * Accumulates token counts, records variables (bindings) and the raw input/output.
@@ -95,16 +292,53 @@ const tracer = trace.getTracer('melchizedek-tracer');
95
292
  export async function* traceAgentRun(stream, metadata) {
96
293
  initializeTracing();
97
294
  const span = tracer.startSpan(`Syndicate Execution: ${metadata.syndicateName}`);
295
+ turnContexts.set(span.spanContext().traceId, {
296
+ sessionId: metadata.sessionId,
297
+ userId: metadata.userId,
298
+ taskId: metadata.taskId,
299
+ });
98
300
  if (metadata.bindings) {
99
301
  span.setAttribute('syndicate.bindings', JSON.stringify(metadata.bindings));
100
302
  }
101
303
  if (metadata.input) {
102
304
  span.setAttribute('syndicate.input', JSON.stringify(metadata.input));
103
305
  }
306
+ if (metadata.route) {
307
+ span.setAttribute('syndicate.route', metadata.route.route);
308
+ if (metadata.route.reason)
309
+ span.setAttribute('syndicate.route.reason', metadata.route.reason);
310
+ span.setAttribute('syndicate.route.fell_back', metadata.route.fellBack === true);
311
+ span.setAttribute('syndicate.route.via_override', metadata.route.viaOverride === true);
312
+ }
313
+ if (metadata.sessionId) {
314
+ span.setAttribute('session.id', metadata.sessionId);
315
+ span.setAttribute('gen_ai.conversation.id', metadata.sessionId);
316
+ }
317
+ if (metadata.userId)
318
+ span.setAttribute('user.id', metadata.userId);
319
+ if (metadata.taskId)
320
+ span.setAttribute('a2a.task_id', metadata.taskId);
321
+ if (metadata.stage)
322
+ span.setAttribute('syndicate.stage', metadata.stage);
323
+ if (metadata.configHash)
324
+ span.setAttribute('syndicate.config_hash', metadata.configHash);
325
+ span.setAttribute('engine.version', engineVersion());
326
+ span.setAttribute('telemetry.schema_version', TELEMETRY_SCHEMA_VERSION);
327
+ for (const [key, value] of Object.entries(metadata.attributes ?? {})) {
328
+ span.setAttribute(key, value);
329
+ }
330
+ metadata.onSpanStart?.({
331
+ traceId: span.spanContext().traceId,
332
+ spanId: span.spanContext().spanId,
333
+ });
104
334
  let totalInputTokens = 0;
105
335
  let totalOutputTokens = 0;
106
336
  let totalThinkingTokens = 0;
107
337
  let outputText = '';
338
+ let invocationId = '';
339
+ let respondingAgent = '';
340
+ let errorCode = '';
341
+ let errorMessage = '';
108
342
  const ctx = trace.setSpan(context.active(), span);
109
343
  try {
110
344
  const wrappedStream = {
@@ -117,6 +351,13 @@ export async function* traceAgentRun(stream, metadata) {
117
351
  }
118
352
  };
119
353
  for await (const event of wrappedStream) {
354
+ const ev = event;
355
+ if (!invocationId && typeof ev.invocationId === 'string')
356
+ invocationId = ev.invocationId;
357
+ if ((ev.errorCode || ev.errorMessage) && ev.errorCode !== 'STOP') {
358
+ errorCode = String(ev.errorCode ?? 'ERROR');
359
+ errorMessage = String(ev.errorMessage ?? '');
360
+ }
120
361
  if (event.usageMetadata) {
121
362
  // Aggregation caveat: a multi-turn run repeats the growing prompt on
122
363
  // every model call, so Math.max under-reports true billed input. The
@@ -135,8 +376,13 @@ export async function* traceAgentRun(stream, metadata) {
135
376
  if (event.content && event.content.parts) {
136
377
  for (const part of event.content.parts) {
137
378
  const p = part;
138
- if (p.text && !p.thought) {
379
+ // Under SSE the reply streams as partials and is then repeated
380
+ // whole on the final event. Count it once, from the final, or
381
+ // syndicate.output records every answer twice.
382
+ if (p.text && !p.thought && !ev.partial) {
139
383
  outputText += p.text;
384
+ if (ev.author)
385
+ respondingAgent = String(ev.author);
140
386
  }
141
387
  if (p.functionCall) {
142
388
  span.addEvent('ToolCall', {
@@ -169,10 +415,66 @@ export async function* traceAgentRun(stream, metadata) {
169
415
  span.setAttribute('syndicate.tokens.output', totalOutputTokens);
170
416
  span.setAttribute('syndicate.tokens.thinking', totalThinkingTokens);
171
417
  span.setAttribute('syndicate.output', outputText);
418
+ if (invocationId)
419
+ span.setAttribute('adk.invocation_id', invocationId);
420
+ if (respondingAgent)
421
+ span.setAttribute('syndicate.agent', respondingAgent);
422
+ if (errorCode) {
423
+ span.setAttribute('syndicate.error.code', errorCode);
424
+ span.setAttribute('syndicate.error.message', errorMessage.slice(0, 4000));
425
+ }
426
+ turnContexts.delete(span.spanContext().traceId);
427
+ const stats = takeTraceStats(span.spanContext().traceId);
428
+ if (stats) {
429
+ span.setAttribute('syndicate.latency.model_ms', Math.round(stats.modelMs));
430
+ span.setAttribute('syndicate.latency.tool_ms', Math.round(stats.toolMs));
431
+ span.setAttribute('syndicate.llm_calls', stats.llmCalls);
432
+ span.setAttribute('syndicate.models', [...stats.models].sort().join(','));
433
+ }
434
+ try {
435
+ for (const [key, value] of Object.entries(metadata.onEnd?.() ?? {})) {
436
+ if (value !== undefined)
437
+ span.setAttribute(key, value);
438
+ }
439
+ }
440
+ catch {
441
+ /* a consumer's onEnd must never break the span */
442
+ }
172
443
  span.end();
173
444
  }
174
445
  }
175
446
  const THINKING_EVENT_MAX_CHARS = 600;
447
+ const ERROR_PAYLOAD_MAX_CHARS = 200_000;
448
+ const ERROR_MESSAGE_MAX_CHARS = 4_000;
449
+ /** JSON.stringify that never throws and never exceeds the cap. */
450
+ function safePayloadJson(value) {
451
+ let json;
452
+ try {
453
+ json = JSON.stringify(value) ?? String(value);
454
+ }
455
+ catch {
456
+ json = String(value);
457
+ }
458
+ return json.length > ERROR_PAYLOAD_MAX_CHARS
459
+ ? json.slice(0, ERROR_PAYLOAD_MAX_CHARS)
460
+ : json;
461
+ }
462
+ /**
463
+ * Providers throw errors whose message IS the API's JSON error body
464
+ * (Gemini: {"error":{"code":503,"message":...,"status":...}}). Pull the
465
+ * numeric code out so llm.error_code reads "503", not "THROWN".
466
+ */
467
+ function refineErrorCode(code, message) {
468
+ try {
469
+ const parsed = JSON.parse(message);
470
+ if (parsed?.error?.code)
471
+ return String(parsed.error.code);
472
+ }
473
+ catch {
474
+ /* not a JSON body — keep the code we have */
475
+ }
476
+ return code;
477
+ }
176
478
  /**
177
479
  * Wraps one adapter `generateContentAsync` invocation in an `llm.request`
178
480
  * span — one span per model call, for EVERY provider. Records provider,
@@ -189,10 +491,33 @@ export async function* traceLlmGeneration(meta, inner) {
189
491
  const span = tracer.startSpan('llm.request');
190
492
  span.setAttribute('llm.provider', meta.provider);
191
493
  span.setAttribute('llm.model', meta.model);
494
+ // OpenTelemetry GenAI semantic conventions alongside the llm.* names, so
495
+ // an OTLP backend (Phoenix, Langfuse, Tempo) reads these spans natively.
496
+ span.setAttribute('gen_ai.system', meta.provider);
497
+ span.setAttribute('gen_ai.request.model', meta.model);
498
+ const agent = agentForSpan(span.spanContext().spanId);
499
+ if (agent) {
500
+ span.setAttribute('llm.agent', agent);
501
+ span.setAttribute('gen_ai.agent.name', agent);
502
+ }
503
+ const turn = turnContexts.get(span.spanContext().traceId);
504
+ if (turn) {
505
+ if (turn.sessionId) {
506
+ span.setAttribute('session.id', turn.sessionId);
507
+ span.setAttribute('gen_ai.conversation.id', turn.sessionId);
508
+ }
509
+ if (turn.userId)
510
+ span.setAttribute('user.id', turn.userId);
511
+ if (turn.taskId)
512
+ span.setAttribute('a2a.task_id', turn.taskId);
513
+ }
192
514
  let inputTokens = 0;
193
515
  let outputTokens = 0;
194
516
  let thinkingTokens = 0;
195
517
  let thinkingPreview = '';
518
+ let errorCode = '';
519
+ let errorMessage = '';
520
+ let errorResponse;
196
521
  const ctx = trace.setSpan(context.active(), span);
197
522
  try {
198
523
  while (true) {
@@ -207,7 +532,9 @@ export async function* traceLlmGeneration(meta, inner) {
207
532
  thinkingTokens = resp.usageMetadata.thoughtsTokenCount ?? thinkingTokens;
208
533
  }
209
534
  if (resp.errorCode) {
210
- span.setAttribute('llm.error_code', resp.errorCode);
535
+ errorCode = String(resp.errorCode);
536
+ errorMessage = String(resp.errorMessage ?? '');
537
+ errorResponse = resp;
211
538
  }
212
539
  for (const part of resp.content?.parts ?? []) {
213
540
  const p = part;
@@ -220,15 +547,32 @@ export async function* traceLlmGeneration(meta, inner) {
220
547
  }
221
548
  catch (error) {
222
549
  span.recordException(error);
550
+ // Gemini (and any adapter that throws instead of yielding an error
551
+ // response) lands here; the message is often the raw API error body.
552
+ errorMessage = error instanceof Error ? error.message : String(error);
553
+ errorCode = refineErrorCode(errorCode || 'THROWN', errorMessage);
554
+ errorResponse = { errorCode, errorMessage };
223
555
  throw error;
224
556
  }
225
557
  finally {
226
558
  if (thinkingPreview) {
227
559
  span.addEvent('llm.thinking', { 'thinking.preview': thinkingPreview });
228
560
  }
561
+ if (errorCode) {
562
+ span.setAttribute('llm.error_code', errorCode);
563
+ span.setAttribute('llm.error_message', errorMessage.slice(0, ERROR_MESSAGE_MAX_CHARS));
564
+ // Make this span a payload candidate (see supabaseSpanExporter's
565
+ // isPayloadSpan): the request as sent and the error as received.
566
+ if (meta.llmRequest !== undefined) {
567
+ span.setAttribute('llm.payload.request', safePayloadJson(meta.llmRequest));
568
+ }
569
+ span.setAttribute('llm.payload.response', safePayloadJson(errorResponse ?? { errorCode, errorMessage }));
570
+ }
229
571
  span.setAttribute('llm.tokens.input', inputTokens);
230
572
  span.setAttribute('llm.tokens.output', outputTokens);
231
573
  span.setAttribute('llm.tokens.thinking', thinkingTokens);
574
+ span.setAttribute('gen_ai.usage.input_tokens', inputTokens);
575
+ span.setAttribute('gen_ai.usage.output_tokens', outputTokens);
232
576
  // llm.latency_ms mirrors the span duration for the flat adk_telemetry row.
233
577
  span.end();
234
578
  }
@@ -1,2 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ /** One human-readable line naming WHY a turn failed, for the failed task's
3
+ * status message — the text the Discord surface renders verbatim. Provider
4
+ * errors sometimes arrive as a raw JSON blob (Gemini ApiError:
5
+ * `{"error":{"code":503,"message":"...","status":"UNAVAILABLE"}}`), so the
6
+ * inner message is dug out before falling back to the string itself. An
7
+ * empty message keeps the old pointer at the server logs. */
8
+ export declare function describeTurnError(error: {
9
+ code: string;
10
+ message: string;
11
+ }): string;
2
12
  export declare function startServer(syndicateName?: string): Promise<void>;