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
@@ -36,8 +36,13 @@ import { providerForModel, resolveModel } from '../models/registry.js';
36
36
  import { createSupabaseServices, hasSupabaseCredentials } from '../persistence/supabaseProvider.js';
37
37
  import { inProcessTurnLock } from './turnLock.js';
38
38
  import { compareSchema, schemaBehindMessage, shippedSchemaVersion } from '../storage/schemaVersion.js';
39
+ import { scopeHashOf } from '../observability/audit.js';
40
+ import { validTraceparent } from '../observability/tracer.js';
41
+ /** One end user's concurrent tasks, unless maxConcurrentPerScope says otherwise (ADR 0039). */
42
+ export const DEFAULT_MAX_CONCURRENT_PER_SCOPE = 4;
39
43
  import { eraseScope } from '../memory/erase.js';
40
44
  import { namespacedMemoryService } from '../memory/namespace.js';
45
+ import { memoryCrossesProviders, memoryDestinations } from '../memory/providers.js';
41
46
  import { A2A_APP_NAME, SyndicateExecutor, TaskLimiter, deriveUserId, requestContextStorage, } from './executor.js';
42
47
  import { HEADER_VALUE_PATTERN, SCOPE_KEY_PATTERN } from './identity.js';
43
48
  import { createMetrics } from '../observability/metrics.js';
@@ -286,6 +291,22 @@ export async function createA2AApp(options) {
286
291
  * and only `long-term` syndicates get the memory service.
287
292
  */
288
293
  const internalSessions = new InMemorySessionService();
294
+ // Long-term memory sends transcripts to its extraction and embedding
295
+ // providers; say so once per syndicate when they are not the agents' own.
296
+ const memoryFlowWarned = new Set();
297
+ const warnMemoryFlow = (cfg) => {
298
+ if (memoryFlowWarned.has(cfg.syndicate_name))
299
+ return;
300
+ memoryFlowWarned.add(cfg.syndicate_name);
301
+ const dest = memoryDestinations(process.env, { extractor: options.memory?.extractor, embedder: options.memory?.embedder });
302
+ const models = [cfg.orchestrator?.model, ...(cfg.subagents ?? []).map((s) => s.model)];
303
+ const crossed = memoryCrossesProviders(models, dest);
304
+ if (crossed.length) {
305
+ warn(`'${cfg.syndicate_name}' keeps long-term memory, so its transcripts also go to ${crossed.join(' and ')} `
306
+ + `(extraction: ${dest.extraction}, embeddings: ${dest.embeddings}), which its agents do not use. `
307
+ + 'Set MEMORY_EXTRACTION_MODEL and MEMORY_EMBEDDING_PROVIDER to keep memory on an approved provider.');
308
+ }
309
+ };
289
310
  const servicesFor = (cfg) => {
290
311
  const mode = cfg.memory_system;
291
312
  const sessionService = mode === 'internal-only' || !durableSessions ? internalSessions : durableSessions;
@@ -293,6 +314,8 @@ export async function createA2AApp(options) {
293
314
  // turn reaches — including AgentTool children, which run under their own
294
315
  // ADK app name — recalls and stores in the root syndicate's memory.
295
316
  const memory = mode === 'long-term' && memoryService ? namespacedMemoryService(memoryService, memoryAppName(cfg)) : undefined;
317
+ if (memory)
318
+ warnMemoryFlow(cfg);
296
319
  if (mode === 'long-term' && !memoryService) {
297
320
  warn(`'${cfg.syndicate_name}' requests long-term memory but Supabase is not configured — memory disabled.`);
298
321
  }
@@ -301,7 +324,11 @@ export async function createA2AApp(options) {
301
324
  }
302
325
  return { sessionService, memoryService: memory, durable: sessionService !== internalSessions };
303
326
  };
304
- const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0);
327
+ const audit = options.audit ?? options.storage?.audit;
328
+ const limiter = new TaskLimiter(options.maxConcurrentTasks ?? 0, {
329
+ perScope: options.maxConcurrentPerScope ?? DEFAULT_MAX_CONCURRENT_PER_SCOPE,
330
+ perCaller: options.maxConcurrentPerCaller ?? 0,
331
+ });
305
332
  const metrics = options.metricsToken ? createMetrics() : undefined;
306
333
  const onTaskEnd = (record) => {
307
334
  metrics?.observeTask(record);
@@ -363,6 +390,7 @@ export async function createA2AApp(options) {
363
390
  agentId,
364
391
  policy: options.policy,
365
392
  onTaskEnd,
393
+ onAudit: audit,
366
394
  log,
367
395
  warn,
368
396
  });
@@ -415,11 +443,60 @@ export async function createA2AApp(options) {
415
443
  res.type('text/plain; version=0.0.4').send(metrics.render(limiter.inFlight));
416
444
  });
