melchizedek-agents 0.9.6 → 0.16.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 (246) hide show
  1. package/.env.example +272 -0
  2. package/AGENT_SETUP.md +39 -23
  3. package/CHANGELOG.md +623 -0
  4. package/DOCUMENTATION.md +429 -109
  5. package/QUICKSTART.md +92 -18
  6. package/README.md +45 -5
  7. package/config/agents/README.md +46 -1
  8. package/config/agents/examples/ares.yaml +117 -40
  9. package/config/agents/examples/assistant.yaml +159 -0
  10. package/config/agents/examples/augustin.yaml +154 -51
  11. package/config/agents/examples/cartographers.yaml +91 -23
  12. package/config/agents/examples/claude.yaml +45 -6
  13. package/config/agents/examples/council.yaml +23 -8
  14. package/config/agents/examples/critic.yaml +57 -21
  15. package/config/agents/examples/delegation.yaml +76 -9
  16. package/config/agents/examples/hierarchical.yaml +55 -8
  17. package/config/agents/examples/image_production.yaml +54 -16
  18. package/config/agents/examples/librarian.yaml +70 -14
  19. package/config/agents/examples/model_zoo.yaml +105 -29
  20. package/config/agents/examples/patient_advocate.yaml +102 -34
  21. package/config/agents/examples/research.yaml +250 -0
  22. package/config/agents/examples/scribe.yaml +242 -0
  23. package/config/agents/examples/scriptorium.yaml +87 -24
  24. package/config/agents/examples/style_council.yaml +31 -9
  25. package/config/agents/examples/syndicate.yaml +29 -10
  26. package/config/agents/examples/tutor.yaml +10 -3
  27. package/config/agents/syndicateSchema.yaml +44 -9
  28. package/config/agents/templates/README.md +58 -0
  29. package/config/agents/templates/account_memory.yaml +153 -0
  30. package/config/agents/templates/case_desk.yaml +188 -0
  31. package/config/agents/templates/conversational.yaml +110 -0
  32. package/config/agents/templates/draft_review.yaml +130 -0
  33. package/config/agents/templates/intake_extractor.yaml +152 -0
  34. package/config/agents/templates/research_brief.yaml +169 -0
  35. package/config/agents/templates/research_desk.yaml +152 -0
  36. package/config/agents/templates/review_panel.yaml +188 -0
  37. package/config/agents/templates/support_triage.yaml +273 -0
  38. package/config/agents/templates/systems_operator.yaml +157 -0
  39. package/db/hardening.sql +202 -0
  40. package/db/memory_v2.sql +77 -0
  41. package/db/migrations/0001_base.sql +167 -0
  42. package/db/migrations/0002_erase_scope.sql +172 -0
  43. package/db/migrations/0003_postgres_storage.sql +58 -0
  44. package/db/migrations/0004_usage.sql +94 -0
  45. package/db/migrations/0005_agent_registry.sql +200 -0
  46. package/db/migrations/0006_task_leases.sql +25 -0
  47. package/db/migrations/0007_memory_commit.sql +134 -0
  48. package/db/migrations/0008_memory_retention.sql +37 -0
  49. package/db/migrations/0009_task_queue.sql +51 -0
  50. package/db/migrations/0010_erase_complete.sql +180 -0
  51. package/db/telemetry.sql +401 -0
  52. package/demo/README.md +77 -0
  53. package/demo/a2a_demo.mjs +95 -0
  54. package/dist/lib/a2a/app.d.ts +253 -0
  55. package/dist/lib/a2a/app.js +751 -0
  56. package/dist/lib/a2a/executor.d.ts +167 -0
  57. package/dist/lib/a2a/executor.js +548 -0
  58. package/dist/lib/a2a/identity.d.ts +122 -0
  59. package/dist/lib/a2a/identity.js +254 -0
  60. package/dist/lib/a2a/policy.d.ts +110 -0
  61. package/dist/lib/a2a/policy.js +178 -0
  62. package/dist/lib/a2a/redisLimits.d.ts +26 -0
  63. package/dist/lib/a2a/redisLimits.js +31 -0
  64. package/dist/lib/a2a/remoteAgent.d.ts +91 -0
  65. package/dist/lib/a2a/remoteAgent.js +268 -0
  66. package/dist/lib/a2a/turnLock.d.ts +28 -0
  67. package/dist/lib/a2a/turnLock.js +76 -0
  68. package/dist/lib/compile.d.ts +11 -1
  69. package/dist/lib/compile.js +109 -8
  70. package/dist/lib/config.d.ts +7 -7
  71. package/dist/lib/config.js +7 -7
  72. package/dist/lib/doctor.d.ts +145 -0
  73. package/dist/lib/doctor.js +517 -0
  74. package/dist/lib/grounding.d.ts +2 -2
  75. package/dist/lib/grounding.js +2 -2
  76. package/dist/lib/guards/index.d.ts +37 -0
  77. package/dist/lib/guards/index.js +66 -0
  78. package/dist/lib/guards/science.d.ts +117 -0
  79. package/dist/lib/guards/science.js +444 -0
  80. package/dist/lib/index.d.ts +37 -2
  81. package/dist/lib/index.js +35 -4
  82. package/dist/lib/loadEnv.d.ts +23 -11
  83. package/dist/lib/loadEnv.js +77 -29
  84. package/dist/lib/loadSyndicate.d.ts +97 -5
  85. package/dist/lib/loadSyndicate.js +176 -76
  86. package/dist/lib/memory/erase.d.ts +24 -0
  87. package/dist/lib/memory/erase.js +47 -0
  88. package/dist/lib/memory/namespace.d.ts +37 -0
  89. package/dist/lib/memory/namespace.js +74 -0
  90. package/dist/lib/memory/providers.d.ts +99 -0
  91. package/dist/lib/memory/providers.js +240 -0
  92. package/dist/lib/memory/store.d.ts +74 -0
  93. package/dist/lib/memory/store.js +108 -0
  94. package/dist/lib/memory/supabaseMemoryService.d.ts +69 -9
  95. package/dist/lib/memory/supabaseMemoryService.js +239 -125
  96. package/dist/lib/models/capabilities.d.ts +103 -0
  97. package/dist/lib/models/capabilities.js +291 -0
  98. package/dist/lib/models/claudeLlm.d.ts +10 -1
  99. package/dist/lib/models/claudeLlm.js +57 -27
  100. package/dist/lib/models/endpoints.d.ts +100 -0
  101. package/dist/lib/models/endpoints.js +282 -0
  102. package/dist/lib/models/gateway.d.ts +107 -0
  103. package/dist/lib/models/gateway.js +157 -0
  104. package/dist/lib/models/gatewayLlm.d.ts +57 -0
  105. package/dist/lib/models/gatewayLlm.js +105 -0
  106. package/dist/lib/models/gptLlm.d.ts +51 -2
  107. package/dist/lib/models/gptLlm.js +169 -26
  108. package/dist/lib/models/grokLlm.d.ts +19 -10
  109. package/dist/lib/models/grokLlm.js +29 -14
  110. package/dist/lib/models/ollamaLlm.d.ts +26 -0
  111. package/dist/lib/models/ollamaLlm.js +43 -1
  112. package/dist/lib/models/openAiCompatibleLlm.d.ts +67 -2
  113. package/dist/lib/models/openAiCompatibleLlm.js +200 -37
  114. package/dist/lib/models/providerMap.js +9 -2
  115. package/dist/lib/models/registry.d.ts +42 -5
  116. package/dist/lib/models/registry.js +140 -29
  117. package/dist/lib/models/retry.d.ts +146 -0
  118. package/dist/lib/models/retry.js +305 -0
  119. package/dist/lib/models/schemaNormalize.d.ts +20 -0
  120. package/dist/lib/models/schemaNormalize.js +43 -0
  121. package/dist/lib/net/addressGuard.d.ts +60 -0
  122. package/dist/lib/net/addressGuard.js +216 -0
  123. package/dist/lib/observability/embeddings.d.ts +11 -2
  124. package/dist/lib/observability/embeddings.js +18 -14
  125. package/dist/lib/observability/metrics.d.ts +36 -0
  126. package/dist/lib/observability/metrics.js +86 -0
  127. package/dist/lib/observability/redact.d.ts +29 -0
  128. package/dist/lib/observability/redact.js +102 -0
  129. package/dist/lib/observability/supabaseSpanExporter.d.ts +2 -0
  130. package/dist/lib/observability/supabaseSpanExporter.js +22 -6
  131. package/dist/lib/observability/tracer.d.ts +10 -0
  132. package/dist/lib/observability/tracer.js +70 -3
  133. package/dist/lib/persistence/supabaseProvider.d.ts +11 -0
  134. package/dist/lib/persistence/supabaseProvider.js +32 -6
  135. package/dist/lib/registry.d.ts +87 -0
  136. package/dist/lib/registry.js +146 -0
  137. package/dist/lib/runtime/approvals.d.ts +44 -0
  138. package/dist/lib/runtime/approvals.js +67 -0
  139. package/dist/lib/runtime/syndicateTurn.d.ts +233 -0
  140. package/dist/lib/runtime/syndicateTurn.js +597 -0
  141. package/dist/lib/runtime/turnControl.d.ts +90 -0
  142. package/dist/lib/runtime/turnControl.js +131 -0
  143. package/dist/lib/session/supabaseSessionService.js +1 -1
  144. package/dist/lib/session/transcript.d.ts +9 -1
  145. package/dist/lib/session/transcript.js +15 -7
  146. package/dist/lib/skills.d.ts +50 -0
  147. package/dist/lib/skills.js +138 -0
  148. package/dist/lib/storage/postgres/index.d.ts +98 -0
  149. package/dist/lib/storage/postgres/index.js +146 -0
  150. package/dist/lib/storage/postgres/memoryStore.d.ts +11 -0
  151. package/dist/lib/storage/postgres/memoryStore.js +82 -0
  152. package/dist/lib/storage/postgres/sessionService.d.ts +51 -0
  153. package/dist/lib/storage/postgres/sessionService.js +225 -0
  154. package/dist/lib/storage/postgres/taskQueue.d.ts +22 -0
  155. package/dist/lib/storage/postgres/taskQueue.js +101 -0
  156. package/dist/lib/storage/postgres/taskStore.d.ts +55 -0
  157. package/dist/lib/storage/postgres/taskStore.js +168 -0
  158. package/dist/lib/storage/schema.d.ts +27 -0
  159. package/dist/lib/storage/schema.js +50 -0
  160. package/dist/lib/storage/schemaVersion.d.ts +25 -0
  161. package/dist/lib/storage/schemaVersion.js +46 -0
  162. package/dist/lib/syndicateSchema.d.ts +275 -0
  163. package/dist/lib/syndicateSchema.js +534 -0
  164. package/dist/lib/toolRegistry.d.ts +15 -0
  165. package/dist/lib/toolRegistry.js +59 -10
  166. package/dist/lib/tools/collectionsSearchTool.js +1 -1
  167. package/dist/lib/tools/mcpServe.d.ts +45 -0
  168. package/dist/lib/tools/mcpServe.js +88 -0
  169. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  170. package/dist/lib/tools/mcpToolFactory.js +41 -21
  171. package/dist/lib/tools/science/corrections.d.ts +42 -0
  172. package/dist/lib/tools/science/corrections.js +115 -0
  173. package/dist/lib/tools/science/europepmc.d.ts +51 -0
  174. package/dist/lib/tools/science/europepmc.js +127 -0
  175. package/dist/lib/tools/science/http.d.ts +31 -0
  176. package/dist/lib/tools/science/http.js +104 -0
  177. package/dist/lib/tools/science/openalex.d.ts +30 -0
  178. package/dist/lib/tools/science/openalex.js +81 -0
  179. package/dist/lib/tools/science/trials.d.ts +58 -0
  180. package/dist/lib/tools/science/trials.js +203 -0
  181. package/dist/lib/tools/science/types.d.ts +69 -0
  182. package/dist/lib/tools/science/types.js +48 -0
  183. package/dist/lib/tools/scienceTools.d.ts +100 -0
  184. package/dist/lib/tools/scienceTools.js +277 -0
  185. package/dist/lib/tools/taskTools.d.ts +204 -0
  186. package/dist/lib/tools/taskTools.js +374 -0
  187. package/dist/lib/tools/toolContract.d.ts +17 -4
  188. package/dist/lib/tools/toolContract.js +13 -5
  189. package/dist/lib/tools/webExtractTool.d.ts +13 -10
  190. package/dist/lib/tools/webExtractTool.js +23 -55
  191. package/dist/lib/tools/webSearchTool.d.ts +2 -1
  192. package/dist/lib/tools/webSearchTool.js +4 -3
  193. package/dist/lib/tools/wikiTools.d.ts +2 -2
  194. package/dist/lib/tools/wikiTools.js +12 -12
  195. package/dist/lib/tools/xApiSearchTool.d.ts +194 -0
  196. package/dist/lib/tools/xApiSearchTool.js +583 -0
  197. package/dist/lib/tools/xSearchTool.js +1 -1
  198. package/dist/lib/wiki/agentRun.js +1 -1
  199. package/dist/lib/wiki/builder.js +21 -6
  200. package/dist/lib/wiki/dive.js +2 -2
  201. package/dist/lib/wiki/entities.d.ts +2 -2
  202. package/dist/lib/wiki/entities.js +7 -7
  203. package/dist/lib/wiki/extract.js +5 -1
  204. package/dist/lib/wiki/fill.js +5 -5
  205. package/dist/lib/wiki/format.d.ts +7 -5
  206. package/dist/lib/wiki/format.js +2 -2
  207. package/dist/lib/wiki/graph.js +2 -2
  208. package/dist/lib/wiki/lint.js +2 -2
  209. package/dist/lib/wiki/markdown.d.ts +1 -1
  210. package/dist/lib/wiki/markdown.js +6 -1
  211. package/dist/lib/wiki/pipeline.js +2 -2
  212. package/dist/lib/wiki/vault.js +2 -2
  213. package/dist/scripts/a2a_server.d.ts +17 -11
  214. package/dist/scripts/a2a_server.js +405 -1028
  215. package/dist/scripts/assistant_worker.d.ts +33 -0
  216. package/dist/scripts/assistant_worker.js +193 -0
  217. package/dist/scripts/db.d.ts +26 -0
  218. package/dist/scripts/db.js +179 -0
  219. package/dist/scripts/doctor.d.ts +23 -0
  220. package/dist/scripts/doctor.js +90 -0
  221. package/dist/scripts/init.d.ts +43 -0
  222. package/dist/scripts/init.js +152 -0
  223. package/dist/scripts/registry.d.ts +25 -0
  224. package/dist/scripts/registry.js +182 -0
  225. package/dist/scripts/skills_install.d.ts +7 -0
  226. package/dist/scripts/skills_install.js +137 -0
  227. package/dist/scripts/syndicate_chat.js +159 -276
  228. package/package.json +90 -16
  229. package/skills/README.md +74 -0
  230. package/skills/briefs/README.md +16 -0
  231. package/skills/briefs/_shared.md +16 -0
  232. package/skills/briefs/melchizedek-author.md +60 -0
  233. package/skills/briefs/melchizedek-memory.md +47 -0
  234. package/skills/briefs/melchizedek-models.md +47 -0
  235. package/skills/briefs/melchizedek-scribe.md +37 -0
  236. package/skills/briefs/melchizedek-serve.md +56 -0
  237. package/skills/briefs/melchizedek.md +69 -0
  238. package/skills/briefs/skills-readme.md +33 -0
  239. package/skills/melchizedek/SKILL.md +159 -0
  240. package/skills/melchizedek-author/SKILL.md +99 -0
  241. package/skills/melchizedek-author/templates/minimal.yaml +60 -0
  242. package/skills/melchizedek-memory/SKILL.md +138 -0
  243. package/skills/melchizedek-models/SKILL.md +137 -0
  244. package/skills/melchizedek-scribe/SKILL.md +72 -0
  245. package/skills/melchizedek-scribe/templates/brief.md +27 -0
  246. package/skills/melchizedek-serve/SKILL.md +119 -0
