melchizedek-agents 0.9.0

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 (111) hide show
  1. package/AGENT_SETUP.md +182 -0
  2. package/DOCUMENTATION.md +614 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +164 -0
  5. package/README.md +151 -0
  6. package/config/agents/README.md +27 -0
  7. package/config/agents/examples/ares.yaml +74 -0
  8. package/config/agents/examples/cartographers.yaml +115 -0
  9. package/config/agents/examples/claude.yaml +17 -0
  10. package/config/agents/examples/council.yaml +65 -0
  11. package/config/agents/examples/critic.yaml +74 -0
  12. package/config/agents/examples/delegation.yaml +29 -0
  13. package/config/agents/examples/hierarchical.yaml +26 -0
  14. package/config/agents/examples/image_production.yaml +112 -0
  15. package/config/agents/examples/librarian.yaml +75 -0
  16. package/config/agents/examples/model_zoo.yaml +81 -0
  17. package/config/agents/examples/patient_advocate.yaml +107 -0
  18. package/config/agents/examples/scriptorium.yaml +96 -0
  19. package/config/agents/examples/style_council.yaml +83 -0
  20. package/config/agents/examples/syndicate.yaml +34 -0
  21. package/config/agents/examples/tutor.yaml +66 -0
  22. package/config/agents/syndicateSchema.yaml +291 -0
  23. package/dist/lib/config.d.ts +108 -0
  24. package/dist/lib/config.js +110 -0
  25. package/dist/lib/dispatch.d.ts +133 -0
  26. package/dist/lib/dispatch.js +182 -0
  27. package/dist/lib/grounding.d.ts +28 -0
  28. package/dist/lib/grounding.js +61 -0
  29. package/dist/lib/index.d.ts +14 -0
  30. package/dist/lib/index.js +13 -0
  31. package/dist/lib/loadEnv.d.ts +21 -0
  32. package/dist/lib/loadEnv.js +58 -0
  33. package/dist/lib/loadSyndicate.d.ts +177 -0
  34. package/dist/lib/loadSyndicate.js +264 -0
  35. package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
  36. package/dist/lib/memory/supabaseMemoryService.js +499 -0
  37. package/dist/lib/models/claudeLlm.d.ts +93 -0
  38. package/dist/lib/models/claudeLlm.js +332 -0
  39. package/dist/lib/models/gptLlm.d.ts +110 -0
  40. package/dist/lib/models/gptLlm.js +497 -0
  41. package/dist/lib/models/grokLlm.d.ts +64 -0
  42. package/dist/lib/models/grokLlm.js +86 -0
  43. package/dist/lib/models/ollamaLlm.d.ts +105 -0
  44. package/dist/lib/models/ollamaLlm.js +134 -0
  45. package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
  46. package/dist/lib/models/openAiCompatibleLlm.js +331 -0
  47. package/dist/lib/models/providerMap.d.ts +28 -0
  48. package/dist/lib/models/providerMap.js +45 -0
  49. package/dist/lib/models/registry.d.ts +71 -0
  50. package/dist/lib/models/registry.js +159 -0
  51. package/dist/lib/models/schemaNormalize.d.ts +22 -0
  52. package/dist/lib/models/schemaNormalize.js +58 -0
  53. package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
  54. package/dist/lib/observability/supabaseSpanExporter.js +115 -0
  55. package/dist/lib/observability/tracer.d.ts +44 -0
  56. package/dist/lib/observability/tracer.js +242 -0
  57. package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
  58. package/dist/lib/persistence/supabaseProvider.js +88 -0
  59. package/dist/lib/session/supabaseSessionService.d.ts +26 -0
  60. package/dist/lib/session/supabaseSessionService.js +193 -0
  61. package/dist/lib/session/transcript.d.ts +193 -0
  62. package/dist/lib/session/transcript.js +309 -0
  63. package/dist/lib/toolRegistry.d.ts +15 -0
  64. package/dist/lib/toolRegistry.js +58 -0
  65. package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
  66. package/dist/lib/tools/collectionsSearchTool.js +86 -0
  67. package/dist/lib/tools/generateImageTool.d.ts +16 -0
  68. package/dist/lib/tools/generateImageTool.js +119 -0
  69. package/dist/lib/tools/inspectImageTool.d.ts +21 -0
  70. package/dist/lib/tools/inspectImageTool.js +97 -0
  71. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  72. package/dist/lib/tools/mcpToolFactory.js +116 -0
  73. package/dist/lib/tools/toolContract.d.ts +78 -0
  74. package/dist/lib/tools/toolContract.js +129 -0
  75. package/dist/lib/tools/webExtractTool.d.ts +70 -0
  76. package/dist/lib/tools/webExtractTool.js +461 -0
  77. package/dist/lib/tools/webSearchTool.d.ts +53 -0
  78. package/dist/lib/tools/webSearchTool.js +126 -0
  79. package/dist/lib/tools/wikiTools.d.ts +112 -0
  80. package/dist/lib/tools/wikiTools.js +697 -0
  81. package/dist/lib/tools/xSearchTool.d.ts +61 -0
  82. package/dist/lib/tools/xSearchTool.js +125 -0
  83. package/dist/lib/wiki/agentRun.d.ts +39 -0
  84. package/dist/lib/wiki/agentRun.js +79 -0
  85. package/dist/lib/wiki/builder.d.ts +71 -0
  86. package/dist/lib/wiki/builder.js +165 -0
  87. package/dist/lib/wiki/dive.d.ts +56 -0
  88. package/dist/lib/wiki/dive.js +208 -0
  89. package/dist/lib/wiki/entities.d.ts +220 -0
  90. package/dist/lib/wiki/entities.js +661 -0
  91. package/dist/lib/wiki/extract.d.ts +97 -0
  92. package/dist/lib/wiki/extract.js +305 -0
  93. package/dist/lib/wiki/fill.d.ts +37 -0
  94. package/dist/lib/wiki/fill.js +175 -0
  95. package/dist/lib/wiki/format.d.ts +132 -0
  96. package/dist/lib/wiki/format.js +177 -0
  97. package/dist/lib/wiki/graph.d.ts +80 -0
  98. package/dist/lib/wiki/graph.js +147 -0
  99. package/dist/lib/wiki/lint.d.ts +58 -0
  100. package/dist/lib/wiki/lint.js +267 -0
  101. package/dist/lib/wiki/markdown.d.ts +127 -0
  102. package/dist/lib/wiki/markdown.js +336 -0
  103. package/dist/lib/wiki/pipeline.d.ts +69 -0
  104. package/dist/lib/wiki/pipeline.js +119 -0
  105. package/dist/lib/wiki/vault.d.ts +84 -0
  106. package/dist/lib/wiki/vault.js +194 -0
  107. package/dist/scripts/a2a_server.d.ts +2 -0
  108. package/dist/scripts/a2a_server.js +997 -0
  109. package/dist/scripts/syndicate_chat.d.ts +2 -0
  110. package/dist/scripts/syndicate_chat.js +485 -0
  111. package/package.json +96 -0
