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.
- package/AGENT_SETUP.md +2 -2
- package/DOCUMENTATION.md +17 -24
- package/QUICKSTART.md +3 -2
- package/README.md +11 -9
- package/config/agents/examples/ares.yaml +0 -2
- package/config/agents/examples/augustin.yaml +0 -2
- package/config/agents/examples/cartographers.yaml +2 -5
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/image_production.yaml +0 -1
- package/config/agents/examples/librarian.yaml +2 -4
- package/config/agents/examples/patient_advocate.yaml +2 -4
- package/config/agents/examples/scriptorium.yaml +2 -5
- package/config/agents/examples/style_council.yaml +2 -6
- package/config/agents/examples/syndicate.yaml +0 -3
- package/config/agents/examples/tutor.yaml +62 -32
- package/config/agents/syndicateSchema.yaml +4 -2
- package/dist/lib/compile.d.ts +64 -0
- package/dist/lib/compile.js +105 -0
- package/dist/lib/models/claudeLlm.js +34 -4
- package/dist/lib/models/gptLlm.js +6 -3
- package/dist/lib/models/ollamaLlm.d.ts +2 -0
- package/dist/lib/models/ollamaLlm.js +5 -1
- package/dist/lib/models/openAiCompatibleLlm.d.ts +49 -0
- package/dist/lib/models/openAiCompatibleLlm.js +233 -9
- package/dist/lib/models/registry.js +1 -1
- package/dist/lib/models/schemaNormalize.d.ts +9 -0
- package/dist/lib/models/schemaNormalize.js +34 -0
- package/dist/lib/observability/embeddings.d.ts +21 -0
- package/dist/lib/observability/embeddings.js +47 -0
- package/dist/lib/observability/lineage.d.ts +35 -0
- package/dist/lib/observability/lineage.js +96 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +191 -18
- package/dist/lib/observability/supabaseSpanExporter.js +388 -80
- package/dist/lib/observability/tracer.d.ts +82 -0
- package/dist/lib/observability/tracer.js +348 -4
- package/dist/scripts/a2a_server.d.ts +10 -0
- package/dist/scripts/a2a_server.js +132 -75
- package/dist/scripts/syndicate_chat.js +41 -10
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>;
|