@@ -1,1058 +1,395 @@
1
1
  #!/usr/bin/env node
2
- import { realpathSync } from 'node:fs';
3
- import { pathToFileURL } from 'node:url';
4
- import express from 'express';
5
- import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from "../lib/grounding.js";
6
- import rateLimit from 'express-rate-limit';
7
- import { AGENT_CARD_PATH } from '@a2a-js/sdk';
8
- import { timingSafeEqual, createHash } from 'node:crypto';
9
- import { loadEnv } from "../lib/loadEnv.js";
10
- import { DefaultRequestHandler, InMemoryTaskStore } from '@a2a-js/sdk/server';
11
- import { AsyncLocalStorage } from 'async_hooks';
12
- import { agentCardHandler, jsonRpcHandler, restHandler, UserBuilder } from '@a2a-js/sdk/server/express';
13
- import { Runner, InMemorySessionService, getFunctionCalls, getFunctionResponses, setLogLevel, LogLevel } from '@google/adk';
14
- import { loadSyndicate } from "../lib/loadSyndicate.js";
15
- import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from "../lib/dispatch.js";
16
- import { traceAgentRun } from "../lib/observability/tracer.js";
17
- import { configDigest } from "../lib/observability/lineage.js";
18
- import { resolveModel } from "../lib/models/registry.js";
19
- import { compileGraph as compileGraphShared, compileSubagent as compileSubagentShared } from "../lib/compile.js";
20
- import { hasSupabaseCredentials, createSupabaseServices } from "../lib/persistence/supabaseProvider.js";
21
- import { ProjectedSessionService, renderTranscriptDigest } from "../lib/session/transcript.js";
22
- const requestContextStorage = new AsyncLocalStorage();
23
- const A2A_APP_NAME = 'melchizedek-a2a';
24
- /** X-User-Id must be short and filesystem/key-safe; anything else is rejected. */
25
- const SITE_USER_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
26
- /** X-Surface-* values obey the same charset rule, for the same reason. */
27
- const SURFACE_VALUE_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
28
- const SURFACE_HEADERS = [
29
- ['x-surface', 'name'],
30
- ['x-surface-guild', 'guild'],
31
- ['x-surface-channel', 'channel'],
32
- ['x-surface-user', 'user'],
33
- ];
34
- /**
35
- * Root-span attributes for the caller's surface. Absent headers produce no
36
- * attributes at all, so a caller that names nothing writes NULLs rather than
37
- * a row full of 'unknown' — an unnamed surface and a surface named 'unknown'
38
- * are different facts and the ledger should not conflate them.
39
- */
40
- function surfaceAttributes(ctx) {
41
- const s = ctx?.surface;
42
- if (!s)
43
- return {};
44
- const attrs = { 'surface.name': s.name };
45
- if (s.guild)
46
- attrs['surface.guild'] = s.guild;
47
- if (s.channel)
48
- attrs['surface.channel'] = s.channel;
49
- if (s.user)
50
- attrs['surface.user'] = s.user;
51
- return attrs;
52
- }
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
53
10
  /**
54
- * Memory/session silo derivation.
55
- * Base silo: a hash of the caller's API key (one bucket per credential).
56
- * With X-User-Id, the end-user is siloed BENEATH the key hash — so a site
57
- * proxying many humans through one backend key gets one bucket per human,
58
- * and no site can ever reach into another credential's buckets, because
59
- * the key hash always prefixes.
11
+ * scripts/a2a_server.ts — the `melchizedek-serve` bin and `npm run start:a2a`.
12
+ *
13
+ * Reads the environment, refuses unsafe configurations, builds the server
14
+ * with `createA2AApp` (lib/a2a/app.ts — mountable in your own Express app),
15
+ * listens, and drains in-flight tasks on SIGTERM. Every turn runs through
16
+ * lib/runtime/syndicateTurn.ts, the same runtime the REPL and evals use.
17
+ *
18
+ * Environment (all optional; see .env.example):
19
+ * PORT listen port (4000)
20
+ * HOST bind address. Default: all interfaces when a
21
+ * secret is set, 127.0.0.1 when none is.
22
+ * PUBLIC_URL external base URL for agent cards; also marks a
23
+ * deployment as public (secret + hardening required)
24
+ * A2A_SERVER_SECRET bearer secret every request must present (A2A_AUTH=secret);
25
+ * with A2A_AUTH=callers it stays valid beside the caller
26
+ * tokens, with its old scoping, until you remove it
27
+ * A2A_AUTH who a caller is (ADR 0025): secret (default) | callers |
28
+ * jwt | header
29
+ * A2A_CALLERS callers: `name:sha256[:scope]; …` — one token per calling
30
+ * backend. Mint: melchizedek-serve --new-caller <name>
31
+ * A2A_JWT_JWKS_URL jwt: your identity provider's JWKS (or A2A_JWT_SECRET, HS256)
32
+ * A2A_JWT_ISSUER jwt: required iss
33
+ * A2A_JWT_AUDIENCE jwt: required aud
34
+ * A2A_JWT_SCOPE_CLAIM jwt: the user claim (sub)
35
+ * A2A_JWT_TENANT_CLAIM jwt: optional tenant claim; scope becomes <tenant>/<user>
36
+ * A2A_TRUSTED_USER_HEADER header: the header your authenticating gateway sets
37
+ * (requires A2A_SERVER_SECRET, which only the gateway holds)
38
+ * ALLOW_UNAUTHENTICATED "true" to bind a non-loopback HOST with no secret
39
+ * ALLOW_UNHARDENED_DB "true" to accept an unhardened Supabase schema
40
+ * A2A_TASK_TIMEOUT_MS per-task wall-clock budget (900000; 0 = none)
41
+ * A2A_MAX_CONCURRENT_TASKS concurrent tasks across all agents (0 = unlimited)
42
+ * A2A_RATE_LIMIT_MAX task submissions per window per IP (60)
43
+ * A2A_RATE_LIMIT_WINDOW_MS rate-limit window (900000)
44
+ * A2A_AUTH_FAILURE_MAX failed authentications per window per IP (30)
45
+ * A2A_TRUST_PROXY Express trust-proxy: hop count, true/false, or subnets (1)
46
+ * A2A_BODY_LIMIT JSON body limit ("1mb")
47
+ * A2A_KEY_MODE server (default): models run on the server's keys;
48
+ * byok: the caller's X-API-Key pays. Under A2A_AUTH=secret
49
+ * the mode also scopes data: X-User-Id in server mode,
50
+ * the key's hash in byok (the pre-0.16 behaviour)
51
+ * A2A_SERVED_AGENTS comma list: the only agent ids /:agentId/ serves;
52
+ * only these may fall back to examples/ and templates/
53
+ * A2A_REGISTRY_AGENTS comma list: bare ids that load from adk_agent_registry
54
+ * (others are files; registry:<id> always is the registry)
55
+ * A2A_SHUTDOWN_GRACE_MS how long SIGTERM waits for running tasks (25000)
56
+ * A2A_BUDGETS daily budgets per UTC day (ADR 0026), JSON:
57
+ * {"perCaller":{"tasks":500,"llmCalls":5000,"tokens":5000000},
58
+ * "callers":{"reports":{"tasks":100}},"perScope":{"tasks":50}}.
59
+ * Counted in Postgres or Supabase when configured
60
+ * (db/migrations/0004), else in process memory
61
+ * A2A_METRICS_TOKEN bearer for GET /metrics (Prometheus); unset = no route
62
+ * A2A_LOG_FORMAT text (default) | json: one JSON object per line,
63
+ * including a `task` record per finished task
64
+ * DATABASE_URL Postgres for every durable store (sessions, memory,
65
+ * A2A tasks, erase) — multi-instance safe (ADR 0021).
66
+ * Without it: Supabase when its credentials are set,
67
+ * else process memory.
68
+ * DATABASE_POOL_MAX connections in the storage pool (default 10)
69
+ * A2A_TURN_LOCK_POOL_MAX connections for per-conversation turn locks, one per
70
+ * running turn (default 20). Behind a session-mode
71
+ * pooler (Supabase :5432) keep the two sums under its
72
+ * client limit, per instance.
60
73
  */