@@ -0,0 +1,997 @@
1
+ #!/usr/bin/env node
2
+ import express from 'express';
3
+ import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from "../lib/grounding.js";
4
+ import rateLimit from 'express-rate-limit';
5
+ import { AGENT_CARD_PATH } from '@a2a-js/sdk';
6
+ import { timingSafeEqual, createHash } from 'node:crypto';
7
+ import { loadEnv } from "../lib/loadEnv.js";
8
+ import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
9
+ import { AsyncLocalStorage } from 'async_hooks';
10
+ import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from '@a2a-js/sdk/server/express';
11
+ import { Runner, LlmAgent, AgentTool, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
12
+ import { loadSyndicate } from "../lib/loadSyndicate.js";
13
+ import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from "../lib/dispatch.js";
14
+ import { traceAgentRun } from "../lib/observability/tracer.js";
15
+ import { resolveModel } from "../lib/models/registry.js";
16
+ import { resolveTools as resolveNamedTools } from "../lib/toolRegistry.js";
17
+ import { createMcpTools } from "../lib/tools/mcpToolFactory.js";
18
+ import { hasSupabaseCredentials, createSupabaseServices } from "../lib/persistence/supabaseProvider.js";
19
+ import { ProjectedSessionService, renderTranscriptDigest } from "../lib/session/transcript.js";
20
+ const requestContextStorage = new AsyncLocalStorage();
21
+ const A2A_APP_NAME = 'melchizedek-a2a';
22
+ /** X-User-Id must be short and filesystem/key-safe; anything else is rejected. */
23
+ const SITE_USER_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
24
+ /**
25
+ * Memory/session silo derivation.
26
+ * Base silo: a hash of the caller's API key (one bucket per credential).
27
+ * With X-User-Id, the end-user is siloed BENEATH the key hash — so a site
28
+ * proxying many humans through one backend key gets one bucket per human,
29
+ * and no site can ever reach into another credential's buckets, because
30
+ * the key hash always prefixes.
31
+ */
32
+ function deriveUserId(ctx) {
33
+ const keyHash = createHash('sha256').update(ctx.apiKey).digest('hex').slice(0, 16);
34
+ return ctx.siteUserId ? `a2a-${keyHash}/${ctx.siteUserId}` : `a2a-${keyHash}`;
35
+ }
36
+ // ── Dynamic Agent Card Compiler ──────────────────────────────────────────
37
+ // Synthesizes the public card configuration from current registry variables
38
+ function compileAgentCard(config) {
39
+ const orchestratorConfig = config.orchestrator;
40
+ const subagents = config.subagents || [];
41
+ const url = process.env.PUBLIC_URL || 'http://localhost:4000';
42
+ return {
43
+ name: orchestratorConfig.name,
44
+ description: orchestratorConfig.description || 'Syndicate Orchestrator Agent',
45
+ protocolVersion: '0.3.0',
46
+ version: '2.0.0',
47
+ url: `${url}/a2a/jsonrpc`,
48
+ preferredTransport: 'JSONRPC',
49
+ capabilities: {
50
+ streaming: true,
51
+ pushNotifications: false,
52
+ stateTransitionHistory: true
53
+ },
54
+ defaultInputModes: ['text/plain'],
55
+ defaultOutputModes: ['text/plain'],
56
+ skills: subagents.map(sub => ({
57
+ id: sub.name,
58
+ name: sub.name,
59
+ description: sub.description || 'Subagent collaborator',
60
+ tags: sub.tools || []
61
+ })),
62
+ additionalInterfaces: [
63
+ { url: `${url}/a2a/jsonrpc`, transport: 'JSONRPC' },
64
+ { url: `${url}/a2a/rest`, transport: 'HTTP+JSON' }
65
+ ]
66
+ };
67
+ }
68
+ // ── Log Formatting Helpers ────────────────────────────────────────────────
69
+ // Summarises tool call args into a single readable string, truncating long values.
70
+ function formatToolArgs(args) {
71
+ if (!args || Object.keys(args).length === 0)
72
+ return '';
73
+ return Object.entries(args)
74
+ .map(([k, v]) => {
75
+ const raw = typeof v === 'string' ? v : JSON.stringify(v);
76
+ return `${k}=${raw.length > 60 ? `${raw.slice(0, 60)}...` : raw}`;
77
+ })
78
+ .join(', ');
79
+ }
80
+ // ── Progress telemetry ────────────────────────────────────────────────────
81
+ // `[STATUS]` is a consumer contract, not decoration: A2A clients scan task
82
+ // history for messages carrying this prefix and surface them as live progress
83
+ // (the Discord bot edits its "Considering your query..." placeholder with the
84
+ // newest one). Keep these lines short and human-readable — they are read by a
85
+ // person waiting for an answer, not by a parser.
86
+ function publishWorking(eventBus, taskId, contextId, text) {
87
+ eventBus.publish({
88
+ kind: 'status-update',
89
+ taskId,
90
+ contextId,
91
+ status: {
92
+ state: 'working',
93
+ message: {
94
+ kind: 'message',
95
+ messageId: crypto.randomUUID(),
96
+ role: 'agent',
97
+ parts: [{ kind: 'text', text: `[STATUS] ${text}` }],
98
+ contextId,
99
+ taskId
100
+ },
101
+ timestamp: new Date().toISOString()
102
+ },
103
+ final: false
104
+ });
105
+ }
106
+ // ── Native ADK AgentExecutor Bridge ───────────────────────────────────────
107
+ class SyndicateExecutor {
108
+ config;
109
+ sessionService;
110
+ memoryService;
111
+ constructor(config, sessionService, memoryService) {
112
+ this.config = config;
113
+ this.sessionService = sessionService;
114
+ this.memoryService = memoryService;
115
+ }
116
+ /**
117
+ * Run ONE agent for ONE turn against a named session, draining its event
118
+ * stream. DELEGATE mode calls this once (the orchestrator); PLAN-DISPATCH
119
+ * calls it twice — the classifier, then the route it chose.
120
+ *
121
+ * Errors are RETURNED rather than published, because the two callers want
122
+ * different things from them: a failed specialist is a failed task, but a
123
+ * failed classifier must fall back to the default route and still answer.
124
+ */
125
+ async runTurn(params) {
126
+ const { agent, sessionId, userId, userParts, taskId, contextId, eventBus } = params;
127
+ const runner = new Runner({
128
+ agent,
129
+ appName: A2A_APP_NAME,
130
+ sessionService: params.sessionService ?? this.sessionService,
131
+ ...(this.memoryService ? { memoryService: this.memoryService } : {})
132
+ });
133
+ let stream = runner.runAsync({
134
+ userId,
135
+ sessionId,
136
+ newMessage: { role: 'user', parts: userParts },
137
+ ...(this.config.max_steps !== undefined ? { maxSteps: this.config.max_steps } : {})
138
+ });
139
+ if (params.trace) {
140
+ stream = traceAgentRun(stream, {
141
+ syndicateName: this.config.syndicate_name || 'melchizedek-syndicate',
142
+ bindings: this.config.variables || {},
143
+ input: userParts
144
+ });
145
+ }
146
+ let combinedText = '';
147
+ let lastToolResultText = '';
148
+ const invokedToolNames = new Set();
149
+ let lastTokenTotal = 0;
150
+ let lastThinkingTokens = 0;
151
+ let error;
152
+ const grounding = newGroundingState();
153
+ let groundingAnnounced = false;
154
+ for await (const event of stream) {
155
+ const evAny = event;
156
+ // Native search grounding rides on the model event, not on a tool call.
157
+ if (collectGrounding(evAny, grounding)) {
158
+ const firstSight = grounding.queries.size + grounding.sources.size > 0 && !groundingAnnounced;
159
+ console.log(`[A2A] ⌕ Grounding: ${describeGrounding(grounding)}`);
160
+ if (params.publishToolStatus && firstSight) {
161
+ // Same shape as a function-tool line so consumers that parse
162
+ // "Invoking tool:" (nihilistic-penguin RouteTrace) list it.
163
+ publishWorking(eventBus, taskId, contextId, `Invoking tool: web_search`);
164
+ }
165
+ groundingAnnounced = true;
166
+ }
167
+ if ((evAny.errorCode || evAny.errorMessage) && evAny.errorCode !== 'STOP') {
168
+ error = {
169
+ code: evAny.errorCode ?? 'ERROR',
170
+ message: evAny.errorMessage ?? ''
171
+ };
172
+ console.error(`[A2A] Error [${error.code}]: ${error.message}`);
173
+ break;
174
+ }
175
+ // Tool calls — log formatted name + args, publish working status to eventBus
176
+ const calls = getFunctionCalls(event);
177
+ if (calls && calls.length > 0) {
178
+ for (const call of calls) {
179
+ if (call.name === 'transfer_to_agent') {
180
+ const target = call.args?.agentName ?? 'unknown';
181
+ console.log(`[A2A] → Delegating to: ${target}`);
182
+ if (params.publishToolStatus) {
183
+ publishWorking(eventBus, taskId, contextId, `Delegating to subagent: ${target}`);
184
+ }
185
+ }
186
+ else {
187
+ const argStr = formatToolArgs(call.args);
188
+ if (call.name)
189
+ invokedToolNames.add(call.name);
190
+ console.log(`[A2A] → Tool: ${call.name}(${argStr})`);
191
+ if (params.publishToolStatus) {
192
+ publishWorking(eventBus, taskId, contextId, `Invoking tool: ${call.name}`);
193
+ }
194
+ }
195
+ }
196
+ }
197
+ // Tool responses — log name and result size (avoids dumping raw JSON/base64 to logs)
198
+ const responses = getFunctionResponses(event);
199
+ if (responses && responses.length > 0) {
200
+ for (const resp of responses) {
201
+ const respAny = resp;
202
+ const respName = respAny.name ?? respAny.functionResponse?.name ?? 'unknown';
203
+ const respContent = respAny.response ?? respAny.functionResponse?.response ?? {};
204
+ const resultStr = typeof respContent === 'string' ? respContent : JSON.stringify(respContent);
205
+ if (respName && respName !== 'unknown')
206
+ invokedToolNames.add(respName);
207
+ console.log(`[A2A] ← Result: ${respName} — ${resultStr.length.toLocaleString()} chars`);
208
+ // Kept as the DELEGATE-mode relay fallback below. Only plain text
209
+ // results qualify — a structured/base64 payload (e.g. image tools)
210
+ // is not a relayable answer.
211
+ const resultText = typeof respContent === 'string'
212
+ ? respContent
213
+ : typeof respContent.result === 'string' ? respContent.result : '';
214
+ if (resultText.trim()) {
215
+ lastToolResultText = resultText.trim();
216
+ }
217
+ }
218
+ }
219
+ // The answer is the LAST turn's text, not every turn's — the same rule
220
+ // AgentTool applied by returning only its final event. It mattered the
221
+ // moment specialists started running directly: a tool-using agent
222
+ // narrates between calls ("Pulling FinTwit-specific NVDA signal…"), and
223
+ // accumulating glued that commentary to the front of its report. A
224
+ // prompt rule against narrating was not enough on grok-4.5, so the rule
225
+ // lives here instead. Thinking traces are suppressed throughout (too
226
+ // verbose for the server log, and never part of the answer).
227
+ //
228
+ // Within ONE event, parts are joined — a single answer legitimately
229
+ // arrives in several parts. `partial` events are streaming chunks of the
230
+ // turn in progress, so they append rather than replace; this deployment
231
+ // does not enable streaming, but getting it wrong would truncate an
232
+ // answer, which is the failure this whole area exists to prevent.
233
+ let eventText = '';
234
+ if (event.content?.parts) {
235
+ for (const part of event.content.parts) {
236
+ const textContent = part.text ?? '';
237
+ const isThought = part.thought === true;
238
+ if (!isThought && textContent) {
239
+ eventText += textContent;
240
+ }
241
+ }
242
+ }
243
+ if (eventText) {
244
+ combinedText = evAny.partial === true ? combinedText + eventText : eventText;
245
+ }
246
+ }
247
+ const sourcesLine = webSourcesLine(grounding);
248
+ if (params.publishToolStatus && sourcesLine) {
249
+ // Published AFTER the loop so it lands once, with the full set. A
250
+ // consumer contract (penguin RouteTrace `_SOURCES_RE`): "Web sources: a, b".
251
+ publishWorking(eventBus, taskId, contextId, sourcesLine);
252
+ }
253
+ return {
254
+ text: combinedText.trim(),
255
+ lastToolResultText,
256
+ invokedToolNames,
257
+ grounding: (grounding.queries.size || grounding.sources.size)
258
+ ? { queries: [...grounding.queries], sources: [...grounding.sources] }
259
+ : undefined,
260
+ tokenInfo: lastTokenTotal > 0
261
+ ? ` | ${lastTokenTotal.toLocaleString()} tokens${lastThinkingTokens > 0 ? ` (${lastThinkingTokens.toLocaleString()} thinking)` : ''}`
262
+ : '',
263
+ error
264
+ };
265
+ }
266
+ async execute(requestContext, eventBus) {
267
+ const message = requestContext.userMessage;
268
+ const rawParts = message.content || message.parts || [];
269
+ const userParts = rawParts.map((p) => {
270
+ if (typeof p === 'string')
271
+ return { text: p };
272
+ return { text: p.text || '' };
273
+ });
274
+ const contextId = requestContext.contextId;
275
+ const taskId = requestContext.taskId;
276
+ const queryPreview = userParts.map((p) => p.text ?? '').join(' ').replace(/\n/g, ' ').slice(0, 120);
277
+ console.log(`[A2A] ─── Task ${taskId.slice(0, 8)} | ${this.config.syndicate_name}`);
278
+ console.log(`[A2A] Query: "${queryPreview}${queryPreview.length >= 120 ? '...' : ''}"`);
279
+ try {
280
+ // Initialize task on the event bus to allow non-blocking clients to start polling immediately
281
+ eventBus.publish({
282
+ id: taskId,
283
+ kind: 'task',
284
+ contextId,
285
+ status: {
286
+ state: 'submitted',
287
+ timestamp: new Date().toISOString()
288
+ },
289
+ history: []
290
+ });
291
+ const authContext = requestContextStorage.getStore();
292
+ if (!authContext) {
293
+ throw new Error('No authentication context available.');
294
+ }
295
+ // Session & memory isolation: per-credential by API-key hash, and
296
+ // per end-user beneath it when the caller supplies X-User-Id.
297
+ // See deriveUserId() for the silo contract.
298
+ const userId = deriveUserId(authContext);
299
+ if (userParts.length === 0)
300
+ throw new Error('Message parts are required.');
301
+ // Model resolution: the YAML model id always wins — its prefix names
302
+ // the provider (claude-*, gpt-*, grok-*, ollama/*, else Gemini). The
303
+ // X-Provider header is DEPRECATED and only picks a default model when
304
+ // the YAML omits `model` entirely. lib/models/registry.ts is the
305
+ // single implementation, shared with the CLI path.
306
+ const resolveModelForRequest = (modelName) => resolveModel(modelName, {
307
+ apiKey: authContext.apiKey,
308
+ defaultProvider: authContext.provider,
309
+ });
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
+ });
374
+ };
375
+ const ensureSession = async (sessionId) => {
376
+ const existing = await this.sessionService.getSession({
377
+ sessionId,
378
+ appName: A2A_APP_NAME,
379
+ userId
380
+ });
381
+ if (existing)
382
+ return true;
383
+ await this.sessionService.createSession({
384
+ sessionId,
385
+ appName: A2A_APP_NAME,
386
+ userId
387
+ });
388
+ return false;
389
+ };
390
+ const failTask = (text) => {
391
+ eventBus.publish({
392
+ kind: 'status-update',
393
+ taskId,
394
+ contextId,
395
+ status: {
396
+ state: 'failed',
397
+ message: {
398
+ kind: 'message',
399
+ messageId: crypto.randomUUID(),
400
+ role: 'agent',
401
+ parts: [{ kind: 'text', text }],
402
+ contextId,
403
+ taskId
404
+ },
405
+ timestamp: new Date().toISOString()
406
+ },
407
+ final: true
408
+ });
409
+ };
410
+ const resumed = await ensureSession(contextId);
411
+ console.log(`[A2A] Session: ${resumed ? 'resumed' : 'new'} — context ${contextId.slice(0, 8)}`);
412
+ let finalText = '';
413
+ let tokenInfo = '';
414
+ let routerTokenInfo = '';
415
+ if (isDispatchSyndicate(this.config)) {
416
+ // ══ PLAN ═══════════════════════════════════════════════════════════
417
+ // Deterministic overrides come FIRST: when the message itself decides
418
+ // the route (an x.com link only XScout can open, an explicit demand
419
+ // for a named tool), there is nothing to classify. The classifier LLM
420
+ // call is skipped entirely — cheaper, and immune to a weak classifier
421
+ // out-voting a stated rule (2026-08-15).
422
+ const overrideWarnings = [];
423
+ const messageText = userParts.map((p) => p.text ?? '').join('\n');
424
+ let resolution = matchRouteOverride(messageText, this.config, overrideWarnings);
425
+ for (const warning of overrideWarnings)
426
+ console.warn(`[A2A] ⚠ ${warning}`);
427
+ if (resolution) {
428
+ console.log(`[A2A] ⇄ Route pinned by override: ${resolution.route}`);
429
+ }
430
+ else {
431
+ // ── The classifier reads the SHARED transcript ──────────────────
432
+ // Its JSON verdicts must never enter the user-facing transcript —
433
+ // the next specialist would read them as conversation — so it does
434
+ // not write to the shared session. It used to get its own durable
435
+ // `<contextId>::route` lane instead, and that lane was the bug: it
436
+ // accumulated the user's messages and the router's own verdicts and
437
+ // NOTHING ELSE, so the router had never seen a single answer it had
438
+ // routed. On 2026-08-15 a user's counter-argument to the desk's
439
+ // MU/SNDK/NVDA/AMD verdict read to it as a remark with no request
440
+ // behind it and went to small talk; its own rule 2 (redo/method
441
+ // complaint) and rule 5 (follow-ups on prior analysis) were dead
442
+ // letters, since neither can be recognised without the answer being
443
+ // followed up. The lane also had HOLES: a message pinned by
444
+ // `route_overrides` skips this branch entirely and was therefore
445
+ // never recorded, so the X recon that opened that same thread was
446
+ // missing from the router's history altogether.
447
+ //
448
+ // Now history arrives as INPUT — a compact digest of the real
449
+ // exchange, both sides of it — and the lane is a throwaway
450
+ // in-memory session that is discarded with the request. Nothing to
451
+ // keep in sync, nothing to leave holes in, nothing persisted.
452
+ const routerAgent = await compileGraph(this.config);
453
+ const routerSessionId = `${contextId}::route`;
454
+ const routerSessions = new InMemorySessionService();
455
+ await routerSessions.createSession({
456
+ sessionId: routerSessionId,
457
+ appName: A2A_APP_NAME,
458
+ userId
459
+ });
460
+ const sharedSession = await this.sessionService.getSession({
461
+ sessionId: contextId,
462
+ appName: A2A_APP_NAME,
463
+ userId
464
+ });
465
+ const digest = renderTranscriptDigest(sharedSession?.events ?? []);
466
+ const routerParts = digest
467
+ ? [{
468
+ text: `RECENT CONVERSATION, oldest first — context for your classification only. `
469
+ + `Never answer it, and never route on it alone; it is here so you can tell a `
470
+ + `follow-up, a redo request, or a challenge to a previous answer apart from small talk.\n`
471
+ + `${digest}\n\n--- MESSAGE TO CLASSIFY ---\n${messageText}`
472
+ }]
473
+ : userParts;
474
+ const plan = await this.runTurn({
475
+ agent: routerAgent,
476
+ sessionId: routerSessionId,
477
+ userId,
478
+ userParts: routerParts,
479
+ taskId,
480
+ contextId,
481
+ eventBus,
482
+ trace: false, // one cheap classification — not worth a span
483
+ publishToolStatus: false, // a dispatch classifier holds no tools
484
+ sessionService: routerSessions
485
+ });
486
+ // Fail-static: a dead classifier costs the user a good route, never
487
+ // their answer. An empty payload resolves to default_route by design.
488
+ resolution = resolveRoute(plan.error ? '' : plan.text, this.config);
489
+ if (plan.error) {
490
+ console.warn(`[A2A] ⚠ Router failed [${plan.error.code}] — defaulting to '${resolution.route}'.`);
491
+ }
492
+ else if (resolution.fellBack) {
493
+ console.warn(`[A2A] ⚠ Routing fell back to '${resolution.route}': ${resolution.fallbackReason}`);
494
+ }
495
+ routerTokenInfo = plan.tokenInfo;
496
+ }
497
+ const routeCfg = (this.config.subagents || []).find(sub => sub.name === resolution.route);
498
+ if (!routeCfg) {
499
+ throw new Error(`Dispatch failed: no subagent named '${resolution.route}' is declared.`);
500
+ }
501
+ const routeNote = resolution.reason ? ` — ${resolution.reason}` : '';
502
+ console.log(`[A2A] ⇄ Route: ${resolution.route}${routeNote}${routerTokenInfo}`);
503
+ publishWorking(eventBus, taskId, contextId, `Routed to ${resolution.route}${routeNote}`);
504
+ // ══ DISPATCH ═══════════════════════════════════════════════════════
505
+ // The chosen route answers the user directly — its output IS the task
506
+ // result, with no relay turn to lose or mangle it. It runs in the
507
+ // SHARED session, so every route writes into one transcript: a
508
+ // follow-up handled by a different specialist still sees what the last
509
+ // one said, and long-term memory ingests real answers rather than a
510
+ // relay copy of them.
511
+ //
512
+ // It READS that transcript through a projection (lib/session/
513
+ // transcript.ts), because sharing the session is not the same as
514
+ // sharing the conversation. ADK renders any event authored by a
515
+ // different agent as `role: "user"` prefixed "For context:" — so a
516
+ // route reading a shared session gets the whole thread as one
517
+ // undifferentiated user monologue, with the previous route's private
518
+ // reasoning and raw tool payloads mixed into it. The projection hands
519
+ // it real `role: "model"` turns instead, labelled by the desk that
520
+ // spoke, with thoughts and tool traffic stripped.
521
+ const routeAgent = await compileSubagent(routeCfg);
522
+ const turn = await this.runTurn({
523
+ agent: routeAgent,
524
+ sessionId: contextId,
525
+ userId,
526
+ userParts,
527
+ taskId,
528
+ contextId,
529
+ eventBus,
530
+ trace: true,
531
+ publishToolStatus: true,
532
+ sessionService: new ProjectedSessionService(this.sessionService, routeCfg.name)
533
+ });
534
+ if (turn.error) {
535
+ failTask(`Error: [${turn.error.code}] ${resolution.route} failed to answer. See server logs for details.`);
536
+ return;
537
+ }
538
+ tokenInfo = turn.tokenInfo;
539
+ finalText = turn.text;
540
+ if (!finalText) {
541
+ // Naming the route turns a blank reply into a lead. This is the
542
+ // shape the XScout outage took: the specialist ran and returned
543
+ // nothing because its provider rejected the call upstream.
544
+ console.warn(`[A2A] ⚠ Route '${resolution.route}' produced no output.`);
545
+ finalText = `${resolution.route} returned no output — the server logs carry the upstream error.`;
546
+ }
547
+ }
548
+ else {
549
+ // ══ DELEGATE (classic) ═════════════════════════════════════════════
550
+ // Subagents are AgentTools; the orchestrator relays the answer it got.
551
+ const orchestrator = await compileGraph(this.config);
552
+ const turn = await this.runTurn({
553
+ agent: orchestrator,
554
+ sessionId: contextId,
555
+ userId,
556
+ userParts,
557
+ taskId,
558
+ contextId,
559
+ eventBus,
560
+ trace: true,
561
+ publishToolStatus: true
562
+ });
563
+ if (turn.error) {
564
+ failTask(`Error: [${turn.error.code}] The agent run failed. See server logs for details.`);
565
+ return;
566
+ }
567
+ tokenInfo = turn.tokenInfo;
568
+ // Failed-relay fallback: an orchestrator (flash-lite especially) can
569
+ // botch the hop that relays a specialist answer, in two observed ways —
570
+ // finishing STOP with zero output tokens, or emitting the bare tool NAME
571
+ // as its text ("FinancialAnalyst", 3 tokens) instead of the answer. Both
572
+ // discard a fully-formed result, so the last tool result is returned
573
+ // deterministically rather than retrying the relay LLM (a retry costs a
574
+ // full round-trip and can fail the same way again).
575
+ //
576
+ // PLAN-DISPATCH does not need any of this — it has no relay turn to
577
+ // fail. This branch is why the two methods exist side by side.
578
+ //
579
+ // Degenerate-text detection is deliberately narrow — an exact match to an
580
+ // invoked tool name after stripping non-alphanumerics — because short
581
+ // real answers are legitimate ("Yes, as of Q2 2026."), so no length or
582
+ // ratio heuristic may be used here.
583
+ const normalize = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
584
+ finalText = turn.text;
585
+ const echoedToolName = finalText.length > 0 && [...turn.invokedToolNames]
586
+ .some(name => normalize(name) === normalize(finalText));
587
+ if ((!finalText || echoedToolName) && turn.lastToolResultText) {
588
+ const reason = echoedToolName
589
+ ? `echoed the tool name ("${finalText}")`
590
+ : 'emitted no text';
591
+ console.warn(`[A2A] ⚠ Orchestrator ${reason} — relaying last tool result verbatim (${turn.lastToolResultText.length.toLocaleString()} chars).`);
592
+ finalText = turn.lastToolResultText;
593
+ }
594
+ }
595
+ if (finalText) {
596
+ console.log(`[A2A] ✓ Task ${taskId.slice(0, 8)} complete — ${finalText.length.toLocaleString()} chars${tokenInfo}`);
597
+ eventBus.publish({
598
+ kind: 'status-update',
599
+ taskId,
600
+ contextId,
601
+ status: {
602
+ state: 'completed',
603
+ message: {
604
+ kind: 'message',
605
+ messageId: crypto.randomUUID(),
606
+ role: 'agent',
607
+ parts: [{ kind: 'text', text: finalText }],
608
+ contextId,
609
+ taskId
610
+ },
611
+ timestamp: new Date().toISOString()
612
+ },
613
+ final: true
614
+ });
615
+ }
616
+ else {
617
+ console.log(`[A2A] ✓ Task ${taskId.slice(0, 8)} complete — empty output`);
618
+ eventBus.publish({
619
+ kind: 'status-update',
620
+ taskId,
621
+ contextId,
622
+ status: {
623
+ state: 'completed',
624
+ timestamp: new Date().toISOString()
625
+ },
626
+ final: true
627
+ });
628
+ }
629
+ // Long-term memory ingestion: the A2A server is stateless, so there is
630
+ // no "session end" moment like the CLI's exit — ingest after every
631
+ // completed task instead. The memory service dedupes facts per user
632
+ // key, so re-serializing the same conversation next turn is harmless.
633
+ // Runs AFTER the final status publish so it never delays the reply.
634
+ if (this.memoryService) {
635
+ try {
636
+ const endedSession = await this.sessionService.getSession({
637
+ sessionId: contextId,
638
+ appName: A2A_APP_NAME,
639
+ userId
640
+ });
641
+ if (endedSession && endedSession.events.length > 0) {
642
+ // The Supabase service accepts per-consumer extraction rules; the
643
+ // base interface does not declare them, and only that service is
644
+ // ever wired here (startServer constructs it directly).
645
+ await this.memoryService
646
+ .addSessionToMemory(endedSession, this.config.memory_extraction_rules);
647
+ }
648
+ }
649
+ catch (memErr) {
650
+ const msg = memErr instanceof Error ? memErr.message : String(memErr);
651
+ console.error(`[A2A] ⚠ Memory ingestion failed (reply already delivered): ${msg}`);
652
+ }
653
+ }
654
+ }
655
+ catch (error) {
656
+ console.error(`[A2A] Exception on task ${taskId.slice(0, 8)}: ${error.message}`);
657
+ eventBus.publish({
658
+ kind: 'status-update',
659
+ taskId,
660
+ contextId,
661
+ status: {
662
+ state: 'failed',
663
+ message: {
664
+ kind: 'message',
665
+ messageId: crypto.randomUUID(),
666
+ role: 'agent',
667
+ parts: [{ kind: 'text', text: 'Internal Error: the request could not be completed. See server logs for details.' }],
668
+ contextId,
669
+ taskId
670
+ },
671
+ timestamp: new Date().toISOString()
672
+ },
673
+ final: true
674
+ });
675
+ }
676
+ finally {
677
+ eventBus.finished();
678
+ }
679
+ }
680
+ async cancelTask() { }
681
+ }
682
+ // ── Bootstrap Routine ──────────────────────────────────────────────────────
683
+ export async function startServer(syndicateName = 'syndicate.yaml') {
684
+ loadEnv(import.meta.url);
685
+ // Suppress ADK internal INFO/DEBUG logs (raw event JSON, embeddings, binary blobs)
686
+ setLogLevel(LogLevel.WARN);
687
+ const serverBindings = {
688
+ current_date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
689
+ };
690
+ let config;
691
+ if (syndicateName.startsWith('registry:')) {
692
+ const registryId = syndicateName.replace('registry:', '');
693
+ // Lazy import to prevent circular dependency issues during regular boot
694
+ const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
695
+ config = await loadSyndicateFromRegistry(registryId, { bindings: serverBindings });
696
+ }
697
+ else {
698
+ config = loadSyndicate(syndicateName, { bindings: serverBindings });
699
+ }
700
+ const agentCard = compileAgentCard(config);
701
+ // Persistence: sessions whenever Supabase credentials exist. The memory
702
+ // service is constructed alongside (it's just a client handle) but is only
703
+ // handed to executors whose syndicate opts in via memory_system:
704
+ // "long-term" — static agent here, dynamic agents per-config below.
705
+ // Embeddings and fact extraction use the SERVER's Gemini key (memory is
706
+ // operator infrastructure, like tools); the caller's BYOK key only funds
707
+ // inference.
708
+ const wantsLongTermMemory = config.memory_system === 'long-term';
709
+ let sessionService = new InMemorySessionService();
710
+ let memoryService;
711
+ if (hasSupabaseCredentials()) {
712
+ const services = await createSupabaseServices({
713
+ apiKey: process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY || '',
714
+ withMemory: true
715
+ });
716
+ sessionService = services.sessionService;
717
+ memoryService = services.memoryService;
718
+ if (wantsLongTermMemory) {
719
+ console.log(`[A2A] Long-term memory enabled for "${config.syndicate_name}" (per-user silos via X-User-Id).`);
720
+ }
721
+ // Defense-in-depth check: warn operators when db/hardening.sql has not
722
+ // been applied. Without it, the anon/authenticated Supabase API paths
723
+ // can read adk_sessions and adk_memory_facts directly — the application
724
+ // silo (user_key) does nothing against that route. Escalation mirrors
725
+ // the A2A_SERVER_SECRET pattern: fatal for public deployments unless
726
+ // explicitly overridden.
727
+ const rls = await services.checkRlsHardening();
728
+ if (rls.applied) {
729
+ console.log(`[A2A] ✓ DB hardening verified — ${rls.detail}.`);
730
+ }
731
+ else {
732
+ const allowUnhardened = process.env.ALLOW_UNHARDENED_DB === 'true';
733
+ if (process.env.PUBLIC_URL && !allowUnhardened) {
734
+ console.error('[A2A] ✗ FATAL: PUBLIC_URL is set but Supabase hardening is missing');
735
+ console.error(`[A2A] (${rls.detail}).`);
736
+ console.error('[A2A] User transcripts and memory facts are exposed to the anon API key.');
737
+ console.error('[A2A] Run db/hardening.sql in the Supabase SQL Editor, or set');
738
+ console.error('[A2A] ALLOW_UNHARDENED_DB=true to accept the risk explicitly.');
739
+ process.exit(1);
740
+ }
741
+ console.log(`[A2A] ⚠ WARNING: Supabase hardening not applied — ${rls.detail}.`);
742
+ console.log('[A2A] Run db/hardening.sql before serving real user data. Details:');
743
+ console.log('[A2A] lib/memory/README.md → "Hardening".');
744
+ }
745
+ }
746
+ else if (wantsLongTermMemory) {
747
+ console.log('[A2A] ⚠ WARNING: syndicate requests long-term memory but Supabase credentials are missing — memory disabled.');
748
+ }
749
+ const app = express();
750
+ app.set('trust proxy', 1); // Trust Heroku proxy for accurate IP rate limiting
751
+ // Security: Server-Level Authentication (Before body parsing)
752
+ const serverSecret = process.env.A2A_SERVER_SECRET;
753
+ if (!serverSecret) {
754
+ // Running without a shared secret leaves every endpoint open (the BYOK
755
+ // X-API-Key check only verifies presence, not validity). Tolerate this for
756
+ // local dev, but refuse to boot a publicly-addressable deployment.
757
+ if (process.env.PUBLIC_URL) {
758
+ console.error('[A2A] ✗ FATAL: PUBLIC_URL is set but A2A_SERVER_SECRET is missing. Refusing to start an unauthenticated public server.');
759
+ process.exit(1);
760
+ }
761
+ console.log('[A2A] ⚠ WARNING: A2A_SERVER_SECRET is not configured. Server-level authentication is disabled (local dev only).');
762
+ }
763
+ else {
764
+ const expected = Buffer.from(serverSecret);
765
+ app.use((req, res, next) => {
766
+ const authHeader = req.headers.authorization;
767
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
768
+ res.status(401).json({ error: 'Unauthorized: Missing or invalid Authorization Bearer token' });
769
+ return;
770
+ }
771
+ const token = Buffer.from(authHeader.substring(7));
772
+ // Constant-time comparison to avoid leaking the secret via response timing.
773
+ if (token.length !== expected.length || !timingSafeEqual(token, expected)) {
774
+ res.status(401).json({ error: 'Unauthorized: Invalid Authorization Bearer token' });
775
+ return;
776
+ }
777
+ next();
778
+ });
779
+ }
780
+ app.use(express.json());
781
+ // Security: BYOK Middleware
782
+ // NOTE ON SCOPE: the X-API-Key only funds LLM *inference* (Gemini/Claude).
783
+ // Tool calls (e.g. generate_image, google_search) use the server's own
784
+ // keys from the environment, so they bill the operator, not the caller. This
785
+ // check verifies the header is present, not that the key is valid — validity
786
+ // is enforced upstream by the provider. The bearer token above is the real
787
+ // gate; keep it set in any shared/public deployment.
788
+ app.use((req, res, next) => {
789
+ const apiKey = req.headers['x-api-key'];
790
+ const provider = req.headers['x-provider'] || 'google';
791
+ const rawUserId = req.headers['x-user-id'];
792
+ if (!apiKey) {
793
+ res.status(401).json({ error: 'Unauthorized: Missing X-API-Key header' });
794
+ return;
795
+ }
796
+ // X-User-Id (optional): the caller's own identifier for the END USER
797
+ // behind this request (e.g. a site's authenticated account id). It
798
+ // silos sessions AND long-term memory per user beneath the caller's
799
+ // API-key hash. Callers are responsible for authenticating their users
800
+ // BEFORE putting an id in this header — the server trusts it within
801
+ // the caller's own silo and it can never cross into another caller's.
802
+ let siteUserId;
803
+ if (rawUserId !== undefined && rawUserId !== '') {
804
+ if (!SITE_USER_ID_PATTERN.test(rawUserId)) {
805
+ res.status(400).json({ error: 'Invalid X-User-Id: must match [A-Za-z0-9._-]{1,64}' });
806
+ return;
807
+ }
808
+ siteUserId = rawUserId;
809
+ }
810
+ requestContextStorage.run({ apiKey, provider, siteUserId }, () => {
811
+ next();
812
+ });
813
+ });
814
+ // Security: Rate Limiting
815
+ // Applied globally (not just to /a2a) so the per-agent dynamic routes
816
+ // (/:agentId/a2a/...) can't be used to bypass the cap. GET requests are
817
+ // exempted because task-status polling issues many GETs per task and is
818
+ // cheap; the limit targets the expensive POST task submissions.
819
+ // NOTE: the bearer-auth middleware above runs BEFORE this limiter, so only
820
+ // AUTHENTICATED callers ever reach it — its role is runaway/cost protection
821
+ // (a buggy client loop burning Gemini quota), not anonymous-abuse defense.
822
+ // Sized for the nihilistic-penguin daily advisory pipeline (~34 task
823
+ // submissions in one burst: 30 council + news + macro + architect + critic)
824
+ // plus concurrent interactive Discord use; the old 20/hour cap forced that
825
+ // pipeline into multi-window backoff crawls. GETs (task polling) stay exempt.
826
+ const limiter = rateLimit({
827
+ windowMs: 15 * 60 * 1000, // 15 minutes
828
+ max: 60, // 60 task submissions per 15 min per IP (~240/h runaway ceiling)
829
+ standardHeaders: true,
830
+ legacyHeaders: false,
831
+ skip: (req) => req.method === 'GET',
832
+ message: { error: 'Too many requests, please try again later.' }
833
+ });
834
+ app.use(limiter);
835
+ // ── Right-to-erasure: DELETE /memory ─────────────────────────────────────
836
+ // Permanently removes every long-term fact in the CALLING silo — the
837
+ // API-key hash plus X-User-Id, exactly as deriveUserId computes it for
838
+ // storage. A site deletes one of its users by passing that user's id in
839
+ // X-User-Id; it cannot name an arbitrary silo, so it can only erase what
840
+ // it could write. Sessions (adk_sessions) are a separate store — see
841
+ // lib/memory/README.md.
842
+ app.delete('/memory', async (_req, res) => {
843
+ if (!memoryService) {
844
+ res.status(404).json({ error: 'Long-term memory is not enabled on this server.' });
845
+ return;
846
+ }
847
+ const ctx = requestContextStorage.getStore();
848
+ if (!ctx) {
849
+ res.status(401).json({ error: 'No authentication context.' });
850
+ return;
851
+ }
852
+ const userKey = `${A2A_APP_NAME}/${deriveUserId(ctx)}`;
853
+ try {
854
+ const deleted = await memoryService.deleteUserMemory(userKey);
855
+ console.log(`[A2A] Memory erasure: ${deleted} fact(s) removed for silo ${userKey}`);
856
+ res.json({ deleted });
857
+ }
858
+ catch (err) {
859
+ const msg = err instanceof Error ? err.message : String(err);
860
+ console.error(`[A2A] Memory erasure failed: ${msg}`);
861
+ res.status(500).json({ error: 'Memory deletion failed. See server logs.' });
862
+ }
863
+ });
864
+ const requestHandler = new DefaultRequestHandler(agentCard, new InMemoryTaskStore(), new SyndicateExecutor(config, sessionService, wantsLongTermMemory ? memoryService : undefined));
865
+ // Setup Static/Default Routing Interfaces (Backward Compatibility)
866
+ app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: requestHandler }));
867
+ app.use('/a2a/jsonrpc', jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
868
+ app.use('/a2a/rest', restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
869
+ // Dynamic Multi-Agent Routing Cache
870
+ const handlerCache = new Map();
871
+ // Security: agent IDs address a syndicate config (file, or "registry:<id>").
872
+ // Restrict to a safe charset so a crafted path segment cannot drive path
873
+ // traversal through loadSyndicate() or the filesystem fallbacks below.
874
+ function isValidAgentId(agentId) {
875
+ return /^[A-Za-z0-9_.:-]+$/.test(agentId) && !agentId.includes('..');
876
+ }
877
+ async function getOrCreateHandlers(agentId) {
878
+ if (!isValidAgentId(agentId)) {
879
+ throw new Error(`Invalid agent id: '${agentId}'`);
880
+ }
881
+ let cached = handlerCache.get(agentId);
882
+ if (cached)
883
+ return cached;
884
+ let targetConfig;
885
+ // Fresh per-load bindings: the static routes get these via startServer's
886
+ // serverBindings; without them here, dynamic agents would interpolate
887
+ // {{current_date}} from stale YAML defaults (or leave it unresolved).
888
+ const routeBindings = {
889
+ current_date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
890
+ };
891
+ // Where the config came from is load-bearing operational information: a
892
+ // bare agent id prefers the REGISTRY and only falls back to the repo file
893
+ // when that lookup fails, so an id with a registry row ignores repo edits
894
+ // until `scripts/deploy_agent.ts` re-publishes it. On 2026-08-15 two
895
+ // rounds of routing fixes were pushed, deployed and restarted while
896
+ // production kept serving a registry copy from a week earlier, and
897
+ // nothing in the logs said so. Now it does.
898
+ let configSource = '';
899
+ try {
900
+ if (agentId.startsWith('registry:')) {
901
+ const registryId = agentId.replace('registry:', '');
902
+ const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
903
+ targetConfig = await loadSyndicateFromRegistry(registryId, { bindings: routeBindings });
904
+ configSource = `registry:${registryId}`;
905
+ }
906
+ else {
907
+ try {
908
+ const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
909
+ targetConfig = await loadSyndicateFromRegistry(agentId, { bindings: routeBindings });
910
+ configSource = `registry:${agentId} (repo file NOT served — deploy_agent.ts publishes edits)`;
911
+ }
912
+ catch {
913
+ if (agentId.endsWith('.yaml')) {
914
+ targetConfig = loadSyndicate(agentId, { bindings: routeBindings });
915
+ configSource = `file:${agentId}`;
916
+ }
917
+ else {
918
+ try {
919
+ targetConfig = loadSyndicate(`config/agents/${agentId}.yaml`, { bindings: routeBindings });
920
+ configSource = `file:config/agents/${agentId}.yaml`;
921
+ }
922
+ catch {
923
+ targetConfig = loadSyndicate(`${agentId}.yaml`, { bindings: routeBindings });
924
+ configSource = `file:${agentId}.yaml`;
925
+ }
926
+ }
927
+ }
928
+ }
929
+ }
930
+ catch (error) {
931
+ throw new Error(`Failed to load syndicate config for '${agentId}': ${error.message}`);
932
+ }
933
+ console.log(`[A2A] ⚙ Loaded '${agentId}' from ${configSource}`);
934
+ const card = compileAgentCard(targetConfig);
935
+ const handler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new SyndicateExecutor(targetConfig, sessionService, targetConfig.memory_system === 'long-term' ? memoryService : undefined));
936
+ cached = {
937
+ agentCard: agentCardHandler({ agentCardProvider: handler }),
938
+ jsonRpc: jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }),
939
+ rest: restHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication })
940
+ };
941
+ handlerCache.set(agentId, cached);
942
+ return cached;
943
+ }
944
+ // Dynamic Route Registration (Exposes /:agentId/a2a/...)
945
+ app.use('/:agentId/.well-known/agent-card.json', async (req, res, next) => {
946
+ console.log(`[A2A Server] GET /${req.params.agentId}/.well-known/agent-card.json`);
947
+ try {
948
+ const handlers = await getOrCreateHandlers(req.params.agentId);
949
+ handlers.agentCard(req, res, next);
950
+ }
951
+ catch (err) {
952
+ res.status(404).json({ error: err.message });
953
+ }
954
+ });
955
+ app.use('/:agentId/agent-card.json', async (req, res, next) => {
956
+ console.log(`[A2A Server] GET /${req.params.agentId}/agent-card.json`);
957
+ try {
958
+ const handlers = await getOrCreateHandlers(req.params.agentId);
959
+ handlers.agentCard(req, res, next);
960
+ }
961
+ catch (err) {
962
+ res.status(404).json({ error: err.message });
963
+ }
964
+ });
965
+ app.use('/:agentId/a2a/jsonrpc', async (req, res, next) => {
966
+ console.log(`[A2A Server] POST /${req.params.agentId}/a2a/jsonrpc`);
967
+ try {
968
+ const handlers = await getOrCreateHandlers(req.params.agentId);
969
+ handlers.jsonRpc(req, res, next);
970
+ }
971
+ catch (err) {
972
+ res.status(404).json({ error: err.message });
973
+ }
974
+ });
975
+ app.use('/:agentId/a2a/rest', async (req, res, next) => {
976
+ // Only log task submissions (POST), not the repeated GET polling requests
977
+ if (req.method !== 'GET') {
978
+ console.log(`[A2A] ${req.method} /${req.params.agentId}/a2a/rest`);
979
+ }
980
+ try {
981
+ const handlers = await getOrCreateHandlers(req.params.agentId);
982
+ handlers.rest(req, res, next);
983
+ }
984
+ catch (err) {
985
+ res.status(404).json({ error: err.message });
986
+ }
987
+ });
988
+ const PORT = process.env.PORT || 4000;
989
+ app.listen(PORT, () => {
990
+ console.log(`[A2A] Exposer server boot complete on port ${PORT}`);
991
+ });
992
+ }
993
+ if (import.meta.url === `file://${process.argv[1]}`) {
994
+ // Running as a script
995
+ const syndicateFile = process.argv[2] || 'syndicate.yaml';
996
+ startServer(syndicateFile).catch(console.error);
997
+ }