melchizedek-agents 0.17.0 → 0.18.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 (41) hide show
  1. package/.env.example +23 -3
  2. package/AGENT_SETUP.md +2 -1
  3. package/CHANGELOG.md +104 -0
  4. package/DOCUMENTATION.md +75 -9
  5. package/config/agents/syndicateSchema.yaml +13 -0
  6. package/db/hardening.sql +4 -3
  7. package/db/migrations/0012_audit_log.sql +98 -0
  8. package/dist/lib/a2a/app.d.ts +20 -0
  9. package/dist/lib/a2a/app.js +94 -4
  10. package/dist/lib/a2a/executor.d.ts +34 -4
  11. package/dist/lib/a2a/executor.js +68 -9
  12. package/dist/lib/compile.d.ts +1 -2
  13. package/dist/lib/compile.js +33 -6
  14. package/dist/lib/config.d.ts +7 -0
  15. package/dist/lib/config.js +7 -0
  16. package/dist/lib/loadEnv.d.ts +5 -0
  17. package/dist/lib/loadEnv.js +7 -0
  18. package/dist/lib/loadSyndicate.d.ts +4 -0
  19. package/dist/lib/memory/providers.d.ts +27 -0
  20. package/dist/lib/memory/providers.js +28 -0
  21. package/dist/lib/models/fallback.d.ts +41 -0
  22. package/dist/lib/models/fallback.js +105 -0
  23. package/dist/lib/observability/audit.d.ts +21 -0
  24. package/dist/lib/observability/audit.js +39 -0
  25. package/dist/lib/observability/metrics.d.ts +6 -0
  26. package/dist/lib/observability/tracer.d.ts +13 -0
  27. package/dist/lib/observability/tracer.js +16 -2
  28. package/dist/lib/runtime/syndicateTurn.d.ts +3 -1
  29. package/dist/lib/runtime/syndicateTurn.js +3 -1
  30. package/dist/lib/storage/postgres/index.d.ts +3 -0
  31. package/dist/lib/storage/postgres/index.js +2 -0
  32. package/dist/lib/syndicateSchema.d.ts +8 -0
  33. package/dist/lib/syndicateSchema.js +19 -4
  34. package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
  35. package/dist/lib/tools/mcpToolFactory.js +61 -4
  36. package/dist/lib/tools/openapiTools.d.ts +6 -0
  37. package/dist/lib/tools/openapiTools.js +31 -1
  38. package/dist/scripts/a2a_server.js +51 -8
  39. package/dist/scripts/db.d.ts +9 -0
  40. package/dist/scripts/db.js +30 -15
  41. package/package.json +3 -3
@@ -20,7 +20,12 @@
20
20
  * (ADR 0028), so a POST or a DELETE can wait for a person.
21
21
  * - SECRETS FROM THE ENVIRONMENT. `auth` names an environment variable,
22
22
  * never a value; a variable that is not set fails the compile, so an
23
- * agent never runs half-authenticated.
23
+ * agent never runs half-authenticated. A YAML (possibly registry-stored)
24
+ * chooses both the variable and the host, so it may never name one of
25
+ * the framework's own secrets (credentialEnvProblem): the database URL,
26
+ * a provider key or an A2A secret cannot be sent to an API.
27
+ * OPENAPI_CREDENTIAL_ENVS, when the operator sets it, is the exact list
28
+ * of variables an `auth` may name.
24
29
  * - THE SSRF GUARD. Every server URL the tools will call must be http(s)
25
30
  * and pass lib/net/addressGuard.ts: its literal rules at compile time