61
- function deriveUserId(ctx) {
62
- const keyHash = createHash('sha256').update(ctx.apiKey).digest('hex').slice(0, 16);
63
- return ctx.siteUserId ? `a2a-${keyHash}/${ctx.siteUserId}` : `a2a-${keyHash}`;
64
- }
65
- // ── Dynamic Agent Card Compiler ──────────────────────────────────────────
66
- // Synthesizes the public card configuration from current registry variables
67
- function compileAgentCard(config) {
68
- const orchestratorConfig = config.orchestrator;
69
- const subagents = config.subagents || [];
70
- const url = process.env.PUBLIC_URL || 'http://localhost:4000';
71
- return {
72
- name: orchestratorConfig.name,
73
- description: orchestratorConfig.description || 'Syndicate Orchestrator Agent',
74
- protocolVersion: '0.3.0',
75
- version: '2.0.0',
76
- url: `${url}/a2a/jsonrpc`,
77
- preferredTransport: 'JSONRPC',
78
- capabilities: {
79
- streaming: true,
80
- pushNotifications: false,
81
- stateTransitionHistory: true
82
- },
83
- defaultInputModes: ['text/plain'],
84
- defaultOutputModes: ['text/plain'],
85
- skills: subagents.map(sub => ({
86
- id: sub.name,
87
- name: sub.name,
88
- description: sub.description || 'Subagent collaborator',
89
- tags: sub.tools || []
90
- })),
91
- additionalInterfaces: [
92
- { url: `${url}/a2a/jsonrpc`, transport: 'JSONRPC' },
93
- { url: `${url}/a2a/rest`, transport: 'HTTP+JSON' }
94
- ]
95
- };
96
- }
97
- // ── Log Formatting Helpers ────────────────────────────────────────────────
98
- // Summarises tool call args into a single readable string, truncating long values.
99
- function formatToolArgs(args) {
100
- if (!args || Object.keys(args).length === 0)
101
- return '';
102
- return Object.entries(args)
103
- .map(([k, v]) => {
104
- const raw = typeof v === 'string' ? v : JSON.stringify(v);
105
- return `${k}=${raw.length > 60 ? `${raw.slice(0, 60)}...` : raw}`;
106
- })
107
- .join(', ');
74
+ import { randomBytes } from 'node:crypto';
75
+ import { realpathSync, writeFileSync } from 'node:fs';
76
+ import { pathToFileURL } from 'node:url';
77
+ import { setLogLevel, LogLevel } from '@google/adk';
78
+ import { createA2AApp } from '../lib/a2a/app.js';
79
+ import { callerTokens, firstOf, hashCallerToken, jwtIdentity, parseCallers, sharedSecret, trustedHeader, } from '../lib/a2a/identity.js';
80
+ import { budgets, memoryUsageStore, parseBudgets, postgresUsageStore, supabaseUsageStore } from '../lib/a2a/policy.js';
81
+ import { hasSupabaseCredentials } from '../lib/persistence/supabaseProvider.js';
82
+ import { setTaskBackend } from '../lib/tools/taskTools.js';
83
+ import { redisRateLimitStore } from '../lib/a2a/redisLimits.js';
84
+ import { dbSchema } from '../lib/storage/schema.js';
85
+ import { postgresStorage } from '../lib/storage/postgres/index.js';
86
+ import { isPlaceholderValue, loadEnv } from '../lib/loadEnv.js';
87
+ import { flushTracing } from '../lib/observability/tracer.js';
88
+ // Re-exported for existing importers (tests/failNarration.test.ts).
89
+ export { describeTurnError } from '../lib/a2a/executor.js';
90
+ /** Minimum bearer-secret length below which the server warns. */
91
+ const MIN_SECRET_LENGTH = 32;
92
+ const isLoopback = (host) => host === '127.0.0.1' || host === '::1' || host === 'localhost';
93
+ function envInt(name, fallback) {
94
+ const raw = process.env[name];
95
+ if (raw === undefined || raw.trim() === '')
96
+ return fallback;
97
+ const n = Number(raw);
98
+ if (!Number.isFinite(n) || n < 0)
99
+ throw new Error(`${name} must be a non-negative number (got '${raw}')`);
100
+ return Math.floor(n);
108
101
  }
109
- // ── Progress telemetry ────────────────────────────────────────────────────
110
- // `[STATUS]` is a consumer contract, not decoration: A2A clients scan task
111
- // history for messages carrying this prefix and surface them as live progress
112
- // (the Discord bot edits its "Considering your query..." placeholder with the
113
- // newest one). Keep these lines short and human-readable — they are read by a
114
- // person waiting for an answer, not by a parser.
115
- function publishWorking(eventBus, taskId, contextId, text) {
116
- eventBus.publish({
117
- kind: 'status-update',
118
- taskId,
119
- contextId,
120
- status: {
121
- state: 'working',
122
- message: {
123
- kind: 'message',
124
- messageId: crypto.randomUUID(),
125
- role: 'agent',
126
- parts: [{ kind: 'text', text: `[STATUS] ${text}` }],
127
- contextId,
128
- taskId
129
- },
130
- timestamp: new Date().toISOString()
131
- },
132
- final: false
133
- });
102
+ function envTrustProxy() {
103
+ const raw = process.env.A2A_TRUST_PROXY?.trim();
104
+ if (!raw)
105
+ return 1;
106
+ if (/^\d+$/.test(raw))
107
+ return Number(raw);
108
+ if (raw === 'true')
109
+ return true;
110
+ if (raw === 'false')
111
+ return false;
112
+ return raw;
134
113
  }
135
- /** One human-readable line naming WHY a turn failed, for the failed task's
136
- * status message — the text the Discord surface renders verbatim. Provider
137
- * errors sometimes arrive as a raw JSON blob (Gemini ApiError:
138
- * `{"error":{"code":503,"message":"...","status":"UNAVAILABLE"}}`), so the
139
- * inner message is dug out before falling back to the string itself. An
140
- * empty message keeps the old pointer at the server logs. */
141
- export function describeTurnError(error) {
142
- let msg = (error.message ?? '').trim();
143
- if (msg.startsWith('{')) {
144
- try {
145
- const parsed = JSON.parse(msg);
146
- const inner = parsed?.error?.message ?? parsed?.message;
147
- if (typeof inner === 'string' && inner.trim())
148
- msg = inner.trim();
149
- }
150
- catch { /* not JSON — use as-is */ }
114
+ /** A2A_LOG_FORMAT=json: every server line is one JSON object (ts, level, msg, …). */
115
+ const jsonLogs = () => (process.env.A2A_LOG_FORMAT ?? '').trim().toLowerCase() === 'json';
116
+ function emit(level, message, fields) {
117
+ if (jsonLogs()) {
118
+ const line = JSON.stringify({ ts: new Date().toISOString(), level, msg: message.replace(/^\[A2A\]\s*(⚠|✗|✓)?\s*/, ''), ...fields });
119
+ (level === 'info' ? console.log : console.error)(line);
120
+ return;
151
121
  }
152
- if (!msg)
153
- return 'see server logs for details.';
154
- return msg.length > 300 ? `${msg.slice(0, 297)}...` : msg;
122
+ (level === 'info' ? console.log : level === 'warn' ? console.warn : console.error)(message);
155
123
  }
156
- // ── Native ADK AgentExecutor Bridge ───────────────────────────────────────
157
- class SyndicateExecutor {
158
- config;
159
- sessionService;
160
- memoryService;
161
- /** Provenance stamp for every turn this executor serves (lineage.ts). */
162
- configHash;
163
- constructor(config, sessionService, memoryService) {
164
- this.config = config;
165
- this.sessionService = sessionService;
166
- this.memoryService = memoryService;
167
- }
168
- configHashFor() {
169
- if (!this.configHash) {
170
- try {
171
- this.configHash = configDigest(this.config, (ref) => loadSyndicate(ref));
172
- }
173
- catch {
174
- this.configHash = 'unhashable';
175
- }
176
- }
177
- return this.configHash;
178
- }
179
- /**
180
- * Run ONE agent for ONE turn against a named session, draining its event
181
- * stream. DELEGATE mode calls this once (the orchestrator); PLAN-DISPATCH
182
- * calls it twice — the classifier, then the route it chose.
183
- *
184
- * Errors are RETURNED rather than published, because the two callers want
185
- * different things from them: a failed specialist is a failed task, but a
186
- * failed classifier must fall back to the default route and still answer.
187
- */
188
- async runTurn(params) {
189
- const { agent, sessionId, userId, userParts, taskId, contextId, eventBus } = params;
190
- const runner = new Runner({
191
- agent,
192
- appName: A2A_APP_NAME,
193
- sessionService: params.sessionService ?? this.sessionService,
194
- ...(this.memoryService ? { memoryService: this.memoryService } : {})
195
- });
196
- let stream = runner.runAsync({
197
- userId,
198
- sessionId,
199
- newMessage: { role: 'user', parts: userParts },
200
- ...(this.config.max_steps !== undefined ? { maxSteps: this.config.max_steps } : {})
201
- });
202
- let combinedText = '';
203
- let lastToolResultText = '';
204
- const invokedToolNames = new Set();
205
- // The DELEGATE relay-fallback predicate, evaluated by the tracer in its
206
- // finally (after the last event) so the turn row records whether the
207
- // orchestrator's relay failed. Same rule as the substitution below.
208
- const normalizeName = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
209
- const relayFallbackFired = () => {
210
- const text = combinedText.trim();
211
- const echoed = text.length > 0 && [...invokedToolNames].some((n) => normalizeName(n) === normalizeName(text));
212
- return (!text || echoed) && !!lastToolResultText;
213
- };
214
- if (params.trace) {
215
- stream = traceAgentRun(stream, {
216
- syndicateName: this.config.syndicate_name || 'melchizedek-syndicate',
217
- bindings: this.config.variables || {},
218
- input: userParts,
219
- route: params.route,
220
- // Identity: the CONVERSATION id (contextId), not the classifier's
221
- // throwaway lane — every stage of one turn shares session/task ids.
222
- sessionId: contextId,
223
- userId,
224
- taskId,
225
- stage: params.stage,
226
- configHash: this.configHashFor(),
227
- attributes: surfaceAttributes(requestContextStorage.getStore()),
228
- onEnd: () => ({
229
- 'syndicate.relay_fallback': params.stage === 'delegate' && relayFallbackFired(),
230
- }),
231
- });
232
- }
233
- let lastTokenTotal = 0;
234
- let lastThinkingTokens = 0;
235
- let error;
236
- const grounding = newGroundingState();
237
- let groundingAnnounced = false;
238
- for await (const event of stream) {
239
- const evAny = event;
240
- // Native search grounding rides on the model event, not on a tool call.
241
- if (collectGrounding(evAny, grounding)) {
242
- const firstSight = grounding.queries.size + grounding.sources.size > 0 && !groundingAnnounced;
243
- console.log(`[A2A] ⌕ Grounding: ${describeGrounding(grounding)}`);
244
- if (params.publishToolStatus && firstSight) {
245
- // Same shape as a function-tool line so consumers that parse
246
- // "Invoking tool:" (nihilistic-penguin RouteTrace) list it.
247
- publishWorking(eventBus, taskId, contextId, `Invoking tool: web_search`);
248
- }
249
- groundingAnnounced = true;
250
- }
251
- if ((evAny.errorCode || evAny.errorMessage) && evAny.errorCode !== 'STOP') {
252
- error = {
253
- code: evAny.errorCode ?? 'ERROR',
254
- message: evAny.errorMessage ?? ''
255
- };
256
- console.error(`[A2A] Error [${error.code}]: ${error.message}`);
257
- break;
258
- }
259
- // Tool calls — log formatted name + args, publish working status to eventBus
260
- const calls = getFunctionCalls(event);
261
- if (calls && calls.length > 0) {
262
- for (const call of calls) {
263
- if (call.name === 'transfer_to_agent') {
264
- const target = call.args?.agentName ?? 'unknown';
265
- console.log(`[A2A] → Delegating to: ${target}`);
266
- if (params.publishToolStatus) {
267
- publishWorking(eventBus, taskId, contextId, `Delegating to subagent: ${target}`);
268
- }
269
- }
270
- else {
271
- const argStr = formatToolArgs(call.args);
272
- if (call.name)
273
- invokedToolNames.add(call.name);
274
- console.log(`[A2A] → Tool: ${call.name}(${argStr})`);
275
- if (params.publishToolStatus) {
276
- publishWorking(eventBus, taskId, contextId, `Invoking tool: ${call.name}`);
277
- }
278
- }
279
- }
280
- }
281
- // Tool responses — log name and result size (avoids dumping raw JSON/base64 to logs)
282
- const responses = getFunctionResponses(event);
283
- if (responses && responses.length > 0) {
284
- for (const resp of responses) {
285
- const respAny = resp;
286
- const respName = respAny.name ?? respAny.functionResponse?.name ?? 'unknown';
287
- const respContent = respAny.response ?? respAny.functionResponse?.response ?? {};
288
- const resultStr = typeof respContent === 'string' ? respContent : JSON.stringify(respContent);
289
- if (respName && respName !== 'unknown')
290
- invokedToolNames.add(respName);
291
- console.log(`[A2A] ← Result: ${respName} — ${resultStr.length.toLocaleString()} chars`);
292
- // Kept as the DELEGATE-mode relay fallback below. Only plain text
293
- // results qualify — a structured/base64 payload (e.g. image tools)
294
- // is not a relayable answer.
295
- const resultText = typeof respContent === 'string'
296
- ? respContent
297
- : typeof respContent.result === 'string' ? respContent.result : '';
298
- if (resultText.trim()) {
299
- lastToolResultText = resultText.trim();
300
- }
301
- }
302
- }
303
- // The answer is the LAST turn's text, not every turn's — the same rule
304
- // AgentTool applied by returning only its final event. It mattered the
305
- // moment specialists started running directly: a tool-using agent
306
- // narrates between calls ("Pulling FinTwit-specific NVDA signal…"), and
307
- // accumulating glued that commentary to the front of its report. A
308
- // prompt rule against narrating was not enough on grok-4.5, so the rule
309
- // lives here instead. Thinking traces are suppressed throughout (too
310
- // verbose for the server log, and never part of the answer).
311
- //
312
- // Within ONE event, parts are joined — a single answer legitimately
313
- // arrives in several parts. `partial` events are streaming chunks of the
314
- // turn in progress, so they append rather than replace; this deployment
315
- // does not enable streaming, but getting it wrong would truncate an
316
- // answer, which is the failure this whole area exists to prevent.
317
- let eventText = '';
318
- if (event.content?.parts) {
319
- for (const part of event.content.parts) {
320
- const textContent = part.text ?? '';
321
- const isThought = part.thought === true;
322
- if (!isThought && textContent) {
323
- eventText += textContent;
324
- }
325
- }
326
- }
327
- if (eventText) {
328
- combinedText = evAny.partial === true ? combinedText + eventText : eventText;
329
- }
330
- }
331
- const sourcesLine = webSourcesLine(grounding);
332
- if (params.publishToolStatus && sourcesLine) {
333
- // Published AFTER the loop so it lands once, with the full set. A
334
- // consumer contract (penguin RouteTrace `_SOURCES_RE`): "Web sources: a, b".
335
- publishWorking(eventBus, taskId, contextId, sourcesLine);
336
- }
337
- return {
338
- text: combinedText.trim(),
339
- lastToolResultText,
340
- invokedToolNames,
341
- grounding: (grounding.queries.size || grounding.sources.size)
342
- ? { queries: [...grounding.queries], sources: [...grounding.sources] }
343
- : undefined,
344
- tokenInfo: lastTokenTotal > 0
345
- ? ` | ${lastTokenTotal.toLocaleString()} tokens${lastThinkingTokens > 0 ? ` (${lastThinkingTokens.toLocaleString()} thinking)` : ''}`
346
- : '',
347
- error
348
- };
349
- }
350
- async execute(requestContext, eventBus) {
351
- const message = requestContext.userMessage;
352
- const rawParts = message.content || message.parts || [];
353
- const userParts = rawParts.map((p) => {
354
- if (typeof p === 'string')
355
- return { text: p };
356
- return { text: p.text || '' };
357
- });
358
- const contextId = requestContext.contextId;
359
- const taskId = requestContext.taskId;
360
- const queryPreview = userParts.map((p) => p.text ?? '').join(' ').replace(/\n/g, ' ').slice(0, 120);
361
- console.log(`[A2A] ─── Task ${taskId.slice(0, 8)} | ${this.config.syndicate_name}`);
362
- console.log(`[A2A] Query: "${queryPreview}${queryPreview.length >= 120 ? '...' : ''}"`);
363
- try {
364
- // Initialize task on the event bus to allow non-blocking clients to start polling immediately
365
- eventBus.publish({
366
- id: taskId,
367
- kind: 'task',
368
- contextId,
369
- status: {
370
- state: 'submitted',
371
- timestamp: new Date().toISOString()
372
- },
373
- history: []
374
- });
375
- const authContext = requestContextStorage.getStore();
376
- if (!authContext) {
377
- throw new Error('No authentication context available.');
378
- }
379
- // Session & memory isolation: per-credential by API-key hash, and
380
- // per end-user beneath it when the caller supplies X-User-Id.
381
- // See deriveUserId() for the silo contract.
382
- const userId = deriveUserId(authContext);
383
- if (userParts.length === 0)
384
- throw new Error('Message parts are required.');
385
- // Model resolution: the YAML model id always wins — its prefix names
386
- // the provider (claude-*, gpt-*, grok-*, ollama/*, else Gemini). The
387
- // X-Provider header is DEPRECATED and only picks a default model when
388
- // the YAML omits `model` entirely. lib/models/registry.ts is the
389
- // single implementation, shared with the CLI path.
390
- const resolveModelForRequest = (modelName) => resolveModel(modelName, {
391
- apiKey: authContext.apiKey,
392
- defaultProvider: authContext.provider,
393
- });
394
- // Compile ADK nodes through the shared compiler (lib/compile.ts) — the
395
- // observatory runs the same function, so the graph an eval measures is
396
- // the graph this server serves. compileSubagent builds ONE runnable
397
- // agent from a subagent entry; in DELEGATE mode compileGraph wraps it in
398
- // an AgentTool, in PLAN-DISPATCH the same agent is run directly as the
399
- // chosen route, so both methods dispatch to identical agents.
400
- const compileOpts = {
401
- resolveModel: resolveModelForRequest,
402
- onUnknownTool: (name) => console.warn(`[A2A] WARNING: Unknown tool '${name}' — skipping.`),
403
- log: (message) => console.log(`[A2A] ${message}`),
404
- };
405
- const compileSubagent = (subCfg) => compileSubagentShared(subCfg, compileOpts);
406
- const compileGraph = (configObj) => compileGraphShared(configObj, compileOpts);
407
- const ensureSession = async (sessionId) => {
408
- const existing = await this.sessionService.getSession({
409
- sessionId,
410
- appName: A2A_APP_NAME,
411
- userId
412
- });
413
- if (existing)
414
- return true;
415
- await this.sessionService.createSession({
416
- sessionId,
417
- appName: A2A_APP_NAME,
418
- userId
419
- });
420
- return false;
421
- };
422
- const failTask = (text) => {
423
- eventBus.publish({
424
- kind: 'status-update',
425
- taskId,
426
- contextId,
427
- status: {
428
- state: 'failed',
429
- message: {
430
- kind: 'message',
431
- messageId: crypto.randomUUID(),
432
- role: 'agent',
433
- parts: [{ kind: 'text', text }],
434
- contextId,
435
- taskId
436
- },
437
- timestamp: new Date().toISOString()
438
- },
439
- final: true
440
- });
441
- };
442
- const resumed = await ensureSession(contextId);
443
- console.log(`[A2A] Session: ${resumed ? 'resumed' : 'new'} — context ${contextId.slice(0, 8)}`);
444
- let finalText = '';
445
- let tokenInfo = '';
446
- let routerTokenInfo = '';
447
- if (isDispatchSyndicate(this.config)) {
448
- // ══ PLAN ═══════════════════════════════════════════════════════════
449
- // Deterministic overrides come FIRST: when the message itself decides
450
- // the route (an x.com link only XScout can open, an explicit demand
451
- // for a named tool), there is nothing to classify. The classifier LLM
452
- // call is skipped entirely — cheaper, and immune to a weak classifier
453
- // out-voting a stated rule (2026-08-15).
454
- const overrideWarnings = [];
455
- const messageText = userParts.map((p) => p.text ?? '').join('\n');
456
- let resolution = matchRouteOverride(messageText, this.config, overrideWarnings);
457
- for (const warning of overrideWarnings)
458
- console.warn(`[A2A] ⚠ ${warning}`);
459
- if (resolution) {
460
- console.log(`[A2A] ⇄ Route pinned by override: ${resolution.route}`);
461
- }
462
- else {
463
- // ── The classifier reads the SHARED transcript ──────────────────
464
- // Its JSON verdicts must never enter the user-facing transcript —
465
- // the next specialist would read them as conversation — so it does
466
- // not write to the shared session. It used to get its own durable
467
- // `<contextId>::route` lane instead, and that lane was the bug: it
468
- // accumulated the user's messages and the router's own verdicts and
469
- // NOTHING ELSE, so the router had never seen a single answer it had
470
- // routed. On 2026-08-15 a user's counter-argument to the desk's
471
- // MU/SNDK/NVDA/AMD verdict read to it as a remark with no request
472
- // behind it and went to small talk; its own rule 2 (redo/method
473
- // complaint) and rule 5 (follow-ups on prior analysis) were dead
474
- // letters, since neither can be recognised without the answer being
475
- // followed up. The lane also had HOLES: a message pinned by
476
- // `route_overrides` skips this branch entirely and was therefore
477
- // never recorded, so the X recon that opened that same thread was
478
- // missing from the router's history altogether.
479
- //
480
- // Now history arrives as INPUT — a compact digest of the real
481
- // exchange, both sides of it — and the lane is a throwaway
482
- // in-memory session that is discarded with the request. Nothing to
483
- // keep in sync, nothing to leave holes in, nothing persisted.
484
- const routerAgent = await compileGraph(this.config);
485
- const routerSessionId = `${contextId}::route`;
486
- const routerSessions = new InMemorySessionService();
487
- await routerSessions.createSession({
488
- sessionId: routerSessionId,
489
- appName: A2A_APP_NAME,
490
- userId
491
- });
492
- const sharedSession = await this.sessionService.getSession({
493
- sessionId: contextId,
494
- appName: A2A_APP_NAME,
495
- userId
496
- });
497
- const digest = renderTranscriptDigest(sharedSession?.events ?? []);
498
- const routerParts = digest
499
- ? [{
500
- text: `RECENT CONVERSATION, oldest first — context for your classification only. `
501
- + `Never answer it, and never route on it alone; it is here so you can tell a `
502
- + `follow-up, a redo request, or a challenge to a previous answer apart from small talk.\n`
503
- + `${digest}\n\n--- MESSAGE TO CLASSIFY ---\n${messageText}`
504
- }]
505
- : userParts;
506
- const plan = await this.runTurn({
507
- agent: routerAgent,
508
- sessionId: routerSessionId,
509
- userId,
510
- userParts: routerParts,
511
- taskId,
512
- contextId,
513
- eventBus,
514
- trace: true, // one row per classification: misroutes are diagnosable from stored data
515
- stage: 'classify',
516
- publishToolStatus: false, // a dispatch classifier holds no tools
517
- sessionService: routerSessions
518
- });
519
- // Fail-static: a dead classifier costs the user a good route, never
520
- // their answer. An empty payload resolves to default_route by design.
521
- resolution = resolveRoute(plan.error ? '' : plan.text, this.config);
522
- if (plan.error) {
523
- console.warn(`[A2A] ⚠ Router failed [${plan.error.code}] — defaulting to '${resolution.route}'.`);
524
- }
525
- else if (resolution.fellBack) {
526
- console.warn(`[A2A] ⚠ Routing fell back to '${resolution.route}': ${resolution.fallbackReason}`);
527
- }
528
- routerTokenInfo = plan.tokenInfo;
529
- }
530
- const routeCfg = (this.config.subagents || []).find(sub => sub.name === resolution.route);
531
- if (!routeCfg) {
532
- throw new Error(`Dispatch failed: no subagent named '${resolution.route}' is declared.`);
533
- }
534
- const routeNote = resolution.reason ? ` — ${resolution.reason}` : '';
535
- console.log(`[A2A] ⇄ Route: ${resolution.route}${routeNote}${routerTokenInfo}`);
536
- publishWorking(eventBus, taskId, contextId, `Routed to ${resolution.route}${routeNote}`);
537
- // ══ DISPATCH ═══════════════════════════════════════════════════════
538
- // The chosen route answers the user directly — its output IS the task
539
- // result, with no relay turn to lose or mangle it. It runs in the
540
- // SHARED session, so every route writes into one transcript: a
541
- // follow-up handled by a different specialist still sees what the last
542
- // one said, and long-term memory ingests real answers rather than a
543
- // relay copy of them.
544
- //
545
- // It READS that transcript through a projection (lib/session/
546
- // transcript.ts), because sharing the session is not the same as
547
- // sharing the conversation. ADK renders any event authored by a
548
- // different agent as `role: "user"` prefixed "For context:" — so a
549
- // route reading a shared session gets the whole thread as one
550
- // undifferentiated user monologue, with the previous route's private
551
- // reasoning and raw tool payloads mixed into it. The projection hands
552
- // it real `role: "model"` turns instead, labelled by the desk that
553
- // spoke, with thoughts and tool traffic stripped.
554
- const routeAgent = await compileSubagent(routeCfg);
555
- const turn = await this.runTurn({
556
- agent: routeAgent,
557
- sessionId: contextId,
558
- userId,
559
- userParts,
560
- taskId,
561
- contextId,
562
- eventBus,
563
- trace: true,
564
- stage: 'dispatch',
565
- publishToolStatus: true,
566
- route: resolution,
567
- sessionService: new ProjectedSessionService(this.sessionService, routeCfg.name)
568
- });
569
- if (turn.error) {
570
- failTask(`Error: [${turn.error.code}] ${resolution.route} failed to answer — ${describeTurnError(turn.error)}`);
571
- return;
572
- }
573
- tokenInfo = turn.tokenInfo;
574
- finalText = turn.text;
575
- if (!finalText) {
576
- // Naming the route turns a blank reply into a lead. This is the
577
- // shape the XScout outage took: the specialist ran and returned
578
- // nothing because its provider rejected the call upstream.
579
- console.warn(`[A2A] ⚠ Route '${resolution.route}' produced no output.`);
580
- finalText = `${resolution.route} returned no output — the server logs carry the upstream error.`;
581
- }
582
- }
583
- else {
584
- // ══ DELEGATE (classic) ═════════════════════════════════════════════
585
- // Subagents are AgentTools; the orchestrator relays the answer it got.
586
- const orchestrator = await compileGraph(this.config);
587
- const turn = await this.runTurn({
588
- agent: orchestrator,
589
- sessionId: contextId,
590
- userId,
591
- userParts,
592
- taskId,
593
- contextId,
594
- eventBus,
595
- trace: true,
596
- stage: 'delegate',
597
- publishToolStatus: true
598
- });
599
- if (turn.error) {
600
- failTask(`Error: [${turn.error.code}] The agent run failed — ${describeTurnError(turn.error)}`);
601
- return;
602
- }
603
- tokenInfo = turn.tokenInfo;
604
- // Failed-relay fallback: an orchestrator (flash-lite especially) can
605
- // botch the hop that relays a specialist answer, in two observed ways —
606
- // finishing STOP with zero output tokens, or emitting the bare tool NAME
607
- // as its text ("FinancialAnalyst", 3 tokens) instead of the answer. Both
608
- // discard a fully-formed result, so the last tool result is returned
609
- // deterministically rather than retrying the relay LLM (a retry costs a
610
- // full round-trip and can fail the same way again).
611
- //
612
- // PLAN-DISPATCH does not need any of this — it has no relay turn to
613
- // fail. This branch is why the two methods exist side by side.
614
- //
615
- // Degenerate-text detection is deliberately narrow — an exact match to an
616
- // invoked tool name after stripping non-alphanumerics — because short
617
- // real answers are legitimate ("Yes, as of Q2 2026."), so no length or
618
- // ratio heuristic may be used here.
619
- const normalize = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
620
- finalText = turn.text;
621
- const echoedToolName = finalText.length > 0 && [...turn.invokedToolNames]
622
- .some(name => normalize(name) === normalize(finalText));
623
- if ((!finalText || echoedToolName) && turn.lastToolResultText) {
624
- const reason = echoedToolName
625
- ? `echoed the tool name ("${finalText}")`
626
- : 'emitted no text';
627
- console.warn(`[A2A] ⚠ Orchestrator ${reason} — relaying last tool result verbatim (${turn.lastToolResultText.length.toLocaleString()} chars).`);
628
- finalText = turn.lastToolResultText;
629
- }
630
- }
631
- if (finalText) {
632
- console.log(`[A2A] ✓ Task ${taskId.slice(0, 8)} complete — ${finalText.length.toLocaleString()} chars${tokenInfo}`);
633
- eventBus.publish({
634
- kind: 'status-update',
635
- taskId,
636
- contextId,
637
- status: {
638
- state: 'completed',
639
- message: {
640
- kind: 'message',
641
- messageId: crypto.randomUUID(),
642
- role: 'agent',
643
- parts: [{ kind: 'text', text: finalText }],
644
- contextId,
645
- taskId
646
- },
647
- timestamp: new Date().toISOString()
648
- },
649
- final: true
650
- });
651
- }
652
- else {
653
- console.log(`[A2A] ✓ Task ${taskId.slice(0, 8)} complete — empty output`);
654
- eventBus.publish({
655
- kind: 'status-update',
656
- taskId,
657
- contextId,
658
- status: {
659
- state: 'completed',
660
- timestamp: new Date().toISOString()
661
- },
662
- final: true
663
- });
664
- }
665
- // Long-term memory ingestion: the A2A server is stateless, so there is
666
- // no "session end" moment like the CLI's exit — ingest after every
667
- // completed task instead. The memory service dedupes facts per user
668
- // key, so re-serializing the same conversation next turn is harmless.
669
- // Runs AFTER the final status publish so it never delays the reply.
670
- if (this.memoryService) {
671
- try {
672
- const endedSession = await this.sessionService.getSession({
673
- sessionId: contextId,
674
- appName: A2A_APP_NAME,
675
- userId
676
- });
677
- if (endedSession && endedSession.events.length > 0) {
678
- // The Supabase service accepts per-consumer extraction rules; the
679
- // base interface does not declare them, and only that service is
680
- // ever wired here (startServer constructs it directly).
681
- await this.memoryService
682
- .addSessionToMemory(endedSession, this.config.memory_extraction_rules);
683
- }
684
- }
685
- catch (memErr) {
686
- const msg = memErr instanceof Error ? memErr.message : String(memErr);
687
- console.error(`[A2A] ⚠ Memory ingestion failed (reply already delivered): ${msg}`);
688
- }
689
- }
690
- }
691
- catch (error) {
692
- console.error(`[A2A] Exception on task ${taskId.slice(0, 8)}: ${error.message}`);
693
- eventBus.publish({
694
- kind: 'status-update',
695
- taskId,
696
- contextId,
697
- status: {
698
- state: 'failed',
699
- message: {
700
- kind: 'message',
701
- messageId: crypto.randomUUID(),
702
- role: 'agent',
703
- parts: [{ kind: 'text', text: 'Internal Error: the request could not be completed. See server logs for details.' }],
704
- contextId,
705
- taskId
706
- },
707
- timestamp: new Date().toISOString()
708
- },
709
- final: true
710
- });
711
- }
712
- finally {
713
- eventBus.finished();
714
- }
715
- }
716
- async cancelTask() { }
124
+ function fatal(message) {
125
+ emit('error', `[A2A] ✗ FATAL: ${message}`);
126
+ process.exit(1);
717
127
  }
718
- // ── Bootstrap Routine ──────────────────────────────────────────────────────
719
- export async function startServer(syndicateName = 'syndicate.yaml') {
128
+ export async function startServer(syndicateName = 'syndicate.yaml', extensions = {}) {
720
129
  loadEnv(import.meta.url);
721
- // Suppress ADK internal INFO/DEBUG logs (raw event JSON, embeddings, binary blobs)
130
+ // Conversation content stays out of stdout unless the operator opts in:
131
+ // the root span carries the full user message and answer.
132
+ if (process.env.OTEL_CONSOLE_SPANS === undefined)
133
+ process.env.OTEL_CONSOLE_SPANS = 'false';
134
+ // ADK's INFO/DEBUG logs carry raw event JSON; keep them quiet.
722
135
  setLogLevel(LogLevel.WARN);
723
- const serverBindings = {
724
- current_date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
725
- };
726
- let config;
727
- if (syndicateName.startsWith('registry:')) {
728
- const registryId = syndicateName.replace('registry:', '');
729
- // Lazy import to prevent circular dependency issues during regular boot
730
- const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
731
- config = await loadSyndicateFromRegistry(registryId, { bindings: serverBindings });
136
+ const publicUrl = process.env.PUBLIC_URL?.trim() || undefined;
137
+ const port = envInt('PORT', 4000);
138
+ // ── Authentication posture ─────────────────────────────────────────────────
139
+ let secret = process.env.A2A_SERVER_SECRET?.trim() || undefined;
140
+ if (secret && isPlaceholderValue(secret)) {
141
+ fatal('A2A_SERVER_SECRET is still the .env.example placeholder. Generate one: openssl rand -hex 32');
732
142
  }
733
- else {
734
- config = loadSyndicate(syndicateName, { bindings: serverBindings });
143
+ if (secret && secret.length < MIN_SECRET_LENGTH) {
144
+ emit('warn', `[A2A] ⚠ A2A_SERVER_SECRET is shorter than ${MIN_SECRET_LENGTH} characters; use a long random value.`);
735
145
  }
736
- const agentCard = compileAgentCard(config);
737
- // Persistence: sessions whenever Supabase credentials exist. The memory
738
- // service is constructed alongside (it's just a client handle) but is only
739
- // handed to executors whose syndicate opts in via memory_system:
740
- // "long-term" — static agent here, dynamic agents per-config below.
741
- // Embeddings and fact extraction use the SERVER's Gemini key (memory is
742
- // operator infrastructure, like tools); the caller's BYOK key only funds
743
- // inference.
744
- const wantsLongTermMemory = config.memory_system === 'long-term';
745
- let sessionService = new InMemorySessionService();
746
- let memoryService;
747
- if (hasSupabaseCredentials()) {
748
- const services = await createSupabaseServices({
749
- apiKey: process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY || '',
750
- withMemory: true
751
- });
752
- sessionService = services.sessionService;
753
- memoryService = services.memoryService;
754
- if (wantsLongTermMemory) {
755
- console.log(`[A2A] Long-term memory enabled for "${config.syndicate_name}" (per-user silos via X-User-Id).`);
146
+ const list = (name) => process.env[name] ? process.env[name].split(',').map((s) => s.trim()).filter(Boolean) : undefined;
147
+ const keyModeRaw = (process.env.A2A_KEY_MODE ?? 'server').trim().toLowerCase();
148
+ if (keyModeRaw !== 'server' && keyModeRaw !== 'byok')
149
+ fatal(`A2A_KEY_MODE must be 'server' or 'byok' (got '${keyModeRaw}')`);
150
+ const keyMode = keyModeRaw;
151
+ // ── Identity: who a caller is, and whose data a request touches (ADR 0025) ─
152
+ const authKind = (process.env.A2A_AUTH ?? 'secret').trim().toLowerCase();
153
+ let authenticator;
154
+ let identityLabel = '';
155
+ try {
156
+ if (authKind === 'callers') {
157
+ const spec = process.env.A2A_CALLERS?.trim();
158
+ if (!spec)
159
+ fatal('A2A_AUTH=callers needs A2A_CALLERS (name:sha256[:scope]; …). Mint a caller: melchizedek-serve --new-caller <name>');
160
+ const callers = parseCallers(spec);
161
+ authenticator = secret ? firstOf(callerTokens(callers), sharedSecret({ secret, keyMode })) : callerTokens(callers);
162
+ identityLabel = `caller tokens (${callers.map((c) => c.name).join(', ')})${secret ? ' + the legacy A2A_SERVER_SECRET' : ''}`;
163
+ }
164
+ else if (authKind === 'jwt') {
165
+ const issuer = list('A2A_JWT_ISSUER');
166
+ const audience = list('A2A_JWT_AUDIENCE');
167
+ if (!issuer || !audience)
168
+ fatal('A2A_AUTH=jwt needs A2A_JWT_ISSUER and A2A_JWT_AUDIENCE.');
169
+ authenticator = jwtIdentity({
170
+ jwksUrl: process.env.A2A_JWT_JWKS_URL?.trim() || undefined,
171
+ secret: process.env.A2A_JWT_SECRET?.trim() || undefined,
172
+ issuer: issuer.length === 1 ? issuer[0] : issuer,
173
+ audience: audience.length === 1 ? audience[0] : audience,
174
+ scopeClaim: process.env.A2A_JWT_SCOPE_CLAIM?.trim() || undefined,
175
+ tenantClaim: process.env.A2A_JWT_TENANT_CLAIM?.trim() || undefined,
176
+ });
177
+ identityLabel = `JWT (iss ${issuer.join(', ')})`;
756
178
  }
757
- // Defense-in-depth check: warn operators when db/hardening.sql has not
758
- // been applied. Without it, the anon/authenticated Supabase API paths
759
- // can read adk_sessions and adk_memory_facts directly — the application
760
- // silo (user_key) does nothing against that route. Escalation mirrors
761
- // the A2A_SERVER_SECRET pattern: fatal for public deployments unless
762
- // explicitly overridden.
763
- const rls = await services.checkRlsHardening();
764
- if (rls.applied) {
765
- console.log(`[A2A] ✓ DB hardening verified — ${rls.detail}.`);
179
+ else if (authKind === 'header') {
180
+ const header = process.env.A2A_TRUSTED_USER_HEADER?.trim();
181
+ if (!header)
182
+ fatal('A2A_AUTH=header needs A2A_TRUSTED_USER_HEADER.');
183
+ if (!secret)
184
+ fatal('A2A_AUTH=header needs A2A_SERVER_SECRET: only the gateway holding it may set the user header.');
185
+ authenticator = trustedHeader({ header });
186
+ identityLabel = `gateway header ${header} behind the bearer secret`;
766
187
  }
767
- else {
768
- const allowUnhardened = process.env.ALLOW_UNHARDENED_DB === 'true';
769
- if (process.env.PUBLIC_URL && !allowUnhardened) {
770
- console.error('[A2A] ✗ FATAL: PUBLIC_URL is set but Supabase hardening is missing');
771
- console.error(`[A2A] (${rls.detail}).`);
772
- console.error('[A2A] User transcripts and memory facts are exposed to the anon API key.');
773
- console.error('[A2A] Run db/hardening.sql in the Supabase SQL Editor, or set');
774
- console.error('[A2A] ALLOW_UNHARDENED_DB=true to accept the risk explicitly.');
775
- process.exit(1);
776
- }
777
- console.log(`[A2A] ⚠ WARNING: Supabase hardening not applied — ${rls.detail}.`);
778
- console.log('[A2A] Run db/hardening.sql before serving real user data. Details:');
779
- console.log('[A2A] lib/memory/README.md → "Hardening".');
188
+ else if (authKind !== 'secret') {
189
+ fatal(`A2A_AUTH must be secret, callers, jwt or header (got '${authKind}')`);
780
190
  }
781
191
  }
782
- else if (wantsLongTermMemory) {
783
- console.log('[A2A] ⚠ WARNING: syndicate requests long-term memory but Supabase credentials are missing — memory disabled.');
192
+ catch (err) {
193
+ fatal(err instanceof Error ? err.message : String(err));
784
194
  }
785
- const app = express();
786
- app.set('trust proxy', 1); // Trust Heroku proxy for accurate IP rate limiting
787
- // Security: Server-Level Authentication (Before body parsing)
788
- const serverSecret = process.env.A2A_SERVER_SECRET;
789
- if (!serverSecret) {
790
- // Running without a shared secret leaves every endpoint open (the BYOK
791
- // X-API-Key check only verifies presence, not validity). Tolerate this for
792
- // local dev, but refuse to boot a publicly-addressable deployment.
793
- if (process.env.PUBLIC_URL) {
794
- console.error('[A2A] ✗ FATAL: PUBLIC_URL is set but A2A_SERVER_SECRET is missing. Refusing to start an unauthenticated public server.');
195
+ // Caller tokens and JWTs are bearer credentials checked by the
196
+ // authenticator; the shared secret is then not a gate of its own.
197
+ const bearerGate = authKind === 'secret' || authKind === 'header';
198
+ const authenticated = !!secret || !!authenticator;
199
+ let host = process.env.HOST?.trim() || undefined;
200
+ if (!authenticated) {
201
+ if (publicUrl)
202
+ fatal('PUBLIC_URL is set but A2A_SERVER_SECRET is missing. Refusing to start an unauthenticated public server.');
203
+ if (host && !isLoopback(host) && process.env.ALLOW_UNAUTHENTICATED !== 'true') {
204
+ fatal(`HOST=${host} would expose an unauthenticated server. Set A2A_SERVER_SECRET, or ALLOW_UNAUTHENTICATED=true to accept the risk.`);
205
+ }
206
+ // Without a secret the server answers only on this machine.
207
+ host = host ?? '127.0.0.1';
208
+ emit('warn', `[A2A] ⚠ A2A_SERVER_SECRET is not set: authentication is off and the server binds ${host} only.`);
209
+ }
210
+ /** A positive integer from the environment, or undefined; anything else stops the server. */
211
+ const positiveInt = (name) => {
212
+ const raw = process.env[name]?.trim();
213
+ if (!raw)
214
+ return undefined;
215
+ const n = Number(raw);
216
+ if (!Number.isInteger(n) || n < 1) {
217
+ console.error(`[A2A] ✗ ${name} must be a positive integer (got "${raw}").`);
795
218
  process.exit(1);
796
219
  }
797
- console.log('[A2A] ⚠ WARNING: A2A_SERVER_SECRET is not configured. Server-level authentication is disabled (local dev only).');
798
- }
799
- else {
800
- const expected = Buffer.from(serverSecret);
801
- app.use((req, res, next) => {
802
- const authHeader = req.headers.authorization;
803
- if (!authHeader || !authHeader.startsWith('Bearer ')) {
804
- res.status(401).json({ error: 'Unauthorized: Missing or invalid Authorization Bearer token' });
805
- return;
806
- }
807
- const token = Buffer.from(authHeader.substring(7));
808
- // Constant-time comparison to avoid leaking the secret via response timing.
809
- if (token.length !== expected.length || !timingSafeEqual(token, expected)) {
810
- res.status(401).json({ error: 'Unauthorized: Invalid Authorization Bearer token' });
811
- return;
812
- }
813
- next();
814
- });
220
+ return n;
221
+ };
222
+ const servedAgents = list('A2A_SERVED_AGENTS');
223
+ const registryAgents = list('A2A_REGISTRY_AGENTS');
224
+ // Storage: Postgres when DATABASE_URL is set (every durable store, safe
225
+ // across instances); otherwise createA2AApp's default (Supabase, else memory).
226
+ const databaseUrl = process.env.DATABASE_URL?.trim() || undefined;
227
+ const pgStorage = databaseUrl
228
+ ? postgresStorage({
229
+ connectionString: databaseUrl,
230
+ schema: dbSchema(),
231
+ ...(positiveInt('DATABASE_POOL_MAX') ? { poolConfig: { max: positiveInt('DATABASE_POOL_MAX') } } : {}),
232
+ ...(positiveInt('A2A_TURN_LOCK_POOL_MAX') ? { lockPoolMax: positiveInt('A2A_TURN_LOCK_POOL_MAX') } : {}),
233
+ memory: { apiKey: process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY || '' },
234
+ })
235
+ : undefined;
236
+ if (!pgStorage && dbSchema() !== 'public') {
237
+ console.error(`[A2A] ✗ MELCHIZEDEK_DB_SCHEMA=${dbSchema()} needs DATABASE_URL: a private schema is not reachable over the Supabase REST API.`);
238
+ process.exit(1);
815
239
  }
816
- app.use(express.json());
817
- // Security: BYOK Middleware
818
- // NOTE ON SCOPE: the X-API-Key only funds LLM *inference* (Gemini/Claude).
819
- // Tool calls (e.g. generate_image, google_search) use the server's own
820
- // keys from the environment, so they bill the operator, not the caller. This
821
- // check verifies the header is present, not that the key is valid — validity
822
- // is enforced upstream by the provider. The bearer token above is the real
823
- // gate; keep it set in any shared/public deployment.
824
- app.use((req, res, next) => {
825
- const apiKey = req.headers['x-api-key'];
826
- const provider = req.headers['x-provider'] || 'google';
827
- const rawUserId = req.headers['x-user-id'];
828
- if (!apiKey) {
829
- res.status(401).json({ error: 'Unauthorized: Missing X-API-Key header' });
830
- return;
831
- }
832
- // X-User-Id (optional): the caller's own identifier for the END USER
833
- // behind this request (e.g. a site's authenticated account id). It
834
- // silos sessions AND long-term memory per user beneath the caller's
835
- // API-key hash. Callers are responsible for authenticating their users
836
- // BEFORE putting an id in this header — the server trusts it within
837
- // the caller's own silo and it can never cross into another caller's.
838
- let siteUserId;
839
- if (rawUserId !== undefined && rawUserId !== '') {
840
- if (!SITE_USER_ID_PATTERN.test(rawUserId)) {
841
- res.status(400).json({ error: 'Invalid X-User-Id: must match [A-Za-z0-9._-]{1,64}' });
842
- return;
843
- }
844
- siteUserId = rawUserId;
845
- }
846
- // X-Surface-* (optional): where this request came from, for telemetry
847
- // only. Same validation as X-User-Id, and a bad value is refused rather
848
- // than dropped — a silently ignored header makes a dashboard lie about
849
- // coverage. `X-Surface` is required for the others to mean anything.
850
- let surface;
851
- for (const [header, field] of SURFACE_HEADERS) {
852
- const raw = req.headers[header];
853
- if (raw === undefined || raw === '')
854
- continue;
855
- if (!SURFACE_VALUE_PATTERN.test(raw)) {
856
- res.status(400).json({ error: `Invalid ${header}: must match [A-Za-z0-9._-]{1,64}` });
857
- return;
858
- }
859
- if (field === 'name') {
860
- surface = { name: raw };
861
- continue;
862
- }
863
- if (!surface) {
864
- res.status(400).json({ error: `${header} requires X-Surface to name the surface` });
865
- return;
866
- }
867
- surface[field] = raw;
868
- }
869
- requestContextStorage.run({ apiKey, provider, siteUserId, surface }, () => {
870
- next();
871
- });
872
- });
873
- // Security: Rate Limiting
874
- // Applied globally (not just to /a2a) so the per-agent dynamic routes
875
- // (/:agentId/a2a/...) can't be used to bypass the cap. GET requests are
876
- // exempted because task-status polling issues many GETs per task and is
877
- // cheap; the limit targets the expensive POST task submissions.
878
- // NOTE: the bearer-auth middleware above runs BEFORE this limiter, so only
879
- // AUTHENTICATED callers ever reach it — its role is runaway/cost protection
880
- // (a buggy client loop burning Gemini quota), not anonymous-abuse defense.
881
- // Sized for the nihilistic-penguin daily advisory pipeline (~34 task
882
- // submissions in one burst: 30 council + news + macro + architect + critic)
883
- // plus concurrent interactive Discord use; the old 20/hour cap forced that
884
- // pipeline into multi-window backoff crawls. GETs (task polling) stay exempt.
885
- const limiter = rateLimit({
886
- windowMs: 15 * 60 * 1000, // 15 minutes
887
- max: 60, // 60 task submissions per 15 min per IP (~240/h runaway ceiling)
888
- standardHeaders: true,
889
- legacyHeaders: false,
890
- skip: (req) => req.method === 'GET',
891
- message: { error: 'Too many requests, please try again later.' }
892
- });
893
- app.use(limiter);
894
- // ── Right-to-erasure: DELETE /memory ─────────────────────────────────────
895
- // Permanently removes every long-term fact in the CALLING silo — the
896
- // API-key hash plus X-User-Id, exactly as deriveUserId computes it for
897
- // storage. A site deletes one of its users by passing that user's id in
898
- // X-User-Id; it cannot name an arbitrary silo, so it can only erase what
899
- // it could write. Sessions (adk_sessions) are a separate store — see
900
- // lib/memory/README.md.
901
- app.delete('/memory', async (_req, res) => {
902
- if (!memoryService) {
903
- res.status(404).json({ error: 'Long-term memory is not enabled on this server.' });
904
- return;
905
- }
906
- const ctx = requestContextStorage.getStore();
907
- if (!ctx) {
908
- res.status(401).json({ error: 'No authentication context.' });
909
- return;
910
- }
911
- const userKey = `${A2A_APP_NAME}/${deriveUserId(ctx)}`;
240
+ // The task tools keep each caller's list in Postgres, not the shared file.
241
+ if (pgStorage)
242
+ setTaskBackend(pgStorage.taskQueue);
243
+ // ── Limits in Redis (ADR 0021 item 5): one window across replicas ────────
244
+ // `redis` is an optional peer dependency: installed only by deployments
245
+ // that set A2A_REDIS_URL.
246
+ let limitStore;
247
+ const redisUrl = process.env.A2A_REDIS_URL?.trim();
248
+ if (redisUrl) {
249
+ let createClient;
912
250
  try {
913
- const deleted = await memoryService.deleteUserMemory(userKey);
914
- console.log(`[A2A] Memory erasure: ${deleted} fact(s) removed for silo ${userKey}`);
915
- res.json({ deleted });
251
+ ({ createClient } = (await import(__rewriteRelativeImportExtension('redis'))));
916
252
  }
917
- catch (err) {
918
- const msg = err instanceof Error ? err.message : String(err);
919
- console.error(`[A2A] Memory erasure failed: ${msg}`);
920
- res.status(500).json({ error: 'Memory deletion failed. See server logs.' });
253
+ catch {
254
+ console.error('[A2A] ✗ A2A_REDIS_URL is set but the `redis` package is not installed: npm install redis');
255
+ process.exit(1);
921
256
  }
922
- });
923
- const requestHandler = new DefaultRequestHandler(agentCard, new InMemoryTaskStore(), new SyndicateExecutor(config, sessionService, wantsLongTermMemory ? memoryService : undefined));
924
- // Setup Static/Default Routing Interfaces (Backward Compatibility)
925
- app.use(`/${AGENT_CARD_PATH}`, agentCardHandler({ agentCardProvider: requestHandler }));
926
- app.use('/a2a/jsonrpc', jsonRpcHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
927
- app.use('/a2a/rest', restHandler({ requestHandler, userBuilder: UserBuilder.noAuthentication }));
928
- // Dynamic Multi-Agent Routing Cache
929
- const handlerCache = new Map();
930
- // Security: agent IDs address a syndicate config (file, or "registry:<id>").
931
- // Restrict to a safe charset so a crafted path segment cannot drive path
932
- // traversal through loadSyndicate() or the filesystem fallbacks below.
933
- function isValidAgentId(agentId) {
934
- return /^[A-Za-z0-9_.:-]+$/.test(agentId) && !agentId.includes('..');
257
+ const client = createClient({ url: redisUrl });
258
+ client.on?.('error', (err) => console.warn(`[A2A] ⚠ Redis: ${err.message}`));
259
+ await client.connect();
260
+ limitStore = (limiter) => redisRateLimitStore({ command: (args) => client.sendCommand(args), prefix: `melchizedek:rl:${limiter}:` });
935
261
  }
936
- async function getOrCreateHandlers(agentId) {
937
- if (!isValidAgentId(agentId)) {
938
- throw new Error(`Invalid agent id: '${agentId}'`);
939
- }
940
- let cached = handlerCache.get(agentId);
941
- if (cached)
942
- return cached;
943
- let targetConfig;
944
- // Fresh per-load bindings: the static routes get these via startServer's
945
- // serverBindings; without them here, dynamic agents would interpolate
946
- // {{current_date}} from stale YAML defaults (or leave it unresolved).
947
- const routeBindings = {
948
- current_date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
949
- };
950
- // Where the config came from is load-bearing operational information: a
951
- // bare agent id prefers the REGISTRY and only falls back to the repo file
952
- // when that lookup fails, so an id with a registry row ignores repo edits
953
- // until `scripts/deploy_agent.ts` re-publishes it. On 2026-08-15 two
954
- // rounds of routing fixes were pushed, deployed and restarted while
955
- // production kept serving a registry copy from a week earlier, and
956
- // nothing in the logs said so. Now it does.
957
- let configSource = '';
262
+ // ── Policy: daily budgets (ADR 0026) ──────────────────────────────────────
263
+ let policy;
264
+ let budgetLabel = '';
265
+ const budgetsJson = process.env.A2A_BUDGETS?.trim();
266
+ if (budgetsJson) {
958
267
  try {
959
- if (agentId.startsWith('registry:')) {
960
- const registryId = agentId.replace('registry:', '');
961
- const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
962
- targetConfig = await loadSyndicateFromRegistry(registryId, { bindings: routeBindings });
963
- configSource = `registry:${registryId}`;
268
+ const config = parseBudgets(budgetsJson);
269
+ let store;
270
+ if (pgStorage) {
271
+ store = postgresUsageStore(pgStorage.pool);
272
+ budgetLabel = 'postgres';
273
+ }
274
+ else if (hasSupabaseCredentials()) {
275
+ const { createClient } = await import('@supabase/supabase-js');
276
+ store = supabaseUsageStore(createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY));
277
+ budgetLabel = 'supabase';
964
278
  }
965
279
  else {
966
- try {
967
- const { loadSyndicateFromRegistry } = await import("../lib/loadSyndicate.js");
968
- targetConfig = await loadSyndicateFromRegistry(agentId, { bindings: routeBindings });
969
- configSource = `registry:${agentId} (repo file NOT served — deploy_agent.ts publishes edits)`;
970
- }
971
- catch {
972
- if (agentId.endsWith('.yaml')) {
973
- targetConfig = loadSyndicate(agentId, { bindings: routeBindings });
974
- configSource = `file:${agentId}`;
975
- }
976
- else {
977
- try {
978
- targetConfig = loadSyndicate(`config/agents/${agentId}.yaml`, { bindings: routeBindings });
979
- configSource = `file:config/agents/${agentId}.yaml`;
980
- }
981
- catch {
982
- targetConfig = loadSyndicate(`${agentId}.yaml`, { bindings: routeBindings });
983
- configSource = `file:${agentId}.yaml`;
984
- }
985
- }
986
- }
280
+ store = memoryUsageStore();
281
+ budgetLabel = 'process memory: per instance, reset on restart';
987
282
  }
988
- }
989
- catch (error) {
990
- throw new Error(`Failed to load syndicate config for '${agentId}': ${error.message}`);
991
- }
992
- console.log(`[A2A] ⚙ Loaded '${agentId}' from ${configSource}`);
993
- const card = compileAgentCard(targetConfig);
994
- const handler = new DefaultRequestHandler(card, new InMemoryTaskStore(), new SyndicateExecutor(targetConfig, sessionService, targetConfig.memory_system === 'long-term' ? memoryService : undefined));
995
- cached = {
996
- agentCard: agentCardHandler({ agentCardProvider: handler }),
997
- jsonRpc: jsonRpcHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication }),
998
- rest: restHandler({ requestHandler: handler, userBuilder: UserBuilder.noAuthentication })
999
- };
1000
- handlerCache.set(agentId, cached);
1001
- return cached;
1002
- }
1003
- // Dynamic Route Registration (Exposes /:agentId/a2a/...)
1004
- app.use('/:agentId/.well-known/agent-card.json', async (req, res, next) => {
1005
- console.log(`[A2A Server] GET /${req.params.agentId}/.well-known/agent-card.json`);
1006
- try {
1007
- const handlers = await getOrCreateHandlers(req.params.agentId);
1008
- handlers.agentCard(req, res, next);
1009
- }
1010
- catch (err) {
1011
- res.status(404).json({ error: err.message });
1012
- }
1013
- });
1014
- app.use('/:agentId/agent-card.json', async (req, res, next) => {
1015
- console.log(`[A2A Server] GET /${req.params.agentId}/agent-card.json`);
1016
- try {
1017
- const handlers = await getOrCreateHandlers(req.params.agentId);
1018
- handlers.agentCard(req, res, next);
283
+ policy = budgets(config, { store });
284
+ budgetLabel = `${Object.keys(config).join(', ')} (counted in ${budgetLabel})`;
1019
285
  }
1020
286
  catch (err) {
1021
- res.status(404).json({ error: err.message });
1022
- }
1023
- });
1024
- app.use('/:agentId/a2a/jsonrpc', async (req, res, next) => {
1025
- console.log(`[A2A Server] POST /${req.params.agentId}/a2a/jsonrpc`);
1026
- try {
1027
- const handlers = await getOrCreateHandlers(req.params.agentId);
1028
- handlers.jsonRpc(req, res, next);
1029
- }
1030
- catch (err) {
1031
- res.status(404).json({ error: err.message });
1032
- }
1033
- });
1034
- app.use('/:agentId/a2a/rest', async (req, res, next) => {
1035
- // Only log task submissions (POST), not the repeated GET polling requests
1036
- if (req.method !== 'GET') {
1037
- console.log(`[A2A] ${req.method} /${req.params.agentId}/a2a/rest`);
1038
- }
1039
- try {
1040
- const handlers = await getOrCreateHandlers(req.params.agentId);
1041
- handlers.rest(req, res, next);
1042
- }
1043
- catch (err) {
1044
- res.status(404).json({ error: err.message });
287
+ fatal(err instanceof Error ? err.message : String(err));
1045
288
  }
289
+ }
290
+ const metricsToken = process.env.A2A_METRICS_TOKEN?.trim() || undefined;
291
+ if (metricsToken && (isPlaceholderValue(metricsToken) || metricsToken.length < 24)) {
292
+ fatal('A2A_METRICS_TOKEN must be a long random value (openssl rand -hex 32).');
293
+ }
294
+ let built;
295
+ try {
296
+ built = await createA2AApp({
297
+ defaultSyndicate: syndicateName,
298
+ publicUrl,
299
+ port,
300
+ serverSecret: bearerGate ? secret : undefined,
301
+ ...(authenticator ?? {}),
302
+ requireHardenedDb: !!publicUrl && process.env.ALLOW_UNHARDENED_DB !== 'true',
303
+ taskTimeoutMs: envInt('A2A_TASK_TIMEOUT_MS', 15 * 60 * 1000),
304
+ streamText: process.env.A2A_STREAM_TEXT?.trim().toLowerCase() === 'true',
305
+ allowSchemaMismatch: process.env.ALLOW_SCHEMA_MISMATCH?.trim().toLowerCase() === 'true',
306
+ turnLockWaitMs: envInt('A2A_TURN_LOCK_WAIT_MS', 30_000),
307
+ maxConcurrentTasks: envInt('A2A_MAX_CONCURRENT_TASKS', 0),
308
+ rateLimit: { windowMs: envInt('A2A_RATE_LIMIT_WINDOW_MS', 15 * 60 * 1000), max: envInt('A2A_RATE_LIMIT_MAX', 60) },
309
+ authFailureLimit: { windowMs: 15 * 60 * 1000, max: envInt('A2A_AUTH_FAILURE_MAX', 30) },
310
+ trustProxy: envTrustProxy(),
311
+ ...(limitStore ? { limitStore } : {}),
312
+ bodyLimit: process.env.A2A_BODY_LIMIT?.trim() || '1mb',
313
+ servedAgents,
314
+ registryAgents,
315
+ keyMode,
316
+ ...(pgStorage ? { storage: pgStorage } : {}),
317
+ ...(policy ? { policy } : {}),
318
+ metricsToken,
319
+ log: (m) => emit('info', `[A2A] ${m}`),
320
+ warn: (m) => emit('warn', `[A2A] ⚠ ${m}`),
321
+ ...(jsonLogs() ? { onTaskEnd: (r) => emit('info', `task ${r.status}`, { event: 'task', ...r }) } : {}),
322
+ ...extensions.options,
323
+ routes: (app) => {
324
+ extensions.routes?.(app);
325
+ extensions.options?.routes?.(app);
326
+ },
327
+ });
328
+ }
329
+ catch (err) {
330
+ if (err?.code === 'ENOENT')
331
+ throw err;
332
+ fatal(err instanceof Error ? err.message : String(err));
333
+ }
334
+ const authMode = `${identityLabel || (secret ? 'bearer secret' : 'no secret (loopback only)')}; keys: ${keyMode === 'byok' ? "caller's X-API-Key (byok)" : "server's own"}`;
335
+ // The secret is not needed past this point; drop the reference.
336
+ secret = undefined;
337
+ const { app, config, sessionBackend, shutdown } = built;
338
+ const server = await new Promise((resolve) => {
339
+ const s = host ? app.listen(port, host, () => resolve(s)) : app.listen(port, () => resolve(s));
1046
340
  });
1047
- const PORT = process.env.PORT || 4000;
1048
- app.listen(PORT, () => {
1049
- console.log(`[A2A] Exposer server boot complete on port ${PORT}`);
341
+ // Load balancers commonly hold idle connections for 60 s; Node's default
342
+ // keep-alive of 5 s makes them reuse a socket Node has just closed (502s).
343
+ server.keepAliveTimeout = envInt('A2A_KEEP_ALIVE_TIMEOUT_MS', 65_000);
344
+ server.headersTimeout = server.keepAliveTimeout + 1_000;
345
+ const base = publicUrl ?? `http://${host && host !== '0.0.0.0' ? host : 'localhost'}:${port}`;
346
+ emit('info', `[A2A] ✓ Serving '${config.syndicate_name}' on ${host ?? '0.0.0.0'}:${port}`);
347
+ emit('info', `[A2A] card ${base}/.well-known/agent-card.json`);
348
+ emit('info', `[A2A] jsonrpc ${base}/a2a/jsonrpc rest ${base}/a2a/rest`);
349
+ emit('info', `[A2A] auth ${authMode}`);
350
+ emit('info', `[A2A] storage ${pgStorage ? 'postgres (multi-instance safe)' : sessionBackend === 'durable' ? 'supabase (tasks are per process: one replica)' : 'in-memory (lost on restart; one replica)'}`);
351
+ emit('info', `[A2A] agents ${servedAgents ? servedAgents.join(', ') : 'the default, plus any file in the agents directory at /:agentId/ (A2A_SERVED_AGENTS restricts)'}`);
352
+ if (registryAgents)
353
+ emit('info', `[A2A] registry ${registryAgents.join(', ')} (bare ids loaded from adk_agent_registry)`);
354
+ if (keyMode === 'server' && sessionBackend === 'durable' && authKind === 'secret') {
355
+ emit('warn', '[A2A] ⚠ Key mode is "server": sessions and memory are scoped by X-User-Id (else "default").');
356
+ emit('warn', '[A2A] Data written by earlier versions lives under key-hash silos (a2a-<hash>/…) that');
357
+ emit('warn', '[A2A] only A2A_KEY_MODE=byok reaches. Keep byok until that data is migrated.');
358
+ }
359
+ if (budgetLabel)
360
+ emit('info', `[A2A] budgets ${budgetLabel}`);
361
+ if (metricsToken)
362
+ emit('info', `[A2A] metrics ${base}/metrics (bearer A2A_METRICS_TOKEN)`);
363
+ emit('info', `[A2A] health ${base}/healthz ${base}/readyz`);
364
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
365
+ let stopping = false;
366
+ const graceMs = envInt('A2A_SHUTDOWN_GRACE_MS', 25_000);
367
+ const stop = async (signal) => {
368
+ if (stopping)
369
+ return;
370
+ stopping = true;
371
+ emit('info', `[A2A] ${signal}: draining — no new tasks; waiting up to ${graceMs} ms for running ones.`);
372
+ server.close();
373
+ const canceled = await shutdown(graceMs);
374
+ if (canceled > 0)
375
+ emit('warn', `[A2A] ⚠ ${canceled} task(s) did not finish in time and were canceled.`);
376
+ // Give canceled tasks a moment to publish their final status, then flush
377
+ // telemetry so the last turns reach the ledger, then release the pool.
378
+ await new Promise((r) => setTimeout(r, canceled > 0 ? 500 : 0));
379
+ await flushTracing().catch(() => { });
380
+ await pgStorage?.close().catch(() => { });
381
+ process.exit(0);
382
+ };
383
+ process.on('SIGTERM', () => void stop('SIGTERM'));
384
+ process.on('SIGINT', () => void stop('SIGINT'));
385
+ process.on('unhandledRejection', (reason) => {
386
+ emit('error', `[A2A] ⚠ Unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`);
1050
387
  });
388
+ return server;
1051
389
  }
1052
- // Run-as-main guard. Compare against the REALPATH of argv[1]: when invoked
1053
- // through the npm bin (`npx melchizedek-serve`), argv[1] is the .bin symlink
1054
- // while import.meta.url is the resolved file — a naive string compare never
1055
- // matches and the server silently does nothing (the 0.9.0 bin bug).
390
+ // Run-as-main guard. Compare against the REALPATH of argv[1]: through the
391
+ // npm bin, argv[1] is the .bin symlink while import.meta.url is the resolved
392
+ // file, so a naive compare never matches (the 0.9.0 bin bug).
1056
393
  const invokedAsMain = (() => {
1057
394
  if (!process.argv[1])
1058
395
  return false;
@@ -1064,13 +401,53 @@ const invokedAsMain = (() => {
1064
401
  }
1065
402
  })();
1066
403
  if (invokedAsMain) {
1067
- const syndicateFile = process.argv[2] || 'syndicate.yaml';
1068
- startServer(syndicateFile).catch((error) => {
1069
- if (error?.code === 'ENOENT' && !process.argv[2]) {
1070
- // Package consumers rarely have a syndicate.yaml — name the fix.
1071
- console.error('[A2A] No default syndicate.yaml in your agents directory.\n' +
1072
- ' Pass your syndicate: melchizedek-serve <name>.yaml\n' +
1073
- ' (agents directory: option --, MELCHIZEDEK_AGENTS_DIR, or <cwd>/config/agents)');
404
+ const arg = process.argv[2];
405
+ if (arg === '--help' || arg === '-h') {
406
+ console.log([
407
+ 'Usage: melchizedek-serve [<syndicate>.yaml | registry:<id>]',
408
+ ' melchizedek-serve --new-caller <name> [--scope <scope>] [--token-file <path>]',
409
+ '',
410
+ 'Serves a syndicate over A2A. Environment variables are listed at the top of',
411
+ 'scripts/a2a_server.ts and in .env.example.',
412
+ '',
413
+ '--new-caller mints a bearer token for one calling backend (A2A_AUTH=callers)',
414
+ 'and prints the A2A_CALLERS entry, which holds only the token\'s SHA-256.',
415
+ '--scope keeps the caller on an existing scope (e.g. its a2a-<hash> silo).',
416
+ '--token-file writes the token to a new file (mode 600) instead of printing it.',
417
+ ].join('\n'));
418
+ process.exit(0);
419
+ }
420
+ if (arg === '--new-caller') {
421
+ const flag = (name) => {
422
+ const i = process.argv.indexOf(name);
423
+ return i > 0 ? process.argv[i + 1] : undefined;
424
+ };
425
+ const name = process.argv[3];
426
+ const scope = flag('--scope');
427
+ const tokenFile = flag('--token-file');
428
+ const token = randomBytes(32).toString('base64url');
429
+ const entry = `${name}:${hashCallerToken(token)}${scope ? `:${scope}` : ''}`;
430
+ try {
431
+ parseCallers(entry);
432
+ }
433
+ catch (err) {
434
+ fatal(err instanceof Error ? err.message : String(err));
435
+ }
436
+ if (tokenFile) {
437
+ writeFileSync(tokenFile, `${token}\n`, { mode: 0o600, flag: 'wx' });
438
+ console.log(`Token for '${name}' written to ${tokenFile} (mode 600). Hand it to the caller, then delete the file.`);
439
+ }
440
+ else {
441
+ console.log(`Token for '${name}' — give it to the caller as its bearer token. It is shown once and never stored:\n ${token}`);
442
+ }
443
+ console.log(`\nAdd this entry to A2A_CALLERS (entries separated by ';'):\n ${entry}`);
444
+ process.exit(0);
445
+ }
446
+ startServer(arg || 'syndicate.yaml').catch((error) => {
447
+ if (error?.code === 'ENOENT') {
448
+ console.error(`[A2A] Syndicate not found: ${arg || 'syndicate.yaml'}.\n`
449
+ + ' Pass your syndicate: melchizedek-serve <name>.yaml\n'
450
+ + ' (agents directory: MELCHIZEDEK_AGENTS_DIR, or <cwd>/config/agents)');
1074
451
  process.exit(1);
1075
452
  }
1076
453
  console.error(error);