melchizedek-agents 0.9.2 → 0.9.3
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 +13 -22
- package/README.md +22 -20
- package/config/agents/examples/ares.yaml +0 -2
- package/config/agents/examples/augustin.yaml +0 -2
- package/config/agents/examples/cartographers.yaml +0 -3
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/image_production.yaml +0 -1
- package/config/agents/examples/librarian.yaml +0 -2
- package/config/agents/examples/patient_advocate.yaml +0 -2
- package/config/agents/examples/scriptorium.yaml +0 -3
- package/config/agents/examples/style_council.yaml +1 -5
- package/config/agents/examples/syndicate.yaml +0 -3
- package/config/agents/examples/tutor.yaml +1 -1
- 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 +25 -1
- package/dist/lib/models/gptLlm.js +5 -2
- package/dist/lib/models/ollamaLlm.d.ts +2 -0
- package/dist/lib/models/ollamaLlm.js +4 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
- package/dist/lib/models/openAiCompatibleLlm.js +14 -5
- 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 +186 -18
- package/dist/lib/observability/supabaseSpanExporter.js +377 -80
- package/dist/lib/observability/tracer.d.ts +74 -0
- package/dist/lib/observability/tracer.js +282 -1
- package/dist/scripts/a2a_server.js +57 -72
- 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,17 @@ 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
|
+
function spanScopeName(span) {
|
|
72
|
+
const s = span;
|
|
73
|
+
return s.instrumentationScope?.name ?? s.instrumentationLibrary?.name ?? '';
|
|
74
|
+
}
|
|
30
75
|
class JsonConsoleExporter {
|
|
31
76
|
export(spans, resultCallback) {
|
|
32
77
|
for (const span of spans) {
|
|
@@ -38,6 +83,8 @@ class JsonConsoleExporter {
|
|
|
38
83
|
/* a listener bug must not break the export pipeline */
|
|
39
84
|
}
|
|
40
85
|
}
|
|
86
|
+
if (!PRINT_ALL_SPANS && spanScopeName(span) === ADK_SCOPE)
|
|
87
|
+
continue;
|
|
41
88
|
// Create a clean JSON representation of the span
|
|
42
89
|
const jsonSpan = {
|
|
43
90
|
traceId: span.spanContext().traceId,
|
|
@@ -63,6 +110,131 @@ class JsonConsoleExporter {
|
|
|
63
110
|
return Promise.resolve();
|
|
64
111
|
}
|
|
65
112
|
}
|
|
113
|
+
// ── Span lineage (per-agent attribution) ─────────────────────────────────────
|
|
114
|
+
// ADK wraps every agent turn in an `invoke_agent <name>` span and every model
|
|
115
|
+
// call in a `call_llm` child of it; our llm.request span is a child of THAT.
|
|
116
|
+
// A span cannot read its parent's name through the OTEL API, so a processor
|
|
117
|
+
// records each span's name and parent at start, and llm.request walks up the
|
|
118
|
+
// chain to find the agent it belongs to. This is what fills the `agent`
|
|
119
|
+
// column of adk_telemetry — per-subagent cost and latency attribution — and
|
|
120
|
+
// what lets the observatory say which agent made which call.
|
|
121
|
+
const spanLineage = new Map();
|
|
122
|
+
const SPAN_LINEAGE_CAP = 10_000;
|
|
123
|
+
const traceStats = new Map();
|
|
124
|
+
const TRACE_STATS_TTL_MS = 60 * 60 * 1000;
|
|
125
|
+
function statsFor(traceId) {
|
|
126
|
+
let s = traceStats.get(traceId);
|
|
127
|
+
if (!s) {
|
|
128
|
+
s = { modelMs: 0, toolMs: 0, llmCalls: 0, models: new Set(), startedAt: Date.now() };
|
|
129
|
+
traceStats.set(traceId, s);
|
|
130
|
+
if (traceStats.size > 2_000) {
|
|
131
|
+
const cutoff = Date.now() - TRACE_STATS_TTL_MS;
|
|
132
|
+
for (const [id, st] of traceStats)
|
|
133
|
+
if (st.startedAt < cutoff)
|
|
134
|
+
traceStats.delete(id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
/** Read-and-forget the stats for a finished trace. */
|
|
140
|
+
export function takeTraceStats(traceId) {
|
|
141
|
+
const s = traceStats.get(traceId);
|
|
142
|
+
traceStats.delete(traceId);
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
function spanDurationMs(span) {
|
|
146
|
+
return span.duration[0] * 1000 + span.duration[1] / 1e6;
|
|
147
|
+
}
|
|
148
|
+
class SpanLineageProcessor {
|
|
149
|
+
onStart(span) {
|
|
150
|
+
const ctx = span.spanContext();
|
|
151
|
+
const parent = span.parentSpanContext?.spanId ?? span.parentSpanId ?? undefined;
|
|
152
|
+
spanLineage.set(ctx.spanId, { name: span.name, parent });
|
|
153
|
+
// ADK's call_llm span (the one carrying the full request/response
|
|
154
|
+
// payloads) learns its agent the same way llm.request does, so a
|
|
155
|
+
// payload row can be attributed without re-walking the tree later.
|
|
156
|
+
if (span.name === 'call_llm') {
|
|
157
|
+
const agent = agentForSpan(parent);
|
|
158
|
+
if (agent)
|
|
159
|
+
span.setAttribute('llm.agent', agent);
|
|
160
|
+
}
|
|
161
|
+
if (spanLineage.size > SPAN_LINEAGE_CAP) {
|
|
162
|
+
// A runaway process must never grow this without bound; parents end
|
|
163
|
+
// after their children, so evicting the oldest entry is safe.
|
|
164
|
+
const oldest = spanLineage.keys().next().value;
|
|
165
|
+
if (oldest !== undefined)
|
|
166
|
+
spanLineage.delete(oldest);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
onEnd(span) {
|
|
170
|
+
spanLineage.delete(span.spanContext().spanId);
|
|
171
|
+
const traceId = span.spanContext().traceId;
|
|
172
|
+
if (span.name === 'llm.request') {
|
|
173
|
+
const s = statsFor(traceId);
|
|
174
|
+
s.modelMs += spanDurationMs(span);
|
|
175
|
+
s.llmCalls += 1;
|
|
176
|
+
const model = span.attributes['llm.model'];
|
|
177
|
+
if (typeof model === 'string' && model)
|
|
178
|
+
s.models.add(model);
|
|
179
|
+
}
|
|
180
|
+
else if (span.name.startsWith('execute_tool ')) {
|
|
181
|
+
statsFor(traceId).toolMs += spanDurationMs(span);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
shutdown() { return Promise.resolve(); }
|
|
185
|
+
forceFlush() { return Promise.resolve(); }
|
|
186
|
+
}
|
|
187
|
+
/** Name of the nearest enclosing ADK `invoke_agent` span, or null. */
|
|
188
|
+
export function agentForSpan(spanId) {
|
|
189
|
+
let cursor = spanId;
|
|
190
|
+
for (let depth = 0; cursor && depth < 12; depth++) {
|
|
191
|
+
const entry = spanLineage.get(cursor);
|
|
192
|
+
if (!entry)
|
|
193
|
+
return null;
|
|
194
|
+
if (entry.name.startsWith('invoke_agent '))
|
|
195
|
+
return entry.name.slice('invoke_agent '.length);
|
|
196
|
+
cursor = entry.parent;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Hands the provider to ADK's private copy of @opentelemetry/api.
|
|
202
|
+
*
|
|
203
|
+
* WHY: @google/adk pins its own @opentelemetry/api (nested under its
|
|
204
|
+
* node_modules) and creates its tracer at module-import time —
|
|
205
|
+
* `trace.getTracer("gcp.vertex.agent")` in telemetry/tracing.js. That call
|
|
206
|
+
* runs BEFORE any entrypoint can register a provider, so it binds to that
|
|
207
|
+
* copy's ProxyTracerProvider, whose delegate is only ever set by a
|
|
208
|
+
* `setGlobalTracerProvider` call made through the SAME copy. Ours goes
|
|
209
|
+
* through the top-level copy; the global registration is shared (it lives
|
|
210
|
+
* on a well-known Symbol), but the proxy's delegate is not. Result: every
|
|
211
|
+
* ADK span — invoke_agent, call_llm, execute_tool — was a no-op, and
|
|
212
|
+
* llm.request spans had no parent. Setting the delegate on ADK's proxy
|
|
213
|
+
* fixes both: ADK spans now export, and the context manager (global,
|
|
214
|
+
* shared) threads our llm.request spans under ADK's call_llm.
|
|
215
|
+
*
|
|
216
|
+
* `_proxyTracerProvider` is a private field of TraceAPI; this is the only
|
|
217
|
+
* place that touches it, it is guarded, and a failure just means ADK spans
|
|
218
|
+
* stay invisible — as they were.
|
|
219
|
+
*/
|
|
220
|
+
function adoptAdkTracerApi(provider) {
|
|
221
|
+
try {
|
|
222
|
+
const requireHere = createRequire(import.meta.url);
|
|
223
|
+
const adkEntry = requireHere.resolve('@google/adk');
|
|
224
|
+
const adkApi = createRequire(adkEntry)('@opentelemetry/api');
|
|
225
|
+
if (adkApi?.trace === trace)
|
|
226
|
+
return true;
|
|
227
|
+
const proxy = adkApi?.trace?._proxyTracerProvider;
|
|
228
|
+
if (proxy && typeof proxy.setDelegate === 'function') {
|
|
229
|
+
proxy.setDelegate(provider);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* ADK not installed or its api copy changed shape — see WHY above */
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
66
238
|
export function initializeTracing() {
|
|
67
239
|
if (isOtelInitialized)
|
|
68
240
|
return;
|
|
@@ -70,6 +242,7 @@ export function initializeTracing() {
|
|
|
70
242
|
// Since we register this globally, any internal ADK spans (e.g. tool calls, model calls)
|
|
71
243
|
// will also automatically route to this provider and show up in the console.
|
|
72
244
|
const spanProcessors = [
|
|
245
|
+
new SpanLineageProcessor(),
|
|
73
246
|
new SimpleSpanProcessor(new JsonConsoleExporter()),
|
|
74
247
|
];
|
|
75
248
|
// Optional durable sink (TELEMETRY_SUPABASE=true + Supabase credentials):
|
|
@@ -82,12 +255,27 @@ export function initializeTracing() {
|
|
|
82
255
|
spanProcessors.push(new BatchSpanProcessor(telemetryExporter));
|
|
83
256
|
console.log('[TELEMETRY] Supabase sink enabled → adk_telemetry');
|
|
84
257
|
}
|
|
85
|
-
const
|
|
258
|
+
const otlp = otlpProcessor();
|
|
259
|
+
if (otlp)
|
|
260
|
+
spanProcessors.push(otlp);
|
|
261
|
+
let resource;
|
|
262
|
+
try {
|
|
263
|
+
const res = createRequire(import.meta.url)('@opentelemetry/resources');
|
|
264
|
+
const make = res.resourceFromAttributes ?? res.Resource?.default?.bind(res.Resource);
|
|
265
|
+
resource = make ? make({ 'service.name': process.env.OTEL_SERVICE_NAME ?? 'melchizedek' }) : undefined;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
resource = undefined;
|
|
269
|
+
}
|
|
270
|
+
const provider = new NodeTracerProvider({ spanProcessors, ...(resource ? { resource } : {}) });
|
|
86
271
|
provider.register();
|
|
272
|
+
adoptAdkTracerApi(provider);
|
|
87
273
|
tracerProvider = provider;
|
|
88
274
|
isOtelInitialized = true;
|
|
89
275
|
}
|
|
90
276
|
const tracer = trace.getTracer('melchizedek-tracer');
|
|
277
|
+
/** Turn identity by trace id, so llm.request children can carry it too. */
|
|
278
|
+
const turnContexts = new Map();
|
|
91
279
|
/**
|
|
92
280
|
* Wraps an ADK `Runner.runAsync` stream with a root OpenTelemetry span.
|
|
93
281
|
* Accumulates token counts, records variables (bindings) and the raw input/output.
|
|
@@ -95,16 +283,53 @@ const tracer = trace.getTracer('melchizedek-tracer');
|
|
|
95
283
|
export async function* traceAgentRun(stream, metadata) {
|
|
96
284
|
initializeTracing();
|
|
97
285
|
const span = tracer.startSpan(`Syndicate Execution: ${metadata.syndicateName}`);
|
|
286
|
+
turnContexts.set(span.spanContext().traceId, {
|
|
287
|
+
sessionId: metadata.sessionId,
|
|
288
|
+
userId: metadata.userId,
|
|
289
|
+
taskId: metadata.taskId,
|
|
290
|
+
});
|
|
98
291
|
if (metadata.bindings) {
|
|
99
292
|
span.setAttribute('syndicate.bindings', JSON.stringify(metadata.bindings));
|
|
100
293
|
}
|
|
101
294
|
if (metadata.input) {
|
|
102
295
|
span.setAttribute('syndicate.input', JSON.stringify(metadata.input));
|
|
103
296
|
}
|
|
297
|
+
if (metadata.route) {
|
|
298
|
+
span.setAttribute('syndicate.route', metadata.route.route);
|
|
299
|
+
if (metadata.route.reason)
|
|
300
|
+
span.setAttribute('syndicate.route.reason', metadata.route.reason);
|
|
301
|
+
span.setAttribute('syndicate.route.fell_back', metadata.route.fellBack === true);
|
|
302
|
+
span.setAttribute('syndicate.route.via_override', metadata.route.viaOverride === true);
|
|
303
|
+
}
|
|
304
|
+
if (metadata.sessionId) {
|
|
305
|
+
span.setAttribute('session.id', metadata.sessionId);
|
|
306
|
+
span.setAttribute('gen_ai.conversation.id', metadata.sessionId);
|
|
307
|
+
}
|
|
308
|
+
if (metadata.userId)
|
|
309
|
+
span.setAttribute('user.id', metadata.userId);
|
|
310
|
+
if (metadata.taskId)
|
|
311
|
+
span.setAttribute('a2a.task_id', metadata.taskId);
|
|
312
|
+
if (metadata.stage)
|
|
313
|
+
span.setAttribute('syndicate.stage', metadata.stage);
|
|
314
|
+
if (metadata.configHash)
|
|
315
|
+
span.setAttribute('syndicate.config_hash', metadata.configHash);
|
|
316
|
+
span.setAttribute('engine.version', engineVersion());
|
|
317
|
+
span.setAttribute('telemetry.schema_version', TELEMETRY_SCHEMA_VERSION);
|
|
318
|
+
for (const [key, value] of Object.entries(metadata.attributes ?? {})) {
|
|
319
|
+
span.setAttribute(key, value);
|
|
320
|
+
}
|
|
321
|
+
metadata.onSpanStart?.({
|
|
322
|
+
traceId: span.spanContext().traceId,
|
|
323
|
+
spanId: span.spanContext().spanId,
|
|
324
|
+
});
|
|
104
325
|
let totalInputTokens = 0;
|
|
105
326
|
let totalOutputTokens = 0;
|
|
106
327
|
let totalThinkingTokens = 0;
|
|
107
328
|
let outputText = '';
|
|
329
|
+
let invocationId = '';
|
|
330
|
+
let respondingAgent = '';
|
|
331
|
+
let errorCode = '';
|
|
332
|
+
let errorMessage = '';
|
|
108
333
|
const ctx = trace.setSpan(context.active(), span);
|
|
109
334
|
try {
|
|
110
335
|
const wrappedStream = {
|
|
@@ -117,6 +342,13 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
117
342
|
}
|
|
118
343
|
};
|
|
119
344
|
for await (const event of wrappedStream) {
|
|
345
|
+
const ev = event;
|
|
346
|
+
if (!invocationId && typeof ev.invocationId === 'string')
|
|
347
|
+
invocationId = ev.invocationId;
|
|
348
|
+
if ((ev.errorCode || ev.errorMessage) && ev.errorCode !== 'STOP') {
|
|
349
|
+
errorCode = String(ev.errorCode ?? 'ERROR');
|
|
350
|
+
errorMessage = String(ev.errorMessage ?? '');
|
|
351
|
+
}
|
|
120
352
|
if (event.usageMetadata) {
|
|
121
353
|
// Aggregation caveat: a multi-turn run repeats the growing prompt on
|
|
122
354
|
// every model call, so Math.max under-reports true billed input. The
|
|
@@ -137,6 +369,8 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
137
369
|
const p = part;
|
|
138
370
|
if (p.text && !p.thought) {
|
|
139
371
|
outputText += p.text;
|
|
372
|
+
if (ev.author)
|
|
373
|
+
respondingAgent = String(ev.author);
|
|
140
374
|
}
|
|
141
375
|
if (p.functionCall) {
|
|
142
376
|
span.addEvent('ToolCall', {
|
|
@@ -169,6 +403,31 @@ export async function* traceAgentRun(stream, metadata) {
|
|
|
169
403
|
span.setAttribute('syndicate.tokens.output', totalOutputTokens);
|
|
170
404
|
span.setAttribute('syndicate.tokens.thinking', totalThinkingTokens);
|
|
171
405
|
span.setAttribute('syndicate.output', outputText);
|
|
406
|
+
if (invocationId)
|
|
407
|
+
span.setAttribute('adk.invocation_id', invocationId);
|
|
408
|
+
if (respondingAgent)
|
|
409
|
+
span.setAttribute('syndicate.agent', respondingAgent);
|
|
410
|
+
if (errorCode) {
|
|
411
|
+
span.setAttribute('syndicate.error.code', errorCode);
|
|
412
|
+
span.setAttribute('syndicate.error.message', errorMessage.slice(0, 2000));
|
|
413
|
+
}
|
|
414
|
+
turnContexts.delete(span.spanContext().traceId);
|
|
415
|
+
const stats = takeTraceStats(span.spanContext().traceId);
|
|
416
|
+
if (stats) {
|
|
417
|
+
span.setAttribute('syndicate.latency.model_ms', Math.round(stats.modelMs));
|
|
418
|
+
span.setAttribute('syndicate.latency.tool_ms', Math.round(stats.toolMs));
|
|
419
|
+
span.setAttribute('syndicate.llm_calls', stats.llmCalls);
|
|
420
|
+
span.setAttribute('syndicate.models', [...stats.models].sort().join(','));
|
|
421
|
+
}
|
|
422
|
+
try {
|
|
423
|
+
for (const [key, value] of Object.entries(metadata.onEnd?.() ?? {})) {
|
|
424
|
+
if (value !== undefined)
|
|
425
|
+
span.setAttribute(key, value);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
/* a consumer's onEnd must never break the span */
|
|
430
|
+
}
|
|
172
431
|
span.end();
|
|
173
432
|
}
|
|
174
433
|
}
|
|
@@ -189,6 +448,26 @@ export async function* traceLlmGeneration(meta, inner) {
|
|
|
189
448
|
const span = tracer.startSpan('llm.request');
|
|
190
449
|
span.setAttribute('llm.provider', meta.provider);
|
|
191
450
|
span.setAttribute('llm.model', meta.model);
|
|
451
|
+
// OpenTelemetry GenAI semantic conventions alongside the llm.* names, so
|
|
452
|
+
// an OTLP backend (Phoenix, Langfuse, Tempo) reads these spans natively.
|
|
453
|
+
span.setAttribute('gen_ai.system', meta.provider);
|
|
454
|
+
span.setAttribute('gen_ai.request.model', meta.model);
|
|
455
|
+
const agent = agentForSpan(span.spanContext().spanId);
|
|
456
|
+
if (agent) {
|
|
457
|
+
span.setAttribute('llm.agent', agent);
|
|
458
|
+
span.setAttribute('gen_ai.agent.name', agent);
|
|
459
|
+
}
|
|
460
|
+
const turn = turnContexts.get(span.spanContext().traceId);
|
|
461
|
+
if (turn) {
|
|
462
|
+
if (turn.sessionId) {
|
|
463
|
+
span.setAttribute('session.id', turn.sessionId);
|
|
464
|
+
span.setAttribute('gen_ai.conversation.id', turn.sessionId);
|
|
465
|
+
}
|
|
466
|
+
if (turn.userId)
|
|
467
|
+
span.setAttribute('user.id', turn.userId);
|
|
468
|
+
if (turn.taskId)
|
|
469
|
+
span.setAttribute('a2a.task_id', turn.taskId);
|
|
470
|
+
}
|
|
192
471
|
let inputTokens = 0;
|
|
193
472
|
let outputTokens = 0;
|
|
194
473
|
let thinkingTokens = 0;
|
|
@@ -229,6 +508,8 @@ export async function* traceLlmGeneration(meta, inner) {
|
|
|
229
508
|
span.setAttribute('llm.tokens.input', inputTokens);
|
|
230
509
|
span.setAttribute('llm.tokens.output', outputTokens);
|
|
231
510
|
span.setAttribute('llm.tokens.thinking', thinkingTokens);
|
|
511
|
+
span.setAttribute('gen_ai.usage.input_tokens', inputTokens);
|
|
512
|
+
span.setAttribute('gen_ai.usage.output_tokens', outputTokens);
|
|
232
513
|
// llm.latency_ms mirrors the span duration for the flat adk_telemetry row.
|
|
233
514
|
span.end();
|
|
234
515
|
}
|
|
@@ -10,13 +10,13 @@ import { loadEnv } from "../lib/loadEnv.js";
|
|
|
10
10
|
import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
|
|
11
11
|
import { AsyncLocalStorage } from 'async_hooks';
|
|
12
12
|
import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from '@a2a-js/sdk/server/express';
|
|
13
|
-
import { Runner,
|
|
13
|
+
import { Runner, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
|
|
14
14
|
import { loadSyndicate } from "../lib/loadSyndicate.js";
|
|
15
15
|
import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from "../lib/dispatch.js";
|
|
16
16
|
import { traceAgentRun } from "../lib/observability/tracer.js";
|
|
17
|
+
import { configDigest } from "../lib/observability/lineage.js";
|
|
17
18
|
import { resolveModel } from "../lib/models/registry.js";
|
|
18
|
-
import {
|
|
19
|
-
import { createMcpTools } from "../lib/tools/mcpToolFactory.js";
|
|
19
|
+
import { compileGraph as compileGraphShared, compileSubagent as compileSubagentShared } from "../lib/compile.js";
|
|
20
20
|
import { hasSupabaseCredentials, createSupabaseServices } from "../lib/persistence/supabaseProvider.js";
|
|
21
21
|
import { ProjectedSessionService, renderTranscriptDigest } from "../lib/session/transcript.js";
|
|
22
22
|
const requestContextStorage = new AsyncLocalStorage();
|
|
@@ -110,11 +110,24 @@ class SyndicateExecutor {
|
|
|
110
110
|
config;
|
|
111
111
|
sessionService;
|
|
112
112
|
memoryService;
|
|
113
|
+
/** Provenance stamp for every turn this executor serves (lineage.ts). */
|
|
114
|
+
configHash;
|
|
113
115
|
constructor(config, sessionService, memoryService) {
|
|
114
116
|
this.config = config;
|
|
115
117
|
this.sessionService = sessionService;
|
|
116
118
|
this.memoryService = memoryService;
|
|
117
119
|
}
|
|
120
|
+
configHashFor() {
|
|
121
|
+
if (!this.configHash) {
|
|
122
|
+
try {
|
|
123
|
+
this.configHash = configDigest(this.config, (ref) => loadSyndicate(ref));
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
this.configHash = 'unhashable';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this.configHash;
|
|
130
|
+
}
|
|
118
131
|
/**
|
|
119
132
|
* Run ONE agent for ONE turn against a named session, draining its event
|
|
120
133
|
* stream. DELEGATE mode calls this once (the orchestrator); PLAN-DISPATCH
|
|
@@ -138,16 +151,36 @@ class SyndicateExecutor {
|
|
|
138
151
|
newMessage: { role: 'user', parts: userParts },
|
|
139
152
|
...(this.config.max_steps !== undefined ? { maxSteps: this.config.max_steps } : {})
|
|
140
153
|
});
|
|
154
|
+
let combinedText = '';
|
|
155
|
+
let lastToolResultText = '';
|
|
156
|
+
const invokedToolNames = new Set();
|
|
157
|
+
// The DELEGATE relay-fallback predicate, evaluated by the tracer in its
|
|
158
|
+
// finally (after the last event) so the turn row records whether the
|
|
159
|
+
// orchestrator's relay failed. Same rule as the substitution below.
|
|
160
|
+
const normalizeName = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
161
|
+
const relayFallbackFired = () => {
|
|
162
|
+
const text = combinedText.trim();
|
|
163
|
+
const echoed = text.length > 0 && [...invokedToolNames].some((n) => normalizeName(n) === normalizeName(text));
|
|
164
|
+
return (!text || echoed) && !!lastToolResultText;
|
|
165
|
+
};
|
|
141
166
|
if (params.trace) {
|
|
142
167
|
stream = traceAgentRun(stream, {
|
|
143
168
|
syndicateName: this.config.syndicate_name || 'melchizedek-syndicate',
|
|
144
169
|
bindings: this.config.variables || {},
|
|
145
|
-
input: userParts
|
|
170
|
+
input: userParts,
|
|
171
|
+
route: params.route,
|
|
172
|
+
// Identity: the CONVERSATION id (contextId), not the classifier's
|
|
173
|
+
// throwaway lane — every stage of one turn shares session/task ids.
|
|
174
|
+
sessionId: contextId,
|
|
175
|
+
userId,
|
|
176
|
+
taskId,
|
|
177
|
+
stage: params.stage,
|
|
178
|
+
configHash: this.configHashFor(),
|
|
179
|
+
onEnd: () => ({
|
|
180
|
+
'syndicate.relay_fallback': params.stage === 'delegate' && relayFallbackFired(),
|
|
181
|
+
}),
|
|
146
182
|
});
|
|
147
183
|
}
|
|
148
|
-
let combinedText = '';
|
|
149
|
-
let lastToolResultText = '';
|
|
150
|
-
const invokedToolNames = new Set();
|
|
151
184
|
let lastTokenTotal = 0;
|
|
152
185
|
let lastThinkingTokens = 0;
|
|
153
186
|
let error;
|
|
@@ -309,71 +342,19 @@ class SyndicateExecutor {
|
|
|
309
342
|
apiKey: authContext.apiKey,
|
|
310
343
|
defaultProvider: authContext.provider,
|
|
311
344
|
});
|
|
312
|
-
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
const nestedConfig = loadSyndicate(subCfg.yaml_reference);
|
|
323
|
-
return compileGraph(nestedConfig, subCfg.name, subCfg.description);
|
|
324
|
-
}
|
|
325
|
-
const subTools = resolveTools(subCfg.tools);
|
|
326
|
-
if (subCfg.mcp_server_url) {
|
|
327
|
-
console.log(`[A2A] Loading MCP tools: ${subCfg.mcp_server_url}`);
|
|
328
|
-
const mcpTools = await createMcpTools(subCfg.mcp_server_url);
|
|
329
|
-
for (const mcpTool of mcpTools) {
|
|
330
|
-
if (!subTools.some(t => t.name === mcpTool.name)) {
|
|
331
|
-
subTools.push(mcpTool);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
return new LlmAgent({
|
|
336
|
-
name: subCfg.name,
|
|
337
|
-
description: subCfg.description,
|
|
338
|
-
model: resolveModelForRequest(subCfg.model),
|
|
339
|
-
instruction: subCfg.instruction,
|
|
340
|
-
tools: subTools.length > 0 ? subTools : undefined,
|
|
341
|
-
outputSchema: subCfg.outputSchema,
|
|
342
|
-
generateContentConfig: {
|
|
343
|
-
...subCfg.generateContentConfig,
|
|
344
|
-
toolConfig: {
|
|
345
|
-
...subCfg.generateContentConfig?.toolConfig,
|
|
346
|
-
includeServerSideToolInvocations: true
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
};
|
|
351
|
-
const compileGraph = async (configObj, overrideName, overrideDesc) => {
|
|
352
|
-
// PLAN-DISPATCH: the orchestrator gets NO subagent tools. ADK refuses
|
|
353
|
-
// to combine outputSchema with AgentTool delegation on one agent (see
|
|
354
|
-
// config/agents/critic.yaml), and the classifier needs the schema —
|
|
355
|
-
// code, not the model, performs the hand-off.
|
|
356
|
-
const compiledTools = isDispatchSyndicate(configObj)
|
|
357
|
-
? []
|
|
358
|
-
: await Promise.all((configObj.subagents || []).map(async (subCfg) => new AgentTool({ agent: await compileSubagent(subCfg) })));
|
|
359
|
-
const orchestratorDirectTools = resolveTools(configObj.orchestrator.tools);
|
|
360
|
-
compiledTools.push(...orchestratorDirectTools);
|
|
361
|
-
return new LlmAgent({
|
|
362
|
-
name: overrideName || configObj.orchestrator.name,
|
|
363
|
-
description: overrideDesc || configObj.orchestrator.description,
|
|
364
|
-
model: resolveModelForRequest(configObj.orchestrator.model),
|
|
365
|
-
instruction: configObj.orchestrator.instruction,
|
|
366
|
-
tools: compiledTools.length > 0 ? compiledTools : undefined,
|
|
367
|
-
outputSchema: configObj.orchestrator.outputSchema,
|
|
368
|
-
generateContentConfig: {
|
|
369
|
-
...configObj.orchestrator.generateContentConfig,
|
|
370
|
-
toolConfig: {
|
|
371
|
-
...configObj.orchestrator.generateContentConfig?.toolConfig,
|
|
372
|
-
includeServerSideToolInvocations: true
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
});
|
|
345
|
+
// Compile ADK nodes through the shared compiler (lib/compile.ts) — the
|
|
346
|
+
// observatory runs the same function, so the graph an eval measures is
|
|
347
|
+
// the graph this server serves. compileSubagent builds ONE runnable
|
|
348
|
+
// agent from a subagent entry; in DELEGATE mode compileGraph wraps it in
|
|
349
|
+
// an AgentTool, in PLAN-DISPATCH the same agent is run directly as the
|
|
350
|
+
// chosen route, so both methods dispatch to identical agents.
|
|
351
|
+
const compileOpts = {
|
|
352
|
+
resolveModel: resolveModelForRequest,
|
|
353
|
+
onUnknownTool: (name) => console.warn(`[A2A] WARNING: Unknown tool '${name}' — skipping.`),
|
|
354
|
+
log: (message) => console.log(`[A2A] ${message}`),
|
|
376
355
|
};
|
|
356
|
+
const compileSubagent = (subCfg) => compileSubagentShared(subCfg, compileOpts);
|
|
357
|
+
const compileGraph = (configObj) => compileGraphShared(configObj, compileOpts);
|
|
377
358
|
const ensureSession = async (sessionId) => {
|
|
378
359
|
const existing = await this.sessionService.getSession({
|
|
379
360
|
sessionId,
|
|
@@ -481,7 +462,8 @@ class SyndicateExecutor {
|
|
|
481
462
|
taskId,
|
|
482
463
|
contextId,
|
|
483
464
|
eventBus,
|
|
484
|
-
trace:
|
|
465
|
+
trace: true, // one row per classification: misroutes are diagnosable from stored data
|
|
466
|
+
stage: 'classify',
|
|
485
467
|
publishToolStatus: false, // a dispatch classifier holds no tools
|
|
486
468
|
sessionService: routerSessions
|
|
487
469
|
});
|
|
@@ -530,7 +512,9 @@ class SyndicateExecutor {
|
|
|
530
512
|
contextId,
|
|
531
513
|
eventBus,
|
|
532
514
|
trace: true,
|
|
515
|
+
stage: 'dispatch',
|
|
533
516
|
publishToolStatus: true,
|
|
517
|
+
route: resolution,
|
|
534
518
|
sessionService: new ProjectedSessionService(this.sessionService, routeCfg.name)
|
|
535
519
|
});
|
|
536
520
|
if (turn.error) {
|
|
@@ -560,6 +544,7 @@ class SyndicateExecutor {
|
|
|
560
544
|
contextId,
|
|
561
545
|
eventBus,
|
|
562
546
|
trace: true,
|
|
547
|
+
stage: 'delegate',
|
|
563
548
|
publishToolStatus: true
|
|
564
549
|
});
|
|
565
550
|
if (turn.error) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "melchizedek-agents",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "A multi-model, multi-agent orchestration framework on Google ADK
|
|
3
|
+
"version": "0.9.3",
|
|
4
|
+
"description": "A multi-model, multi-agent orchestration framework on Google ADK \u2014 YAML-defined syndicates, Supabase session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
-
"//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency
|
|
7
|
+
"//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency \u2014 the consumer's app owns the ADK instance so the model registry is a singleton.",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=22.0.0"
|
|
10
10
|
},
|
|
@@ -67,7 +67,10 @@
|
|
|
67
67
|
"start:a2a": "node --disable-warning=DEP0040 --experimental-strip-types scripts/a2a_server.ts",
|
|
68
68
|
"test": "node --disable-warning=DEP0040 --experimental-strip-types --test tests/**/*.test.ts",
|
|
69
69
|
"memory": "node --disable-warning=DEP0040 --experimental-strip-types scripts/memory_admin.ts",
|
|
70
|
-
"db:purge": "node --disable-warning=DEP0040 --experimental-strip-types scripts/purge_supabase.ts"
|
|
70
|
+
"db:purge": "node --disable-warning=DEP0040 --experimental-strip-types scripts/purge_supabase.ts",
|
|
71
|
+
"telemetry:stats": "node --disable-warning=DEP0040 --experimental-strip-types scripts/telemetry_admin.ts stats",
|
|
72
|
+
"telemetry:prune": "node --disable-warning=DEP0040 --experimental-strip-types scripts/telemetry_admin.ts prune",
|
|
73
|
+
"telemetry:replay": "node --disable-warning=DEP0040 --experimental-strip-types scripts/telemetry_admin.ts replay"
|
|
71
74
|
},
|
|
72
75
|
"peerDependencies": {
|
|
73
76
|
"@google/adk": "^1.3.0"
|
|
@@ -78,6 +81,7 @@
|
|
|
78
81
|
"@google/genai": "2.2.0",
|
|
79
82
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
80
83
|
"@opentelemetry/api": "^1.9.1",
|
|
84
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
|
81
85
|
"@opentelemetry/resources": "^2.7.1",
|
|
82
86
|
"@opentelemetry/sdk-trace-base": "^2.7.1",
|
|
83
87
|
"@opentelemetry/sdk-trace-node": "^2.7.1",
|