417
445
  }
418
- app.get('/readyz', (_req, res) => {
419
- if (limiter.isDraining) {
446
+ // Readiness: not while stopping, and not while durable storage is
447
+ // unreachable (a turn there would fail). The reason is logged once per
448
+ // change of state, never per probe, and the answer names no host.
449
+ let unready = false;
450
+ let storageDown = false;
451
+ // /readyz is unauthenticated, so the database is asked at most once per
452
+ // READY_CACHE_MS however often it is probed; concurrent probes share the
453
+ // one check in flight.
454
+ const READY_CACHE_MS = 2_000;
455
+ let lastCheck;
456
+ let checking;
457
+ const checkStorage = async () => {
458
+ let timer;
459
+ try {
460
+ await Promise.race([
461
+ readSchemaVersion(),
462
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('no answer within 2 s')), 2_000); }),
463
+ ]);
464
+ if (storageDown)
465
+ log('readyz: the database answers again.');
466
+ storageDown = false;
467
+ return true;
468
+ }
469
+ catch (err) {
470
+ if (!storageDown)
471
+ warn(`readyz: the database is unreachable (${err instanceof Error ? err.message : String(err)}); reporting not ready.`);
472
+ storageDown = true;
473
+ return false;
474
+ }
475
+ finally {
476
+ clearTimeout(timer);
477
+ }
478
+ };
479
+ const storageReachable = async () => {
480
+ if (!readSchemaVersion)
481
+ return true;
482
+ if (lastCheck && Date.now() - lastCheck.at < READY_CACHE_MS)
483
+ return lastCheck.ok;
484
+ checking ??= checkStorage().then((ok) => {
485
+ lastCheck = { at: Date.now(), ok };
486
+ checking = undefined;
487
+ return ok;
488
+ });
489
+ return checking;
490
+ };
491
+ app.get('/readyz', async (_req, res) => {
492
+ if (unready || limiter.isDraining) {
420
493
  res.status(503).json({ status: 'draining' });
421
494
  return;
422
495
  }
496
+ if (!(await storageReachable())) {
497
+ res.status(503).json({ status: 'unavailable', reason: 'storage' });
498
+ return;
499
+ }
423
500
  res.json({ status: 'ready', sessions: sessionBackend, inFlight: limiter.inFlight });
424
501
  });
425
502
  // ── 2–3. Authentication ───────────────────────────────────────────────────
@@ -437,6 +514,12 @@ export async function createA2AApp(options) {
437
514
  }));
438
515
  const rejectAuth = (req, res, error) => {
439
516
  warn(`401 ${req.method} ${req.path} from ${req.ip}: ${error}`);
517
+ try {
518
+ audit?.({ event: 'auth.failure', outcome: 'denied', sourceIp: req.ip, detail: { method: req.method, path: req.path, reason: error } });
519
+ }
520
+ catch {
521
+ /* the refusal stands whatever the sink does */
522
+ }
440
523
  res.status(401).json({ error });
441
524
  };
442
525
  if (options.serverSecret) {
@@ -544,6 +627,7 @@ export async function createA2AApp(options) {
544
627
  caller: identity.caller,
545
628
  ownsNested: identity.ownsNested ?? false,
546
629
  operator: identity.operator ?? false,
630
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
547
631
  }, () => next());
548
632
  return;
549
633
  }
@@ -568,7 +652,7 @@ export async function createA2AApp(options) {
568
652
  // The caller's key funds its provider, and its hash prefixes the scope
569
653
  // so no key holder can reach another's data.
570
654
  const scopeKey = deriveUserId({ apiKey, siteUserId });
571
- requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret }, () => next());
655
+ requestContextStorage.run({ apiKey, provider, siteUserId, scopeKey, surface, caller: 'shared-secret', ownsNested: !siteUserId, operator: !!options.serverSecret, sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent) }, () => next());
572
656
  return;
573
657
  }
574
658
  // Server mode: the server's credentials pay; X-API-Key is not used.
@@ -585,6 +669,7 @@ export async function createA2AApp(options) {
585
669
  // Everyone holding the secret is one caller; without a secret (loopback) the caller is local.
586
670
  caller: options.serverSecret ? 'shared-secret' : 'local',
587
671
  operator: !!options.serverSecret,
672
+ sourceIp: req.ip, traceparent: validTraceparent(req.headers.traceparent),
588
673
  }, () => next());
