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.
- package/.env.example +23 -3
- package/AGENT_SETUP.md +2 -1
- package/CHANGELOG.md +104 -0
- package/DOCUMENTATION.md +75 -9
- package/config/agents/syndicateSchema.yaml +13 -0
- package/db/hardening.sql +4 -3
- package/db/migrations/0012_audit_log.sql +98 -0
- package/dist/lib/a2a/app.d.ts +20 -0
- package/dist/lib/a2a/app.js +94 -4
- package/dist/lib/a2a/executor.d.ts +34 -4
- package/dist/lib/a2a/executor.js +68 -9
- package/dist/lib/compile.d.ts +1 -2
- package/dist/lib/compile.js +33 -6
- package/dist/lib/config.d.ts +7 -0
- package/dist/lib/config.js +7 -0
- package/dist/lib/loadEnv.d.ts +5 -0
- package/dist/lib/loadEnv.js +7 -0
- package/dist/lib/loadSyndicate.d.ts +4 -0
- package/dist/lib/memory/providers.d.ts +27 -0
- package/dist/lib/memory/providers.js +28 -0
- package/dist/lib/models/fallback.d.ts +41 -0
- package/dist/lib/models/fallback.js +105 -0
- package/dist/lib/observability/audit.d.ts +21 -0
- package/dist/lib/observability/audit.js +39 -0
- package/dist/lib/observability/metrics.d.ts +6 -0
- package/dist/lib/observability/tracer.d.ts +13 -0
- package/dist/lib/observability/tracer.js +16 -2
- package/dist/lib/runtime/syndicateTurn.d.ts +3 -1
- package/dist/lib/runtime/syndicateTurn.js +3 -1
- package/dist/lib/storage/postgres/index.d.ts +3 -0
- package/dist/lib/storage/postgres/index.js +2 -0
- package/dist/lib/syndicateSchema.d.ts +8 -0
- package/dist/lib/syndicateSchema.js +19 -4
- package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
- package/dist/lib/tools/mcpToolFactory.js +61 -4
- package/dist/lib/tools/openapiTools.d.ts +6 -0
- package/dist/lib/tools/openapiTools.js +31 -1
- package/dist/scripts/a2a_server.js +51 -8
- package/dist/scripts/db.d.ts +9 -0
- package/dist/scripts/db.js +30 -15
- package/package.json +3 -3
package/dist/lib/a2a/app.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
419
|
-
|
|
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
|
-
/**
|
|
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
|
|
127
|
-
acquire(taskId: string
|
|
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,
|
package/dist/lib/a2a/executor.js
CHANGED
|
@@ -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
|
|
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
|
|
138
|
-
acquire(taskId) {
|
|
139
|
-
if (this.
|
|
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
|
-
|
|
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.
|
|
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),
|
package/dist/lib/compile.d.ts
CHANGED
|
@@ -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 {
|
package/dist/lib/compile.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/config.js
CHANGED
|
@@ -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
|
package/dist/lib/loadEnv.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/loadEnv.js
CHANGED
|
@@ -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
|
+
}
|