26
31
  * (offline), and the full check with DNS before each call (a name can
@@ -83,10 +88,35 @@ async function hostProblem(baseUrl, resolve) {
83
88
  const reason = resolve ? await checkHost(url.hostname) : blockedHostReason(url.hostname);
84
89
  return reason ? `refusing ${url.hostname}: ${reason} (set ALLOW_PRIVATE_OPENAPI=true for local development)` : null;
85
90
  }
91
+ /** Prefixes and names of variables the framework itself reads: never an API credential. */
92
+ const FRAMEWORK_ENV_PREFIXES = [
93
+ 'A2A_', 'SUPABASE_', 'DATABASE_', 'MCP_', 'MODEL_', 'MEMORY_', 'OTEL_', 'TELEMETRY_', 'MELCHIZEDEK_',
94
+ 'GOOGLE_', 'GEMINI_', 'ANTHROPIC_', 'OPENAI_', 'AZURE_', 'AWS_', 'XAI_', 'MOONSHOT_', 'OLLAMA_', 'WIKI_',
95
+ 'ALLOW_', 'OPENAPI_',
96
+ ];
97
+ const FRAMEWORK_ENV_NAMES = new Set(['PUBLIC_URL', 'HOST', 'PORT', 'PATH', 'HOME', 'NODE_OPTIONS', 'WEB_EXTRACT_CHAR_LIMIT']);
98
+ /**
99
+ * Why an `auth` may not read this variable, or null. With
100
+ * OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
101
+ * it, anything but the framework's own variables is.
102
+ */
103
+ export function credentialEnvProblem(name, env = process.env) {
104
+ const allow = env.OPENAPI_CREDENTIAL_ENVS?.split(',').map((s) => s.trim()).filter(Boolean);
105
+ if (allow?.length) {
106
+ return allow.includes(name) ? null : `${name} is not in OPENAPI_CREDENTIAL_ENVS (${allow.join(', ')})`;
107
+ }
108
+ if (FRAMEWORK_ENV_NAMES.has(name) || FRAMEWORK_ENV_PREFIXES.some((p) => name.startsWith(p))) {
109
+ return `${name} is one of the framework's own settings and may not be sent to an API; give the API its own variable (or list it in OPENAPI_CREDENTIAL_ENVS)`;
110
+ }
111
+ return null;
112
+ }
86
113
  function credentialFor(auth, spec) {
87
114
  if (!auth)
88
115
  return {};
89
116
  const read = (env) => {
117
+ const refused = credentialEnvProblem(env);
118
+ if (refused)
119
+ throw new Error(`openapi ${spec}: ${refused}`);
90
120
  const value = process.env[env]?.trim();
91
121
  if (!value)
92
122
  throw new Error(`openapi ${spec}: ${env} is not set (auth reads it from the environment)`);
@@ -25,7 +25,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
25
25
  * with A2A_AUTH=callers it stays valid beside the caller
26
26
  * tokens, with its old scoping, until you remove it
27
27
  * A2A_AUTH who a caller is (ADR 0025): secret (default) | callers |
28
- * jwt | header
28
+ * jwt | header (required with PUBLIC_URL, ADR 0039)
29
29
  * A2A_CALLERS callers: `name:sha256[:scope]; …` — one token per calling
30
30
  * backend. Mint: melchizedek-serve --new-caller <name>
31
31
  * A2A_JWT_JWKS_URL jwt: your identity provider's JWKS (or A2A_JWT_SECRET, HS256)
@@ -39,20 +39,25 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
39
39
  * ALLOW_UNHARDENED_DB "true" to accept an unhardened Supabase schema
40
40
  * A2A_TASK_TIMEOUT_MS per-task wall-clock budget (900000; 0 = none)
41
41
  * A2A_MAX_CONCURRENT_TASKS concurrent tasks across all agents (0 = unlimited)
42
+ * A2A_MAX_CONCURRENT_PER_SCOPE concurrent tasks for one end user (4; 0 = unlimited)
43
+ * A2A_MAX_CONCURRENT_PER_CALLER concurrent tasks for one caller (0 = unlimited)
42
44
  * A2A_RATE_LIMIT_MAX task submissions per window per IP (60)
43
45
  * A2A_RATE_LIMIT_WINDOW_MS rate-limit window (900000)
44
46
  * 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)
47
+ * A2A_TRUST_PROXY Express trust-proxy: hop count, true/false, or subnets
48
+ * (false; required with PUBLIC_URL)
46
49
  * A2A_BODY_LIMIT JSON body limit ("1mb")
47
50
  * A2A_KEY_MODE server (default): models run on the server's keys;
48
51
  * byok: the caller's X-API-Key pays. Under A2A_AUTH=secret
49
52
  * the mode also scopes data: X-User-Id in server mode,
50
53
  * the key's hash in byok (the pre-0.16 behaviour)
51
54
  * A2A_SERVED_AGENTS comma list: the only agent ids /:agentId/ serves;
52
- * only these may fall back to examples/ and templates/
55
+ * only these may fall back to examples/ and templates/;
56
+ * * serves every one (required with PUBLIC_URL)
53
57
  * A2A_REGISTRY_AGENTS comma list: bare ids that load from adk_agent_registry
54
58
  * (others are files; registry:<id> always is the registry)
55
59
  * A2A_SHUTDOWN_GRACE_MS how long SIGTERM waits for running tasks (25000)
60
+ * A2A_SHUTDOWN_DELAY_MS of that, how long /readyz fails while requests are still served (5000)
56
61
  * A2A_BUDGETS daily budgets per UTC day (ADR 0026), JSON:
57
62
  * {"perCaller":{"tasks":500,"llmCalls":5000,"tokens":5000000},
58
63
  * "callers":{"reports":{"tasks":100}},"perScope":{"tasks":50}}.
@@ -101,8 +106,10 @@ function envInt(name, fallback) {
101
106
  }
102
107
  function envTrustProxy() {
103
108
  const raw = process.env.A2A_TRUST_PROXY?.trim();
109
+ // Unset: trust no proxy, so a client cannot choose its own IP with
110
+ // X-Forwarded-For. A public server must set it (the posture check below).
104
111
  if (!raw)
105
- return 1;
112
+ return false;
106
113
  if (/^\d+$/.test(raw))
107
114
  return Number(raw);
108
115
  if (raw === 'true')
@@ -219,7 +226,28 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
219
226
  }
220
227
  return n;
221
228
  };
222
- const servedAgents = list('A2A_SERVED_AGENTS');
229
+ // `*` serves every file in the agents directory and every registry row,
230
+ // stated on purpose; unset means the same off a public URL (ADR 0039).
231
+ const servedList = list('A2A_SERVED_AGENTS');
232
+ const servedAgents = servedList?.length === 1 && servedList[0] === '*' ? undefined : servedList;
233
+ // ── Public posture (ADR 0039) ──────────────────────────────────────────────
234
+ // A server on a public URL states how it authenticates, which agents it
235
+ // answers, and how many proxies stand in front of it. Each default was the
236
+ // permissive choice, so none is taken silently here.
237
+ if (publicUrl) {
238
+ const missing = [];
239
+ if (!process.env.A2A_AUTH?.trim()) {
240
+ missing.push('A2A_AUTH: callers or jwt (one identity per caller; recommended), header (a gateway authenticates users), or secret (one shared secret; any holder can act as any user by naming them in X-User-Id)');
241
+ }
242
+ if (!servedList?.length) {
243
+ missing.push('A2A_SERVED_AGENTS: the agent ids this server answers, comma-separated, or * for every file in the agents directory and every registry row');
244
+ }
245
+ if (!process.env.A2A_TRUST_PROXY?.trim()) {
246
+ missing.push('A2A_TRUST_PROXY: how many proxies stand in front of this server (1 behind one load balancer), or false when clients connect directly');
247
+ }
248
+ if (missing.length)
249
+ fatal(`PUBLIC_URL is set, so this server must state its security posture. Set:\n - ${missing.join('\n - ')}`);
250
+ }
223
251
  const registryAgents = list('A2A_REGISTRY_AGENTS');
224
252
  // Storage: Postgres when DATABASE_URL is set (every durable store, safe
225
253
  // across instances); otherwise createA2AApp's default (Supabase, else memory).
@@ -305,6 +333,8 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
305
333
  allowSchemaMismatch: process.env.ALLOW_SCHEMA_MISMATCH?.trim().toLowerCase() === 'true',
306
334
  turnLockWaitMs: envInt('A2A_TURN_LOCK_WAIT_MS', 30_000),
307
335
  maxConcurrentTasks: envInt('A2A_MAX_CONCURRENT_TASKS', 0),
336
+ maxConcurrentPerScope: envInt('A2A_MAX_CONCURRENT_PER_SCOPE', 4),
337
+ maxConcurrentPerCaller: envInt('A2A_MAX_CONCURRENT_PER_CALLER', 0),
308
338
  rateLimit: { windowMs: envInt('A2A_RATE_LIMIT_WINDOW_MS', 15 * 60 * 1000), max: envInt('A2A_RATE_LIMIT_MAX', 60) },
309
339
  authFailureLimit: { windowMs: 15 * 60 * 1000, max: envInt('A2A_AUTH_FAILURE_MAX', 30) },
310
340
  trustProxy: envTrustProxy(),
@@ -334,7 +364,7 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
334
364
  const authMode = `${identityLabel || (secret ? 'bearer secret' : 'no secret (loopback only)')}; keys: ${keyMode === 'byok' ? "caller's X-API-Key (byok)" : "server's own"}`;
335
365
  // The secret is not needed past this point; drop the reference.
336
366
  secret = undefined;
337
- const { app, config, sessionBackend, shutdown } = built;
367
+ const { app, config, sessionBackend, shutdown, markUnready } = built;
338
368
  const server = await new Promise((resolve) => {
339
369
  const s = host ? app.listen(port, host, () => resolve(s)) : app.listen(port, () => resolve(s));
340
370
  });
@@ -364,13 +394,26 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
364
394
  // ── Lifecycle ──────────────────────────────────────────────────────────────
365
395
  let stopping = false;
366
396
  const graceMs = envInt('A2A_SHUTDOWN_GRACE_MS', 25_000);
397
+ // A load balancer keeps sending requests for a few seconds after SIGTERM,
398
+ // until it sees /readyz fail and deregisters this instance. Closing the
399
+ // listener at once would refuse those requests; fail readiness first and
400
+ // keep serving for the delay, then close and drain. The delay is part of
401
+ // the grace budget (running tasks keep running through it), so a stop still
402
+ // takes at most A2A_SHUTDOWN_GRACE_MS, inside Kubernetes' default 30 s.
403
+ const delayMs = Math.min(envInt('A2A_SHUTDOWN_DELAY_MS', 5_000), graceMs);
367
404
  const stop = async (signal) => {
368
405
  if (stopping)
369
406
  return;
370
407
  stopping = true;
371
- emit('info', `[A2A] ${signal}: draining — no new tasks; waiting up to ${graceMs} ms for running ones.`);
408
+ markUnready();
409
+ if (delayMs > 0) {
410
+ emit('info', `[A2A] ${signal}: /readyz now fails; still serving for ${delayMs} ms while the load balancer deregisters this instance.`);
411
+ await new Promise((r) => setTimeout(r, delayMs));
412
+ }
413
+ const drainMs = graceMs - delayMs;
414
+ emit('info', `[A2A] ${signal}: draining — no new tasks; waiting up to ${drainMs} ms for running ones.`);
372
415
  server.close();
373
- const canceled = await shutdown(graceMs);
416
+ const canceled = await shutdown(drainMs);
374
417
  if (canceled > 0)
375
418
  emit('warn', `[A2A] ⚠ ${canceled} task(s) did not finish in time and were canceled.`);
376
419
  // Give canceled tasks a moment to publish their final status, then flush
@@ -22,5 +22,14 @@
22
22
  /** db/migrations/NNNN_name.sql, in numeric order. */
23
23
  export declare function migrationFiles(dir?: string): string[];
24
24
  export declare function installOrder(withTelemetry: boolean): string[];
25
+ /**
26
+ * The whole install as one psql script: an advisory lock first, then every
27
+ * file in order with a progress line before each. psql runs it with
28
+ * --single-transaction and ON_ERROR_STOP, so a failure anywhere rolls the
29
+ * whole install back (no half-applied migration), and a second `apply`
30
+ * started meanwhile waits on the lock instead of interleaving with this one.
31
+ */
32
+ export declare const APPLY_LOCK_KEY = "melchizedek-db apply";
33
+ export declare function applyScript(files: string[], sqlOf: (file: string) => string, label?: (file: string) => string): string;
25
34
  /** postgres://user:pass@host:port/db?sslmode=… → libpq environment variables. */
26
35
  export declare function pgEnv(url: string): Record<string, string>;
@@ -42,6 +42,20 @@ export function installOrder(withTelemetry) {
42
42
  const base = [...migrationFiles(), 'hardening.sql'];
43
43
  return withTelemetry ? [...base, 'telemetry.sql', 'hardening.sql'] : base;
44
44
  }
45
+ /**
46
+ * The whole install as one psql script: an advisory lock first, then every
47
+ * file in order with a progress line before each. psql runs it with
48
+ * --single-transaction and ON_ERROR_STOP, so a failure anywhere rolls the
49
+ * whole install back (no half-applied migration), and a second `apply`
50
+ * started meanwhile waits on the lock instead of interleaving with this one.
51
+ */
52
+ export const APPLY_LOCK_KEY = 'melchizedek-db apply';
53
+ export function applyScript(files, sqlOf, label = (f) => `db/${f}`) {
54
+ const parts = [`SELECT pg_advisory_xact_lock(hashtext('${APPLY_LOCK_KEY}'));\n`];
55
+ for (const file of files)
56
+ parts.push(`\\echo '→ applying ${label(file).replace(/'/g, '')}'\n`, sqlOf(file), '\n');
57
+ return parts.join('');
58
+ }
45
59
  /** postgres://user:pass@host:port/db?sslmode=… → libpq environment variables. */
46
60
  export function pgEnv(url) {
47
61
  const u = new URL(url);
@@ -100,21 +114,22 @@ async function main() {
100
114
  console.error(' or set DATABASE_URL to the Postgres connection string (Supabase: Settings → Database).');
101
115
  process.exit(1);
102
116
  }
103
- for (const file of installOrder(withTelemetry)) {
104
- console.log(`→ applying db/${file}${schema === 'public' ? '' : ` into schema ${schema}`}`);
105
- const r = spawnSync('psql', ['-v', 'ON_ERROR_STOP=1', '-q', '-f', '-'], {
106
- input: sqlOf(file),
107
- stdio: ['pipe', 'inherit', 'inherit'],
108
- // The connection goes to psql as PG* variables, never as an
109
- // argument, so the password stays out of the process list.
110
- env: { ...process.env, ...pgEnv(url) },
111
- });
112
- if (r.error) {
113
- console.error(`✗ could not run psql (${r.error.message}). Install the Postgres client, or use \`print\`.`);
114
- process.exit(1);
115
- }
116
- if (r.status !== 0)
117
- process.exit(r.status ?? 1);
117
+ // One transaction under one lock (see applyScript): all of it, or none.
118
+ const script = applyScript(installOrder(withTelemetry), sqlOf, (f) => `db/${f}${schema === 'public' ? '' : ` into schema ${schema}`}`);
119
+ const r = spawnSync('psql', ['-v', 'ON_ERROR_STOP=1', '-q', '--single-transaction', '-f', '-'], {
120
+ input: script,
121
+ stdio: ['pipe', 'inherit', 'inherit'],
122
+ // The connection goes to psql as PG* variables, never as an
123
+ // argument, so the password stays out of the process list.
124
+ env: { ...process.env, ...pgEnv(url) },
125
+ });
126
+ if (r.error) {
127
+ console.error(`✗ could not run psql (${r.error.message}). Install the Postgres client, or use \`print\`.`);
128
+ process.exit(1);
129
+ }
130
+ if (r.status !== 0) {
131
+ console.error('✗ the install failed and was rolled back: the database is as it was before `apply`.');
132
+ process.exit(r.status ?? 1);
118
133
  }
119
134
  console.log('✓ database is installed and hardened');
120
135
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melchizedek-agents",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "A multi-model, multi-agent orchestration framework on Google ADK — YAML-defined syndicates, Supabase session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  "bugs": {
13
13
  "url": "https://github.com/jhwadman/melch-ai/issues"
14
14
  },
15
- "//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency — the consumer's app owns the ADK instance so the model registry is a singleton.",
15
+ "//": "adk + genai are pinned exact in dev: newer genai minors change response part shapes and silently break the chat printer. Bump deliberately, verify a live run, then update the pin. adk itself is a PEER dependency — the consumer's app owns the ADK instance so the model registry is a singleton. The adk peer range is capped to the tested minor (~2.2.0): a new adk minor can pull a second genai copy, so the range widens only after a release is tested against it.",
16
16
  "engines": {
17
17
  "node": ">=22.6.0"
18
18
  },
@@ -126,7 +126,7 @@
126
126
  "@anthropic-ai/bedrock-sdk": ">=0.20.0",
127
127
  "@anthropic-ai/vertex-sdk": ">=0.11.0",
128
128
  "@azure/identity": ">=4.0.0",
129
- "@google/adk": "^2.2.0",
129
+ "@google/adk": "~2.2.0",
130
130
  "redis": ">=4.6.0"
131
131
  },
132
132
  "dependencies": {