melchizedek-agents 0.9.0 → 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 (36) hide show
  1. package/AGENT_SETUP.md +14 -2
  2. package/DOCUMENTATION.md +13 -22
  3. package/QUICKSTART.md +35 -6
  4. package/README.md +22 -19
  5. package/config/agents/examples/ares.yaml +0 -2
  6. package/config/agents/examples/augustin.yaml +248 -0
  7. package/config/agents/examples/cartographers.yaml +0 -3
  8. package/config/agents/examples/council.yaml +1 -1
  9. package/config/agents/examples/image_production.yaml +0 -1
  10. package/config/agents/examples/librarian.yaml +0 -2
  11. package/config/agents/examples/patient_advocate.yaml +0 -2
  12. package/config/agents/examples/scriptorium.yaml +0 -3
  13. package/config/agents/examples/style_council.yaml +1 -5
  14. package/config/agents/examples/syndicate.yaml +0 -3
  15. package/config/agents/examples/tutor.yaml +1 -1
  16. package/config/agents/syndicateSchema.yaml +4 -2
  17. package/dist/lib/compile.d.ts +64 -0
  18. package/dist/lib/compile.js +105 -0
  19. package/dist/lib/models/claudeLlm.js +25 -1
  20. package/dist/lib/models/gptLlm.js +5 -2
  21. package/dist/lib/models/ollamaLlm.d.ts +2 -0
  22. package/dist/lib/models/ollamaLlm.js +4 -0
  23. package/dist/lib/models/openAiCompatibleLlm.d.ts +2 -0
  24. package/dist/lib/models/openAiCompatibleLlm.js +14 -5
  25. package/dist/lib/models/schemaNormalize.d.ts +9 -0
  26. package/dist/lib/models/schemaNormalize.js +34 -0
  27. package/dist/lib/observability/embeddings.d.ts +21 -0
  28. package/dist/lib/observability/embeddings.js +47 -0
  29. package/dist/lib/observability/lineage.d.ts +35 -0
  30. package/dist/lib/observability/lineage.js +96 -0
  31. package/dist/lib/observability/supabaseSpanExporter.d.ts +186 -18
  32. package/dist/lib/observability/supabaseSpanExporter.js +377 -80
  33. package/dist/lib/observability/tracer.d.ts +74 -0
  34. package/dist/lib/observability/tracer.js +282 -1
  35. package/dist/scripts/a2a_server.js +85 -75
  36. package/package.json +9 -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
  }
@@ -1,4 +1,6 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
2
4
  import express from 'express';
3
5
  import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from "../lib/grounding.js";
4
6
  import rateLimit from 'express-rate-limit';
@@ -8,13 +10,13 @@ import { loadEnv } from "../lib/loadEnv.js";
8
10
  import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
9
11
  import { AsyncLocalStorage } from 'async_hooks';
10
12
  import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from '@a2a-js/sdk/server/express';
