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.
Files changed (35) hide show
  1. package/AGENT_SETUP.md +2 -2
  2. package/DOCUMENTATION.md +13 -22
  3. package/README.md +22 -20
  4. package/config/agents/examples/ares.yaml +0 -2
  5. package/config/agents/examples/augustin.yaml +0 -2
  6. package/config/agents/examples/cartographers.yaml +0 -3
  7. package/config/agents/examples/council.yaml +1 -1
  8. package/config/agents/examples/image_production.yaml +0 -1
  9. package/config/agents/examples/librarian.yaml +0 -2
  10. package/config/agents/examples/patient_advocate.yaml +0 -2
  11. package/config/agents/examples/scriptorium.yaml +0 -3
  12. package/config/agents/examples/style_council.yaml +1 -5
  13. package/config/agents/examples/syndicate.yaml +0 -3
  14. package/config/agents/examples/tutor.yaml +1 -1
  15. package/config/agents/syndicateSchema.yaml +4 -2
  16. package/dist/lib/compile.d.ts +64 -0
  17. package/dist/lib/compile.js +105 -0
  18. package/dist/lib/models/claudeLlm.js +25 -1
  19. package/dist/lib/models/gptLlm.js +5 -2
  20. package/dist/lib/models/ollamaLlm.d.ts +2 -0
  21. package/dist/lib/models/ollamaLlm.js +4 -0
  22. package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
  23. package/dist/lib/models/openAiCompatibleLlm.js +14 -5
  24. package/dist/lib/models/schemaNormalize.d.ts +9 -0
  25. package/dist/lib/models/schemaNormalize.js +34 -0
  26. package/dist/lib/observability/embeddings.d.ts +21 -0
  27. package/dist/lib/observability/embeddings.js +47 -0
  28. package/dist/lib/observability/lineage.d.ts +35 -0
  29. package/dist/lib/observability/lineage.js +96 -0
  30. package/dist/lib/observability/supabaseSpanExporter.d.ts +186 -18
  31. package/dist/lib/observability/supabaseSpanExporter.js +377 -80
  32. package/dist/lib/observability/tracer.d.ts +74 -0
  33. package/dist/lib/observability/tracer.js +282 -1
  34. package/dist/scripts/a2a_server.js +57 -72
  35. 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 provider = new NodeTracerProvider({ spanProcessors });
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, LlmAgent, AgentTool, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
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 { resolveTools as resolveNamedTools } from "../lib/toolRegistry.js";
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
- const resolveTools = (toolNames = []) => resolveNamedTools(toolNames, (name) => console.warn(`[A2A] WARNING: Unknown tool '${name}' — skipping.`));
313
- // Compile ADK Nodes dynamically.
314
- //
315
- // compileSubagent builds ONE runnable agent from a subagent entry. In
316
- // DELEGATE mode compileGraph wraps its result in an AgentTool; in
317
- // PLAN-DISPATCH the same agent is run directly as the chosen route, so
318
- // both methods dispatch to identical agents and can never drift.
319
- const compileSubagent = async (subCfg) => {
320
- if (subCfg.yaml_reference) {
321
- console.log(`[A2A] Loading nested syndicate: ${subCfg.yaml_reference}`);
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: false, // one cheap classification — not worth a span
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.2",
4
- "description": "A multi-model, multi-agent orchestration framework on Google ADK — YAML-defined syndicates, Supabase session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
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 — the consumer's app owns the ADK instance so the model registry is a singleton.",
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",