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.
- package/AGENT_SETUP.md +182 -0
- package/DOCUMENTATION.md +614 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +164 -0
- package/README.md +151 -0
- package/config/agents/README.md +27 -0
- package/config/agents/examples/ares.yaml +74 -0
- package/config/agents/examples/cartographers.yaml +115 -0
- package/config/agents/examples/claude.yaml +17 -0
- package/config/agents/examples/council.yaml +65 -0
- package/config/agents/examples/critic.yaml +74 -0
- package/config/agents/examples/delegation.yaml +29 -0
- package/config/agents/examples/hierarchical.yaml +26 -0
- package/config/agents/examples/image_production.yaml +112 -0
- package/config/agents/examples/librarian.yaml +75 -0
- package/config/agents/examples/model_zoo.yaml +81 -0
- package/config/agents/examples/patient_advocate.yaml +107 -0
- package/config/agents/examples/scriptorium.yaml +96 -0
- package/config/agents/examples/style_council.yaml +83 -0
- package/config/agents/examples/syndicate.yaml +34 -0
- package/config/agents/examples/tutor.yaml +66 -0
- package/config/agents/syndicateSchema.yaml +291 -0
- package/dist/lib/config.d.ts +108 -0
- package/dist/lib/config.js +110 -0
- package/dist/lib/dispatch.d.ts +133 -0
- package/dist/lib/dispatch.js +182 -0
- package/dist/lib/grounding.d.ts +28 -0
- package/dist/lib/grounding.js +61 -0
- package/dist/lib/index.d.ts +14 -0
- package/dist/lib/index.js +13 -0
- package/dist/lib/loadEnv.d.ts +21 -0
- package/dist/lib/loadEnv.js +58 -0
- package/dist/lib/loadSyndicate.d.ts +177 -0
- package/dist/lib/loadSyndicate.js +264 -0
- package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
- package/dist/lib/memory/supabaseMemoryService.js +499 -0
- package/dist/lib/models/claudeLlm.d.ts +93 -0
- package/dist/lib/models/claudeLlm.js +332 -0
- package/dist/lib/models/gptLlm.d.ts +110 -0
- package/dist/lib/models/gptLlm.js +497 -0
- package/dist/lib/models/grokLlm.d.ts +64 -0
- package/dist/lib/models/grokLlm.js +86 -0
- package/dist/lib/models/ollamaLlm.d.ts +105 -0
- package/dist/lib/models/ollamaLlm.js +134 -0
- package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
- package/dist/lib/models/openAiCompatibleLlm.js +331 -0
- package/dist/lib/models/providerMap.d.ts +28 -0
- package/dist/lib/models/providerMap.js +45 -0
- package/dist/lib/models/registry.d.ts +71 -0
- package/dist/lib/models/registry.js +159 -0
- package/dist/lib/models/schemaNormalize.d.ts +22 -0
- package/dist/lib/models/schemaNormalize.js +58 -0
- package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
- package/dist/lib/observability/supabaseSpanExporter.js +115 -0
- package/dist/lib/observability/tracer.d.ts +44 -0
- package/dist/lib/observability/tracer.js +242 -0
- package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
- package/dist/lib/persistence/supabaseProvider.js +88 -0
- package/dist/lib/session/supabaseSessionService.d.ts +26 -0
- package/dist/lib/session/supabaseSessionService.js +193 -0
- package/dist/lib/session/transcript.d.ts +193 -0
- package/dist/lib/session/transcript.js +309 -0
- package/dist/lib/toolRegistry.d.ts +15 -0
- package/dist/lib/toolRegistry.js +58 -0
- package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
- package/dist/lib/tools/collectionsSearchTool.js +86 -0
- package/dist/lib/tools/generateImageTool.d.ts +16 -0
- package/dist/lib/tools/generateImageTool.js +119 -0
- package/dist/lib/tools/inspectImageTool.d.ts +21 -0
- package/dist/lib/tools/inspectImageTool.js +97 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
- package/dist/lib/tools/mcpToolFactory.js +116 -0
- package/dist/lib/tools/toolContract.d.ts +78 -0
- package/dist/lib/tools/toolContract.js +129 -0
- package/dist/lib/tools/webExtractTool.d.ts +70 -0
- package/dist/lib/tools/webExtractTool.js +461 -0
- package/dist/lib/tools/webSearchTool.d.ts +53 -0
- package/dist/lib/tools/webSearchTool.js +126 -0
- package/dist/lib/tools/wikiTools.d.ts +112 -0
- package/dist/lib/tools/wikiTools.js +697 -0
- package/dist/lib/tools/xSearchTool.d.ts +61 -0
- package/dist/lib/tools/xSearchTool.js +125 -0
- package/dist/lib/wiki/agentRun.d.ts +39 -0
- package/dist/lib/wiki/agentRun.js +79 -0
- package/dist/lib/wiki/builder.d.ts +71 -0
- package/dist/lib/wiki/builder.js +165 -0
- package/dist/lib/wiki/dive.d.ts +56 -0
- package/dist/lib/wiki/dive.js +208 -0
- package/dist/lib/wiki/entities.d.ts +220 -0
- package/dist/lib/wiki/entities.js +661 -0
- package/dist/lib/wiki/extract.d.ts +97 -0
- package/dist/lib/wiki/extract.js +305 -0
- package/dist/lib/wiki/fill.d.ts +37 -0
- package/dist/lib/wiki/fill.js +175 -0
- package/dist/lib/wiki/format.d.ts +132 -0
- package/dist/lib/wiki/format.js +177 -0
- package/dist/lib/wiki/graph.d.ts +80 -0
- package/dist/lib/wiki/graph.js +147 -0
- package/dist/lib/wiki/lint.d.ts +58 -0
- package/dist/lib/wiki/lint.js +267 -0
- package/dist/lib/wiki/markdown.d.ts +127 -0
- package/dist/lib/wiki/markdown.js +336 -0
- package/dist/lib/wiki/pipeline.d.ts +69 -0
- package/dist/lib/wiki/pipeline.js +119 -0
- package/dist/lib/wiki/vault.d.ts +84 -0
- package/dist/lib/wiki/vault.js +194 -0
- package/dist/scripts/a2a_server.d.ts +2 -0
- package/dist/scripts/a2a_server.js +997 -0
- package/dist/scripts/syndicate_chat.d.ts +2 -0
- package/dist/scripts/syndicate_chat.js +485 -0
- 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
|
+
}
|