11
- import { Runner, LlmAgent, AgentTool, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
13
+ import { Runner, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
12
14
  import { loadSyndicate } from "../lib/loadSyndicate.js";
13
15
  import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from "../lib/dispatch.js";
14
16
  import { traceAgentRun } from "../lib/observability/tracer.js";
17
+ import { configDigest } from "../lib/observability/lineage.js";
15
18
  import { resolveModel } from "../lib/models/registry.js";
16
- import { resolveTools as resolveNamedTools } from "../lib/toolRegistry.js";
17
- import { createMcpTools } from "../lib/tools/mcpToolFactory.js";
19
+ import { compileGraph as compileGraphShared, compileSubagent as compileSubagentShared } from "../lib/compile.js";
18
20
  import { hasSupabaseCredentials, createSupabaseServices } from "../lib/persistence/supabaseProvider.js";
19
21
  import { ProjectedSessionService, renderTranscriptDigest } from "../lib/session/transcript.js";
20
22
  const requestContextStorage = new AsyncLocalStorage();
@@ -108,11 +110,24 @@ class SyndicateExecutor {
108
110
  config;
109
111
  sessionService;
110
112
  memoryService;
113
+ /** Provenance stamp for every turn this executor serves (lineage.ts). */
114
+ configHash;
111
115
  constructor(config, sessionService, memoryService) {
112
116
  this.config = config;
113
117
  this.sessionService = sessionService;
114
118
  this.memoryService = memoryService;
115
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
+ }
116
131
  /**
117
132
  * Run ONE agent for ONE turn against a named session, draining its event
118
133
  * stream. DELEGATE mode calls this once (the orchestrator); PLAN-DISPATCH
@@ -136,16 +151,36 @@ class SyndicateExecutor {
136
151
  newMessage: { role: 'user', parts: userParts },
137
152
  ...(this.config.max_steps !== undefined ? { maxSteps: this.config.max_steps } : {})
138
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
+ };
139
166
  if (params.trace) {
140
167
  stream = traceAgentRun(stream, {
141
168
  syndicateName: this.config.syndicate_name || 'melchizedek-syndicate',
142
169
  bindings: this.config.variables || {},
143
- 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
+ }),
144
182
  });
145
183
  }
146
- let combinedText = '';
147
- let lastToolResultText = '';
148
- const invokedToolNames = new Set();
149
184
  let lastTokenTotal = 0;
150
185
  let lastThinkingTokens = 0;
151
186
  let error;
@@ -307,71 +342,19 @@ class SyndicateExecutor {
307
342
  apiKey: authContext.apiKey,
308
343
  defaultProvider: authContext.provider,
309
344
  });
310
- const resolveTools = (toolNames = []) => resolveNamedTools(toolNames, (name) => console.warn(`[A2A] WARNING: Unknown tool '${name}' — skipping.`));
311
- // Compile ADK Nodes dynamically.
312
- //
313
- // compileSubagent builds ONE runnable agent from a subagent entry. In
314
- // DELEGATE mode compileGraph wraps its result in an AgentTool; in
315
- // PLAN-DISPATCH the same agent is run directly as the chosen route, so
316
- // both methods dispatch to identical agents and can never drift.
317
- const compileSubagent = async (subCfg) => {
318
- if (subCfg.yaml_reference) {
319
- console.log(`[A2A] Loading nested syndicate: ${subCfg.yaml_reference}`);
320
- const nestedConfig = loadSyndicate(subCfg.yaml_reference);
321
- return compileGraph(nestedConfig, subCfg.name, subCfg.description);
322
- }
323
- const subTools = resolveTools(subCfg.tools);
324
- if (subCfg.mcp_server_url) {
325
- console.log(`[A2A] Loading MCP tools: ${subCfg.mcp_server_url}`);
326
- const mcpTools = await createMcpTools(subCfg.mcp_server_url);
327
- for (const mcpTool of mcpTools) {
328
- if (!subTools.some(t => t.name === mcpTool.name)) {
329
- subTools.push(mcpTool);
330
- }
331
- }
332
- }
333
- return new LlmAgent({
334
- name: subCfg.name,
335
- description: subCfg.description,
336
- model: resolveModelForRequest(subCfg.model),
337
- instruction: subCfg.instruction,
338
- tools: subTools.length > 0 ? subTools : undefined,
339
- outputSchema: subCfg.outputSchema,
340
- generateContentConfig: {
341
- ...subCfg.generateContentConfig,
342
- toolConfig: {
343
- ...subCfg.generateContentConfig?.toolConfig,
344
- includeServerSideToolInvocations: true
345
- }
346
- }
347
- });
348
- };
349
- const compileGraph = async (configObj, overrideName, overrideDesc) => {
350
- // PLAN-DISPATCH: the orchestrator gets NO subagent tools. ADK refuses
351
- // to combine outputSchema with AgentTool delegation on one agent (see
352
- // config/agents/critic.yaml), and the classifier needs the schema —
353
- // code, not the model, performs the hand-off.
354
- const compiledTools = isDispatchSyndicate(configObj)
355
- ? []
356
- : await Promise.all((configObj.subagents || []).map(async (subCfg) => new AgentTool({ agent: await compileSubagent(subCfg) })));
357
- const orchestratorDirectTools = resolveTools(configObj.orchestrator.tools);
358
- compiledTools.push(...orchestratorDirectTools);
359
- return new LlmAgent({
360
- name: overrideName || configObj.orchestrator.name,
361
- description: overrideDesc || configObj.orchestrator.description,
362
- model: resolveModelForRequest(configObj.orchestrator.model),
363
- instruction: configObj.orchestrator.instruction,
364
- tools: compiledTools.length > 0 ? compiledTools : undefined,
365
- outputSchema: configObj.orchestrator.outputSchema,
366
- generateContentConfig: {
367
- ...configObj.orchestrator.generateContentConfig,
368
- toolConfig: {
369
- ...configObj.orchestrator.generateContentConfig?.toolConfig,
370
- includeServerSideToolInvocations: true
371
- }
372
- }
373
- });
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}`),
374
355
  };
356
+ const compileSubagent = (subCfg) => compileSubagentShared(subCfg, compileOpts);
357
+ const compileGraph = (configObj) => compileGraphShared(configObj, compileOpts);
375
358
  const ensureSession = async (sessionId) => {
376
359
  const existing = await this.sessionService.getSession({
377
360
  sessionId,
@@ -479,7 +462,8 @@ class SyndicateExecutor {
479
462
  taskId,
480
463
  contextId,
481
464
  eventBus,
482
- 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',
483
467
  publishToolStatus: false, // a dispatch classifier holds no tools
484
468
  sessionService: routerSessions
485
469
  });
@@ -528,7 +512,9 @@ class SyndicateExecutor {
528
512
  contextId,
529
513
  eventBus,
530
514
  trace: true,
515
+ stage: 'dispatch',
531
516
  publishToolStatus: true,
517
+ route: resolution,
532
518
  sessionService: new ProjectedSessionService(this.sessionService, routeCfg.name)
533
519
  });
534
520
  if (turn.error) {
@@ -558,6 +544,7 @@ class SyndicateExecutor {
558
544
  contextId,
559
545
  eventBus,
560
546
  trace: true,
547
+ stage: 'delegate',
561
548
  publishToolStatus: true
562
549
  });
563
550
  if (turn.error) {
@@ -990,8 +977,31 @@ export async function startServer(syndicateName = 'syndicate.yaml') {
990
977
  console.log(`[A2A] Exposer server boot complete on port ${PORT}`);
991
978
  });
992
979
  }
993
- if (import.meta.url === `file://${process.argv[1]}`) {
994
- // Running as a script
980
+ // Run-as-main guard. Compare against the REALPATH of argv[1]: when invoked
981
+ // through the npm bin (`npx melchizedek-serve`), argv[1] is the .bin symlink
982
+ // while import.meta.url is the resolved file — a naive string compare never
983
+ // matches and the server silently does nothing (the 0.9.0 bin bug).
984
+ const invokedAsMain = (() => {
985
+ if (!process.argv[1])
986
+ return false;
987
+ try {
988
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
989
+ }
990
+ catch {
991
+ return import.meta.url === `file://${process.argv[1]}`;
992
+ }
993
+ })();
994
+ if (invokedAsMain) {
995
995
  const syndicateFile = process.argv[2] || 'syndicate.yaml';
996
- startServer(syndicateFile).catch(console.error);
996
+ startServer(syndicateFile).catch((error) => {
997
+ if (error?.code === 'ENOENT' && !process.argv[2]) {
998
+ // Package consumers rarely have a syndicate.yaml — name the fix.
999
+ console.error('[A2A] No default syndicate.yaml in your agents directory.\n' +
1000
+ ' Pass your syndicate: melchizedek-serve <name>.yaml\n' +
1001
+ ' (agents directory: option --, MELCHIZEDEK_AGENTS_DIR, or <cwd>/config/agents)');
1002
+ process.exit(1);
1003
+ }
1004
+ console.error(error);
1005
+ process.exit(1);
1006
+ });
997
1007
  }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "melchizedek-agents",
3
- "version": "0.9.0",
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
  },
@@ -57,6 +57,7 @@
57
57
  "syndicate:librarian": "node --disable-warning=DEP0040 --experimental-strip-types scripts/syndicate_chat.ts --syndicate librarian",
58
58
  "syndicate:scriptorium": "node --disable-warning=DEP0040 --experimental-strip-types scripts/syndicate_chat.ts --syndicate scriptorium",
59
59
  "syndicate:cartographers": "node --disable-warning=DEP0040 --experimental-strip-types scripts/syndicate_chat.ts --syndicate cartographers",
60
+ "syndicate:augustin": "node --disable-warning=DEP0040 --experimental-strip-types scripts/syndicate_chat.ts --syndicate augustin",
60
61
  "demo:models": "node --disable-warning=DEP0040 --experimental-strip-types scripts/demo_model_optionality.ts",
61
62
  "demo:direct": "node --disable-warning=DEP0040 --experimental-strip-types scripts/direct_call.ts",
62
63
  "mcp:demo": "node --disable-warning=DEP0040 --experimental-strip-types scripts/demo_mcp_server.ts",
@@ -66,7 +67,10 @@
66
67
  "start:a2a": "node --disable-warning=DEP0040 --experimental-strip-types scripts/a2a_server.ts",
67
68
  "test": "node --disable-warning=DEP0040 --experimental-strip-types --test tests/**/*.test.ts",
68
69
  "memory": "node --disable-warning=DEP0040 --experimental-strip-types scripts/memory_admin.ts",
69
- "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"
70
74
  },
71
75
  "peerDependencies": {
72
76
  "@google/adk": "^1.3.0"
@@ -77,6 +81,7 @@
77
81
  "@google/genai": "2.2.0",
78
82
  "@modelcontextprotocol/sdk": "^1.29.0",
79
83
  "@opentelemetry/api": "^1.9.1",
84
+ "@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
80
85
  "@opentelemetry/resources": "^2.7.1",
81
86
  "@opentelemetry/sdk-trace-base": "^2.7.1",
82
87
  "@opentelemetry/sdk-trace-node": "^2.7.1",