589
674
  });
590
675
  // ── 6. Task rate limit ────────────────────────────────────────────────────
@@ -640,11 +725,13 @@ export async function createA2AApp(options) {
640
725
  includeNested: !!ctx.ownsNested,
641
726
  });
642
727
  log(`Erasure for scope ${ctx.scopeKey}${all ? ' (all namespaces)' : ''}: ${JSON.stringify(counts)}`);
728
+ audit?.({ event: 'memory.erase', outcome: 'ok', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all, deleted: counts } });
643
729
  res.json({ deleted: counts });
644
730
  }
645
731
  catch (err) {
646
732
  const msg = err instanceof Error ? err.message : String(err);
647
733
  warn(`Erasure failed: ${msg}`);
734
+ audit?.({ event: 'memory.erase', outcome: 'failed', caller: ctx.caller, scopeHash: scopeHashOf(ctx.scopeKey), sourceIp: ctx.sourceIp, detail: { allNamespaces: all } });
648
735
  res.status(503).json({ error: msg });
649
736
  }
650
737
  });
@@ -745,6 +832,9 @@ export async function createA2AApp(options) {
745
832
  app,
746
833
  config,
747
834
  sessionBackend,
835
+ markUnready: () => {
836
+ unready = true;
837
+ },
748
838
  shutdown: async (graceMs) => {
749
839
  const left = await limiter.drain(graceMs);
750
840
  for (const t of leaseTimers)
@@ -18,6 +18,7 @@ import type { TurnLock } from './turnLock.ts';
18
18
  import type { PendingApproval } from '../runtime/approvals.ts';
19
19
  import type { MessagePart, SyndicateTurnResult } from '../runtime/syndicateTurn.ts';
20
20
  import type { TaskRecord } from '../observability/metrics.ts';
21
+ import type { AuditSink } from '../observability/audit.ts';
21
22
  import type { Policy } from './policy.ts';
22
23
  /** Per-request caller context, set by the server's identity middleware. */
23
24
  export interface A2AContext {
@@ -44,6 +45,10 @@ export interface A2AContext {
44
45
  * server secret), as opposed to an end user (a JWT, a gateway header).
45
46
  * Operator-only adopter routes check it. */
46
47
  operator?: boolean;
48
+ /** The request's source address (behind A2A_TRUST_PROXY), for the audit trail only. */
49
+ sourceIp?: string;
50
+ /** The caller's W3C traceparent, when well formed: the turn's root span links to it. */
51
+ traceparent?: string;
47
52
  }
48
53
  export interface SurfaceContext {
49
54
  name: string;
@@ -103,6 +108,8 @@ export interface ExecutorOptions {
103
108
  policy?: Policy;
104
109
  /** One record per task, however it ended: the task log and metrics. */
105
110
  onTaskEnd?: (record: TaskRecord) => void;
111
+ /** Receives one `task.end` audit event per task (ADR 0042). */
112
+ onAudit?: AuditSink;
106
113
  /**
107
114
  * Stream the answer as the model writes it, as `answer` artifact chunks
108
115
  * (see answerStream). Off by default; never for a syndicate with guards.
@@ -115,16 +122,39 @@ export interface ExecutorOptions {
115
122
  log: (message: string) => void;
116
123
  warn: (message: string) => void;
117
124
  }
118
- /** Concurrency cap and in-flight registry shared by all executors. */
125
+ /** Per-key caps beside the global one: 0 or absent means no cap. */
126
+ export interface TaskLimits {
127
+ /** Concurrent tasks for one scope (one end user's data). */
128
+ perScope?: number;
129
+ /** Concurrent tasks for one authenticated caller (a backend, a token). */
130
+ perCaller?: number;
131
+ }
132
+ /** Concurrency caps and in-flight registry shared by all executors. */
119
133
  export declare class TaskLimiter {
120
134
  readonly max: number;
135
+ readonly perScope: number;
136
+ readonly perCaller: number;
121
137
  private readonly running;
138
+ /** taskId → the keys it counts against, so release frees exactly those. */
139
+ private readonly keysOf;
140
+ private readonly counts;
122
141
  private draining;
123
142
  private idleWaiters;
124
- constructor(max: number);
143
+ constructor(max: number, limits?: TaskLimits);
144
+ /**
145
+ * Why a task for this scope and caller may not start now, or undefined.
146
+ * Checked before acquire() so the refusal can name the cap that was hit.
147
+ */
148
+ refusal(who?: {
149
+ scopeKey?: string;
150
+ caller?: string;
151
+ }): string | undefined;
125
152
  get inFlight(): number;
126
- /** Reserve a slot. Returns undefined when saturated or shutting down. */
127
- acquire(taskId: string): AbortController | undefined;
153
+ /** Reserve a slot. Returns undefined when any cap is reached or the server is shutting down. */
154
+ acquire(taskId: string, who?: {
155
+ scopeKey?: string;
156
+ caller?: string;
157
+ }): AbortController | undefined;
128
158
  release(taskId: string): void;
129
159
  cancel(taskId: string): boolean;
130
160
  /** Stop admitting tasks; resolve when all running ones finish or `graceMs` passes,
@@ -122,28 +122,64 @@ export function a2aPartsToMessage(rawParts) {
122
122
  return { parts };
123
123
  }
124
124
  const NO_USAGE = { llmCalls: 0, inputTokens: 0, outputTokens: 0, thinkingTokens: 0 };
125
- /** Concurrency cap and in-flight registry shared by all executors. */
125
+ /** Concurrency caps and in-flight registry shared by all executors. */
126
126
  export class TaskLimiter {
127
127
  max;
128
+ perScope;
129
+ perCaller;
128
130
  running = new Map();
131
+ /** taskId → the keys it counts against, so release frees exactly those. */
132
+ keysOf = new Map();
133
+ counts = new Map();
129
134
  draining = false;
130
135
  idleWaiters = [];
131
- constructor(max) {
136
+ constructor(max, limits = {}) {
132
137
  this.max = max > 0 ? max : Infinity;
138
+ this.perScope = limits.perScope && limits.perScope > 0 ? limits.perScope : Infinity;
139
+ this.perCaller = limits.perCaller && limits.perCaller > 0 ? limits.perCaller : Infinity;
140
+ }
141
+ /**
142
+ * Why a task for this scope and caller may not start now, or undefined.
143
+ * Checked before acquire() so the refusal can name the cap that was hit.
144
+ */
145
+ refusal(who = {}) {
146
+ if (this.draining)
147
+ return 'The server is shutting down; retry shortly.';
148
+ if (this.running.size >= this.max)
149
+ return `The server is at its limit of ${this.max} concurrent tasks; retry shortly.`;
150
+ if (who.scopeKey && (this.counts.get(`s:${who.scopeKey}`) ?? 0) >= this.perScope) {
151
+ return `This user already has ${this.perScope} tasks running, the most allowed at once; retry when one finishes.`;
152
+ }
153
+ if (who.caller && (this.counts.get(`c:${who.caller}`) ?? 0) >= this.perCaller) {
154
+ return `This caller already has ${this.perCaller} tasks running, the most allowed at once; retry when one finishes.`;
155
+ }
156
+ return undefined;
133
157
  }
134
158
  get inFlight() {
135
159
  return this.running.size;
136
160
  }
137
- /** Reserve a slot. Returns undefined when saturated or shutting down. */
138
- acquire(taskId) {
139
- if (this.draining || this.running.size >= this.max)
161
+ /** Reserve a slot. Returns undefined when any cap is reached or the server is shutting down. */
162
+ acquire(taskId, who = {}) {
163
+ if (this.refusal(who))
140
164
  return undefined;
141
165
  const controller = new AbortController();
142
166
  this.running.set(taskId, controller);
167
+ const keys = [who.scopeKey && `s:${who.scopeKey}`, who.caller && `c:${who.caller}`].filter((k) => !!k);
168
+ for (const k of keys)
169
+ this.counts.set(k, (this.counts.get(k) ?? 0) + 1);
170
+ this.keysOf.set(taskId, keys);
143
171
  return controller;
144
172
  }
145
173
  release(taskId) {
146
174
  this.running.delete(taskId);
175
+ for (const k of this.keysOf.get(taskId) ?? []) {
176
+ const n = (this.counts.get(k) ?? 1) - 1;
177
+ if (n > 0)
178
+ this.counts.set(k, n);
179
+ else
180
+ this.counts.delete(k);
181
+ }
182
+ this.keysOf.delete(taskId);
147
183
  if (this.running.size === 0)
148
184
  for (const w of this.idleWaiters.splice(0))
149
185
  w();
@@ -367,6 +403,8 @@ export class SyndicateExecutor {
367
403
  // Reports the task once: to the task log and metrics, and its spend to
368
404
  // the policy. Never throws into the task.
369
405
  let reported = false;
406
+ // The turn's trace (the last root span started: a dispatch turn's answer).
407
+ let traceId;
370
408
  const report = async (ctx, status, reason, usage = NO_USAGE) => {
371
409
  if (reported)
372
410
  return;
@@ -382,6 +420,9 @@ export class SyndicateExecutor {
382
420
  }
383
421
  try {
384
422
  this.opts.onTaskEnd?.({
423
+ taskId,
424
+ traceId,
425
+ ...(ctx?.traceparent ? { callerTraceId: ctx.traceparent.split('-')[1] } : {}),
385
426
  agentId,
386
427
  syndicate: config.syndicate_name,
387
428
  caller: ctx?.caller,
@@ -395,6 +436,21 @@ export class SyndicateExecutor {
395
436
  catch {
396
437
  /* a broken log sink must not fail the task */
397
438
  }
439
+ try {
440
+ this.opts.onAudit?.({
441
+ event: 'task.end',
442
+ outcome: status,
443
+ caller: ctx?.caller,
444
+ scopeHash: scopeKey ? createHash('sha256').update(scopeKey).digest('hex').slice(0, 12) : undefined,
445
+ sourceIp: ctx?.sourceIp,
446
+ agentId,
447
+ taskId,
448
+ detail: { syndicate: config.syndicate_name, ...(reason ? { reason } : {}), ...(traceId ? { traceId } : {}), durationMs: Date.now() - started, llmCalls: usage.llmCalls },
449
+ });
450
+ }
451
+ catch {
452
+ /* nor a broken audit sink */
453
+ }
398
454
  };
399
455
  let ctxForReport;
400
456
  try {
@@ -443,11 +499,10 @@ export class SyndicateExecutor {
443
499
  return;
444
500
  }
445
501
  }
446
- slot = this.opts.limiter.acquire(taskId);
502
+ const who = { scopeKey: ctx.scopeKey, caller: ctx.caller };
503
+ slot = this.opts.limiter.acquire(taskId, who);
447
504
  if (!slot) {
448
- const why = this.opts.limiter.isDraining
449
- ? 'The server is shutting down; retry shortly.'
450
- : `The server is at its limit of ${this.opts.limiter.max} concurrent tasks; retry shortly.`;
505
+ const why = this.opts.limiter.refusal(who) ?? 'The server is at capacity; retry shortly.';
451
506
  warn(`Task ${short} rejected — ${why}`);
452
507
  publishFinal(eventBus, taskId, contextId, 'rejected', why);
453
508
  await report(ctx, 'rejected', 'capacity');
@@ -506,6 +561,10 @@ export class SyndicateExecutor {
506
561
  taskId,
507
562
  configHash: this.configHashFor(),
508
563
  attributes: surfaceAttributes(ctx),
564
+ traceparent: ctx.traceparent,
565
+ onSpanStart: (ids) => {
566
+ traceId = ids.traceId;
567
+ },
509
568
  },
510
569
  events: {
511
570
  onProgress: (text) => publishWorking(eventBus, taskId, contextId, text),
@@ -27,9 +27,8 @@
27
27
  * dispatches to is identical to the one DELEGATE would have wrapped.
28
28
  * Contract and rationale: lib/dispatch.ts.
29
29
  */
30
- import { FunctionTool, LlmAgent } from '@google/adk';
30
+ import { BaseLlm, FunctionTool, LlmAgent } from '@google/adk';
31
31
  import type { BaseTool } from '@google/adk';
32
- import type { BaseLlm } from '@google/adk';
33
32
  import type { SubagentYamlConfig, SyndicateYamlConfig } from './loadSyndicate.ts';
34
33
  import type { SkillsConfig } from './tools/skillToolset.ts';
35
34
  export interface CompileOptions {
@@ -27,13 +27,15 @@
27
27
  * dispatches to is identical to the one DELEGATE would have wrapped.
28
28
  * Contract and rationale: lib/dispatch.ts.
29
29
  */
30
- import { AgentTool, BuiltInCodeExecutor, ExampleTool, FunctionTool, LLMRegistry, LlmAgent, LlmSummarizer, TokenBasedContextCompactor } from '@google/adk';
30
+ import { AgentTool, BaseLlm, BuiltInCodeExecutor, ExampleTool, FunctionTool, LLMRegistry, LlmAgent, LlmSummarizer, TokenBasedContextCompactor } from '@google/adk';
31
31
  import { relative } from 'node:path';
32
32
  import { isDispatchSyndicate } from './dispatch.js';
33
33
  import { loadSyndicate, nestedLoader } from './loadSyndicate.js';
34
34
  import { resolveTools as resolveNamedTools } from './toolRegistry.js';
35
35
  import { createMcpTools } from './tools/mcpToolFactory.js';
36
36
  import { capabilitySummary, describeCapabilities } from './models/capabilities.js';
37
+ import { FallbackLlm } from './models/fallback.js';
38
+ import { resolveModel as resolveRegistryModel } from './models/registry.js';
37
39
  import { remoteAgentTool } from './a2a/remoteAgent.js';
38
40
  import { buildSkillHarness } from './tools/skillToolset.js';
39
41
  import { buildOpenApiTools, isOpenApiTool, namesTool } from './tools/openapiTools.js';
@@ -193,7 +195,19 @@ export function examplesTool(examples) {
193
195
  new ExampleTool(examples.map((e) => ({ input: { role: 'user', parts: [{ text: e.input }] }, output: [{ role: 'model', parts: [{ text: e.output }] }] }))),
194
196
  ];
195
197
  }
196
- async function resolveAgentTools(toolNames, mcpServerUrl, opts, openapi, examples) {
198
+ /**
199
+ * An agent with `fallback_model:` gets its model wrapped (lib/models/fallback.ts):
200
+ * the fallback answers a provider-side failure, and a provider that keeps
201
+ * failing is skipped for a cooldown. A model id string is resolved to its
202
+ * adapter first, so the wrapper always holds two adapters.
203
+ */
204
+ function withFallback(primary, fallbackId, resolve, opts) {
205
+ if (!fallbackId)
206
+ return primary;
207
+ const asLlm = (m) => (m instanceof BaseLlm ? m : resolveRegistryModel(String(m)));
208
+ return new FallbackLlm(asLlm(primary), asLlm(resolve(fallbackId)), opts.log ?? ((m) => console.warn(m)));
209
+ }
210
+ async function resolveAgentTools(toolNames, mcpServerUrl, opts, openapi, examples, mcpAllowed) {
197
211
  const tools = [...resolveNamedTools(toolNames, opts.onUnknownTool), ...examplesTool(examples)];
198
212
  // OpenAPI operations become tools here, so require_approval can name them.
199
213
  for (const entry of openapi ?? []) {
@@ -207,7 +221,20 @@ async function resolveAgentTools(toolNames, mcpServerUrl, opts, openapi, example
207
221
  }
208
222
  if (mcpServerUrl) {
209
223
  opts.log?.(`Loading MCP tools: ${mcpServerUrl}`);
210
- const mcpTools = await createMcpTools(mcpServerUrl);
224
+ const offered = await createMcpTools(mcpServerUrl);
225
+ // mcp_tools: only the named tools are exposed (a server's list is its
226
+ // own to change); a name the server does not offer is reported, and a
227
+ // gate on it fails the compile in gateTools.
228
+ const allowed = mcpAllowed ? new Set(mcpAllowed) : undefined;
229
+ const mcpTools = allowed ? offered.filter((t) => allowed.has(t.name)) : offered;
230
+ if (allowed) {
231
+ const missing = [...allowed].filter((n) => !offered.some((t) => t.name === n));
232
+ if (missing.length)
233
+ opts.log?.(`MCP ${mcpServerUrl} does not offer ${missing.map((n) => `'${n}'`).join(', ')} (mcp_tools)`);
234
+ const hidden = offered.length - mcpTools.length;
235
+ if (hidden)
236
+ opts.log?.(`MCP ${mcpServerUrl}: ${hidden} tool(s) not in mcp_tools are not exposed`);
237
+ }
211
238
  for (const mcpTool of mcpTools) {
212
239
  if (!tools.some((t) => t.name === mcpTool.name))
213
240
  tools.push(mcpTool);
@@ -259,14 +286,14 @@ export async function compileSubagent(subCfg, opts = {}) {
259
286
  }
260
287
  return compileGraph(nested, opts, subCfg.name, subCfg.description);
261
288
  }
262
- const gated = gateTools(await resolveAgentTools(subCfg.tools, subCfg.mcp_server_url, opts, subCfg.openapi, subCfg.examples), subCfg.require_approval, subCfg.name);
289
+ const gated = gateTools(await resolveAgentTools(subCfg.tools, subCfg.mcp_server_url, opts, subCfg.openapi, subCfg.examples, subCfg.mcp_tools), subCfg.require_approval, subCfg.name);
263
290
  const { instruction, tools } = await withSkills(subCfg.instruction ?? '', gated, subCfg.skills, subCfg.name, opts);
264
291
  const resolveModel = opts.resolveModel ?? ((m) => m);
265
292
  logCapabilities(opts, subCfg.name, subCfg.model, subCfg.tools);
266
293
  return new LlmAgent({
267
294
  name: subCfg.name,
268
295
  description: subCfg.description,
269
- model: resolveModel(subCfg.model),
296
+ model: withFallback(resolveModel(subCfg.model), subCfg.fallback_model, resolveModel, opts),
270
297
  instruction,
271
298
  tools: tools.length > 0 ? tools : undefined,
272
299
  outputSchema: subCfg.outputSchema,
@@ -313,7 +340,7 @@ export async function compileGraph(config, opts = {}, overrideName, overrideDesc
313
340
  return new LlmAgent({
314
341
  name: overrideName || config.orchestrator.name,
315
342
  description: overrideDescription || config.orchestrator.description,
316
- model: resolveModel(config.orchestrator.model),
343
+ model: withFallback(resolveModel(config.orchestrator.model), config.orchestrator.fallback_model, resolveModel, opts),
317
344
  instruction,
318
345
  tools: orchestratorTools.length > 0 ? orchestratorTools : undefined,
319
346
  outputSchema: config.orchestrator.outputSchema,
@@ -76,6 +76,13 @@ export declare const DEFAULT_KIMI_MODEL = "kimi-k3";
76
76
  * override. Source: platform.moonshot.ai › Guides › Reasoning effort.
77
77
  */
78
78
  export declare const DEFAULT_KIMI_REASONING_EFFORT = "high";
79
+ /**
80
+ * Model calls one turn may make, subagents included, when the syndicate sets
81
+ * no `max_steps`. A runaway tool loop stops here instead of at the provider's
82
+ * bill. Every shipped example that sets a cap sets 12 to 30; a syndicate that
83
+ * needs more raises its own `max_steps` (ADR 0039).
84
+ */
85
+ export declare const DEFAULT_MAX_STEPS = 50;
79
86
  /**
80
87
  * Default open-weight model, served locally by Ollama (lib/models/ollamaLlm.ts).
81
88
  * qwen3:8b is the smallest pulled model that supports tool calling — the
@@ -77,6 +77,13 @@ export const DEFAULT_KIMI_MODEL = 'kimi-k3';
77
77
  * override. Source: platform.moonshot.ai › Guides › Reasoning effort.
78
78
  */
79
79
  export const DEFAULT_KIMI_REASONING_EFFORT = 'high';
80
+ /**
81
+ * Model calls one turn may make, subagents included, when the syndicate sets
82
+ * no `max_steps`. A runaway tool loop stops here instead of at the provider's
83
+ * bill. Every shipped example that sets a cap sets 12 to 30; a syndicate that
84
+ * needs more raises its own `max_steps` (ADR 0039).
85
+ */
86
+ export const DEFAULT_MAX_STEPS = 50;
80
87
  /**
81
88
  * Default open-weight model, served locally by Ollama (lib/models/ollamaLlm.ts).
82
89
  * qwen3:8b is the smallest pulled model that supports tool calling — the
@@ -19,6 +19,11 @@
19
19
  * as a credential. Before this, `cp .env.example .env` crashed the default
20
20
  * quickstart (`Invalid supabaseUrl`), made the doctor report every provider
21
21
  * funded, and turned the example bearer secret into a live one.
22
+ *
23
+ * `MELCHIZEDEK_DOTENV=off` in the environment skips every file: the process
24
+ * runs on its ambient environment alone. A container whose secrets arrive as
25
+ * real variables wants that, and so does a test that starts a bin from a
26
+ * clone holding a real `.env`, which must never reach live services.
22
27
  */
23
28
  /** True for template placeholders that must never act as real values. */
24
29
  export declare function isPlaceholderValue(value: string | undefined): boolean;
@@ -19,6 +19,11 @@
19
19
  * as a credential. Before this, `cp .env.example .env` crashed the default
20
20
  * quickstart (`Invalid supabaseUrl`), made the doctor report every provider
21
21
  * funded, and turned the example bearer secret into a live one.
22
+ *
23
+ * `MELCHIZEDEK_DOTENV=off` in the environment skips every file: the process
24
+ * runs on its ambient environment alone. A container whose secrets arrive as
25
+ * real variables wants that, and so does a test that starts a bin from a
26
+ * clone holding a real `.env`, which must never reach live services.
22
27
  */
23
28
  import { existsSync, readFileSync } from 'node:fs';
24
29
  import { dirname, join, resolve } from 'node:path';
@@ -69,6 +74,8 @@ export function parseEnvFile(raw) {
69
74
  * clone run from another directory still finds its own `.env`.
70
75
  */
71
76
  export function loadEnv(moduleUrl) {
77
+ if (process.env.MELCHIZEDEK_DOTENV?.trim().toLowerCase() === 'off')
78
+ return;
72
79
  const files = [join(process.cwd(), '.env')];
73
80
  if (moduleUrl) {
74
81
  const own = join(findRepoRoot(dirname(fileURLToPath(moduleUrl))), '.env');
@@ -88,6 +88,10 @@ export interface AgentYamlConfig {
88
88
  */
89
89
  outputSchema?: Record<string, unknown>;
90
90
  mcp_server_url?: string;
91
+ /** The MCP server's tools this agent may use; the rest are not exposed. */
92
+ mcp_tools?: string[];
93
+ /** Answers when `model` fails provider-side, or its provider's circuit is open (ADR 0044). */
94
+ fallback_model?: string;
91
95
  /**
92
96
  * Agent Skills (SKILL.md directories) this agent holds the way a coding
93
97
  * harness does: the index in its instruction, a skill read on demand, its
@@ -97,3 +97,30 @@ export interface MemoryProviders {
97
97
  * model or embedder is Gemini.
98
98
  */
99
99
  export declare function memoryProvidersFromEnv(env?: NodeJS.ProcessEnv, geminiKey?: string): MemoryProviders;
100
+ /**
101
+ * Where long-term memory sends a syndicate's transcripts: the provider that
102
+ * extracts facts and the one that embeds them. By default both are Gemini,
103
+ * whatever the agents run on, so a deployment approved for one provider can
104
+ * send transcripts to another without noticing; the server warns when they
105
+ * differ (memoryCrossesProviders).
106
+ */
107
+ export declare function memoryDestinations(env?: NodeJS.ProcessEnv, override?: {
108
+ extractor?: {
109
+ model: string;
110
+ };
111
+ embedder?: {
112
+ provider: string;
113
+ };
114
+ }): {
115
+ extraction: string;
116
+ embeddings: string;
117
+ };
118
+ /**
119
+ * The memory destinations a syndicate's own agents do not already use, or []
120
+ * when memory stays with the agents' providers. An embedding endpoint the
121
+ * operator configured themselves (`openai-compatible`) is not counted.
122
+ */
123
+ export declare function memoryCrossesProviders(models: Array<string | undefined>, dest: {
124
+ extraction: string;
125
+ embeddings: string;
126
+ }): string[];
@@ -238,3 +238,31 @@ export function memoryProvidersFromEnv(env = process.env, geminiKey) {
238
238
  }
239
239
  return { extractor, embedder };
240
240
  }
241
+ /**
242
+ * Where long-term memory sends a syndicate's transcripts: the provider that
243
+ * extracts facts and the one that embeds them. By default both are Gemini,
244
+ * whatever the agents run on, so a deployment approved for one provider can
245
+ * send transcripts to another without noticing; the server warns when they
246
+ * differ (memoryCrossesProviders).
247
+ */
248
+ export function memoryDestinations(env = process.env, override = {}) {
249
+ const model = override.extractor?.model ?? (env.MEMORY_EXTRACTION_MODEL?.trim() || MEMORY_EXTRACTION_MODEL);
250
+ return {
251
+ extraction: providerForModel(model),
252
+ embeddings: (override.embedder?.provider ?? (env.MEMORY_EMBEDDING_PROVIDER?.trim() || 'gemini')).toLowerCase(),
253
+ };
254
+ }
255
+ /**
256
+ * The memory destinations a syndicate's own agents do not already use, or []
257
+ * when memory stays with the agents' providers. An embedding endpoint the
258
+ * operator configured themselves (`openai-compatible`) is not counted.
259
+ */
260
+ export function memoryCrossesProviders(models, dest) {
261
+ const agents = new Set(models.filter((m) => !!m).map((m) => providerForModel(m)));
262
+ const out = new Set();
263
+ if (!agents.has(dest.extraction))
264
+ out.add(dest.extraction);
265
+ if (dest.embeddings !== 'openai-compatible' && !agents.has(dest.embeddings))
266
+ out.add(dest.embeddings);
267
+ return [...out];
268
+ }