melchizedek-agents 0.16.2 → 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 (100) hide show
  1. package/.env.example +40 -3
  2. package/AGENT_SETUP.md +4 -2
  3. package/CHANGELOG.md +246 -0
  4. package/DOCUMENTATION.md +311 -15
  5. package/QUICKSTART.md +2 -2
  6. package/README.md +125 -149
  7. package/config/agents/examples/harness.yaml +135 -0
  8. package/config/agents/examples/model_zoo.yaml +23 -9
  9. package/config/agents/examples/pipeline.yaml +120 -0
  10. package/config/agents/examples/specs/open-meteo-forecast.json +77 -0
  11. package/config/agents/examples/specs/open-meteo-geocoding.json +53 -0
  12. package/config/agents/examples/weather.yaml +59 -0
  13. package/config/agents/syndicateSchema.yaml +125 -1
  14. package/db/hardening.sql +4 -3
  15. package/db/migrations/0011_erase_expired.sql +198 -0
  16. package/db/migrations/0012_audit_log.sql +98 -0
  17. package/dist/lib/a2a/app.d.ts +27 -0
  18. package/dist/lib/a2a/app.js +101 -5
  19. package/dist/lib/a2a/executor.d.ts +34 -4
  20. package/dist/lib/a2a/executor.js +109 -9
  21. package/dist/lib/compile.d.ts +41 -3
  22. package/dist/lib/compile.js +162 -17
  23. package/dist/lib/config.d.ts +24 -0
  24. package/dist/lib/config.js +24 -0
  25. package/dist/lib/doctor.js +17 -4
  26. package/dist/lib/index.d.ts +2 -0
  27. package/dist/lib/index.js +1 -0
  28. package/dist/lib/loadEnv.d.ts +5 -0
  29. package/dist/lib/loadEnv.js +7 -0
  30. package/dist/lib/loadSyndicate.d.ts +50 -0
  31. package/dist/lib/loadSyndicate.js +19 -3
  32. package/dist/lib/memory/erase.d.ts +5 -0
  33. package/dist/lib/memory/erase.js +5 -0
  34. package/dist/lib/memory/providers.d.ts +27 -0
  35. package/dist/lib/memory/providers.js +28 -0
  36. package/dist/lib/models/capabilities.js +10 -0
  37. package/dist/lib/models/endpoints.d.ts +1 -1
  38. package/dist/lib/models/endpoints.js +4 -1
  39. package/dist/lib/models/fallback.d.ts +41 -0
  40. package/dist/lib/models/fallback.js +105 -0
  41. package/dist/lib/models/gateway.d.ts +2 -2
  42. package/dist/lib/models/gateway.js +4 -4
  43. package/dist/lib/models/kimiLlm.d.ts +123 -0
  44. package/dist/lib/models/kimiLlm.js +171 -0
  45. package/dist/lib/models/providerMap.d.ts +2 -2
  46. package/dist/lib/models/providerMap.js +4 -1
  47. package/dist/lib/models/registry.d.ts +1 -1
  48. package/dist/lib/models/registry.js +11 -3
  49. package/dist/lib/net/redirects.d.ts +14 -0
  50. package/dist/lib/net/redirects.js +94 -0
  51. package/dist/lib/observability/audit.d.ts +21 -0
  52. package/dist/lib/observability/audit.js +39 -0
  53. package/dist/lib/observability/metrics.d.ts +6 -0
  54. package/dist/lib/observability/tracer.d.ts +14 -1
  55. package/dist/lib/observability/tracer.js +16 -2
  56. package/dist/lib/persistence/supabaseProvider.d.ts +2 -6
  57. package/dist/lib/persistence/supabaseProvider.js +2 -31
  58. package/dist/lib/runtime/questions.d.ts +55 -0
  59. package/dist/lib/runtime/questions.js +107 -0
  60. package/dist/lib/runtime/syndicateTurn.d.ts +45 -5
  61. package/dist/lib/runtime/syndicateTurn.js +165 -17
  62. package/dist/lib/storage/postgres/index.d.ts +9 -0
  63. package/dist/lib/storage/postgres/index.js +13 -0
  64. package/dist/lib/storage/rlsStatus.d.ts +45 -0
  65. package/dist/lib/storage/rlsStatus.js +69 -0
  66. package/dist/lib/syndicateSchema.d.ts +177 -0
  67. package/dist/lib/syndicateSchema.js +343 -3
  68. package/dist/lib/toolRegistry.js +9 -0
  69. package/dist/lib/tools/mcpToolFactory.d.ts +26 -0
  70. package/dist/lib/tools/mcpToolFactory.js +61 -4
  71. package/dist/lib/tools/openapiTools.d.ts +52 -0
  72. package/dist/lib/tools/openapiTools.js +225 -0
  73. package/dist/lib/tools/skillToolset.d.ts +135 -0
  74. package/dist/lib/tools/skillToolset.js +247 -0
  75. package/dist/lib/tools/urlContextTool.d.ts +29 -0
  76. package/dist/lib/tools/urlContextTool.js +39 -0
  77. package/dist/lib/workflow.d.ts +72 -0
  78. package/dist/lib/workflow.js +180 -0
  79. package/dist/lib/workflowConfig.d.ts +95 -0
  80. package/dist/lib/workflowConfig.js +93 -0
  81. package/dist/scripts/a2a_server.js +51 -8
  82. package/dist/scripts/db.d.ts +9 -0
  83. package/dist/scripts/db.js +30 -15
  84. package/dist/scripts/syndicate_chat.js +39 -5
  85. package/package.json +6 -3
  86. package/skills/briefs/_shared.md +1 -1
  87. package/skills/briefs/melchizedek-author.md +8 -4
  88. package/skills/briefs/melchizedek-memory.md +1 -1
  89. package/skills/briefs/melchizedek-models.md +1 -1
  90. package/skills/briefs/melchizedek-scribe.md +4 -4
  91. package/skills/briefs/melchizedek-serve.md +1 -1
  92. package/skills/briefs/melchizedek.md +4 -1
  93. package/skills/melchizedek/SKILL.md +4 -1
  94. package/skills/melchizedek-author/SKILL.md +7 -3
  95. package/skills/melchizedek-memory/SKILL.md +1 -1
  96. package/skills/melchizedek-models/SKILL.md +1 -1
  97. package/skills/melchizedek-scribe/SKILL.md +3 -3
  98. package/skills/melchizedek-serve/SKILL.md +1 -1
  99. /package/skills/melchizedek-author/{templates → assets}/minimal.yaml +0 -0
  100. /package/skills/melchizedek-scribe/{templates → assets}/brief.md +0 -0
@@ -22,6 +22,7 @@
22
22
  * SDKs whose fields this repo does not own.
23
23
  */
24
24
  import { z } from 'zod';
25
+ import { DEFAULT_ROUTE_KEY, NODE_KINDS, ROUTE_STEP_SUFFIX, START_NAME, elementNames, nodeKind } from './workflowConfig.js';
25
26
  // ── Leaf rules ───────────────────────────────────────────────────────────────
26
27
  /**
27
28
  * ADK's own rule (validateAgentName in @google/adk base_agent): checked here
@@ -64,6 +65,55 @@ const generateContentConfig = z
64
65
  thinkingConfig: thinkingConfig.optional(),
65
66
  })
66
67
  .describe('Model generation config (@google/genai GenerateContentConfig). Do not set tools here.');
68
+ // ── Skills ───────────────────────────────────────────────────────────────────
69
+ export const SKILL_SCRIPT_MODES = ['none', 'local'];
70
+ /**
71
+ * Agent Skills for one agent (lib/tools/skillToolset.ts): the frontmatter
72
+ * index is injected into the instruction at compile time, a skill is read in
73
+ * full on demand, and its scripts run only when `scripts` allows and a person
74
+ * approves each run.
75
+ */
76
+ const skillsSchema = z
77
+ .strictObject({
78
+ dir: z
79
+ .string()
80
+ .min(1)
81
+ .describe('A directory of skills: one subdirectory per skill, named as its SKILL.md frontmatter names it. Relative to the working directory.'),
82
+ scripts: z
83
+ .enum(SKILL_SCRIPT_MODES)
84
+ .optional()
85
+ .describe('Whether a skill\'s scripts/ may run. "local" runs them on this machine, each after a person approves (ADR 0028); default "none".'),
86
+ tools: z
87
+ .array(z.string().min(1))
88
+ .optional()
89
+ .describe('Registry tool names a skill may unlock through its `allowed-tools` frontmatter once it is loaded. Omitted, no skill unlocks anything.'),
90
+ })
91
+ .describe('Agent Skills (SKILL.md directories) this agent reads the way a coding harness does.');
92
+ // ── OpenAPI ──────────────────────────────────────────────────────────────────
93
+ const envName = z.string().regex(/^[A-Z_][A-Z0-9_]*$/, 'an environment variable name (A–Z, 0–9, _)');
94
+ const openapiEntry = z
95
+ .strictObject({
96
+ spec: z.string().min(1).describe('An OpenAPI 3 spec file (.yaml, .yml, .json), relative to this syndicate file. Files only, never URLs.'),
97
+ operations: z
98
+ .array(z.string().min(1))
99
+ .min(1)
100
+ .optional()
101
+ .describe('Operations to expose, by operationId or tool name. Omitted: every GET operation, and nothing that writes.'),
102
+ base_url: z.string().url().optional().describe('Overrides the spec\'s servers[0].url. Checked by the SSRF guard.'),
103
+ prefix: z.string().regex(/^[a-z][a-z0-9_]*$/).optional().describe('Prepended to each tool name, to keep two APIs apart.'),
104
+ auth: z
105
+ .strictObject({
106
+ bearer_env: envName.optional().describe('Environment variable holding a bearer token.'),
107
+ api_key: z
108
+ .strictObject({ env: envName, in: z.enum(['header', 'query']), name: z.string().min(1) })
109
+ .optional()
110
+ .describe('An API key read from `env`, sent in a header or the query under `name`.'),
111
+ })
112
+ .refine((a) => !!a.bearer_env !== !!a.api_key, 'exactly one of bearer_env or api_key')
113
+ .optional()
114
+ .describe('Credentials, always from the environment, never written in YAML.'),
115
+ })
116
+ .describe('An HTTP API as tools, from its OpenAPI spec (lib/tools/openapiTools.ts).');
67
117
  // ── Agents ───────────────────────────────────────────────────────────────────
68
118
  const agentFields = {
69
119
  name: agentName,
@@ -75,6 +125,11 @@ const agentFields = {
75
125
  .string()
76
126
  .min(1)
77
127
  .describe('Model id; its prefix picks the provider (gemini-*, claude-*, gpt-*/o*, grok-*, ollama/*).'),
128
+ fallback_model: z
129
+ .string()
130
+ .min(1)
131
+ .optional()
132
+ .describe('A model on another provider that answers when this agent\'s model fails provider-side (5xx, 429, network) before producing anything, or while that provider\'s circuit is open (ADR 0044).'),
78
133
  instruction: z.string().min(1).describe('System prompt / persona.'),
79
134
  globalInstruction: z
80
135
  .string()
@@ -104,6 +159,35 @@ const agentFields = {
104
159
  .string()
105
160
  .optional()
106
161
  .describe('MCP server (SSE) whose tools are discovered at runtime and merged with `tools`.'),
162
+ mcp_tools: z
163
+ .array(z.string().min(1))
164
+ .min(1)
165
+ .optional()
166
+ .describe('The MCP server\'s tools this agent may use, by name; any other tool the server lists is not exposed. Without it, every listed tool is. require_approval may name these. Needs mcp_server_url.'),
167
+ skills: skillsSchema.optional(),
168
+ openapi: z.array(openapiEntry).min(1).optional(),
169
+ code_execution: z
170
+ .literal('gemini')
171
+ .optional()
172
+ .describe('"gemini": the model writes and runs Python in Gemini\'s server-side sandbox; nothing runs on this host. Gemini models only.'),
173
+ context: z
174
+ .strictObject({
175
+ compact_after_tokens: z.number().int().min(1000).describe('Compact when the last request\'s prompt passed this many tokens.'),
176
+ keep_recent_events: z.number().int().min(1).max(100).optional().describe('Events kept verbatim after the summary. Default 6.'),
177
+ summary_model: z.string().min(1).optional().describe('The model that writes the summary. Default: the agent\'s own.'),
178
+ })
179
+ .optional()
180
+ .describe('Summarize a long conversation instead of overflowing the window. The orchestrator of a delegate syndicate only.'),
181
+ examples: z
182
+ .array(z.strictObject({ input: z.string().min(1), output: z.string().min(1) }))
183
+ .min(1)
184
+ .max(20)
185
+ .optional()
186
+ .describe('Few-shot exchanges (input, output) added to every request\'s instruction.'),
187
+ mode: z
188
+ .literal('task')
189
+ .optional()
190
+ .describe('"task": the agent works until it calls finish_task; its arguments (matching outputSchema) are the node\'s output. Workflow nodes only.'),
107
191
  orchestration: z
108
192
  .strictObject({
109
193
  role: z.enum(['primary', 'sub-agent']).optional(),
@@ -159,6 +243,48 @@ const dispatchSchema = z
159
243
  route_overrides: z.array(routeOverride).optional(),
160
244
  })
161
245
  .describe('Opts into PLAN-DISPATCH orchestration (lib/dispatch.ts).');
246
+ // ── Workflow ─────────────────────────────────────────────────────────────────
247
+ const nodeName = z.string().min(1);
248
+ const edgeTargets = z.union([nodeName, z.array(nodeName).min(1)]);
249
+ /** One element of an edge chain: a name, several names, or a routing map. */
250
+ const edgeElement = z.union([
251
+ nodeName,
252
+ z.array(nodeName).min(1).describe('Several nodes: fan-out when they follow one node, fan-in when one node follows them.'),
253
+ z
254
+ .record(z.string().min(1), edgeTargets)
255
+ .describe('A routing map after a node: `{ <route>: <node or nodes>, default: <node> }`, matched against that node\'s output.'),
256
+ ]);
257
+ const retrySchema = z
258
+ .strictObject({
259
+ max_attempts: z.number().int().positive().optional().describe('Attempts including the first; 1 = no retry. ADK default 5.'),
260
+ initial_delay: z.number().nonnegative().optional().describe('Seconds before the first retry.'),
261
+ max_delay: z.number().nonnegative().optional(),
262
+ backoff_factor: z.number().positive().optional(),
263
+ })
264
+ .describe('Retry a node on failure (lib/workflow.ts).');
265
+ const workflowNodeSchema = z
266
+ .strictObject({
267
+ ask_user: z.string().min(1).optional().describe('Pause and ask the person this; the reply becomes the node\'s output.'),
268
+ schema: z.record(z.string(), z.unknown()).optional().describe('JSON Schema a structured reply to ask_user must satisfy; plain text passes as is.'),
269
+ join: z.literal(true).optional().describe('Wait for every predecessor; output `{ <predecessor>: <output> }`.'),
270
+ map: z.string().min(1).optional().describe('Run this agent once per item of a list input, concurrently; output the list of results.'),
271
+ max_parallel: z.number().int().positive().optional().describe('Concurrency of map. Default 8.'),
272
+ tool: z.string().min(1).optional().describe('Run this registry tool with the node input as its arguments.'),
273
+ route_key: z.string().min(1).optional().describe('Property of a JSON output holding the route. Default "route".'),
274
+ retry: retrySchema.optional(),
275
+ timeout: z.number().positive().optional().describe('Seconds this node may run before it fails.'),
276
+ })
277
+ .describe('A declared node (exactly one of ask_user, join, map, tool) or modifiers for an agent node (retry, timeout, route_key).');
278
+ const workflowSchema = z
279
+ .strictObject({
280
+ edges: z
281
+ .array(z.array(edgeElement).min(2))
282
+ .min(1)
283
+ .describe('Chains of nodes, each a list; `START` begins at least one. A routing map follows the node whose output it routes.'),
284
+ nodes: z.record(nodeName, workflowNodeSchema).optional(),
285
+ max_concurrency: z.number().int().positive().optional().describe('Nodes that may run at once. Default: unbounded.'),
286
+ })
287
+ .describe('Opts into a WORKFLOW: the syndicate as a graph (lib/workflow.ts). Cannot be combined with dispatch.');
162
288
  // ── Syndicate ────────────────────────────────────────────────────────────────
163
289
  export const syndicateSchema = z
164
290
  .strictObject({
@@ -169,6 +295,14 @@ export const syndicateSchema = z
169
295
  .optional()
170
296
  .describe('The orchestrator\'s team. Omit it (or write `subagents: []`) for a single-agent syndicate.'),
171
297
  dispatch: dispatchSchema.optional(),
298
+ workflow: workflowSchema.optional(),
299
+ retries: z
300
+ .strictObject({
301
+ model_errors: z.number().int().min(0).max(5).optional().describe('Retries of a malformed model reply. Default 2; 0 off.'),
302
+ tool_errors: z.number().int().min(0).max(5).optional().describe('Retries of a tool that threw, with reflection guidance. Default 3; 0 off.'),
303
+ })
304
+ .optional()
305
+ .describe('Self-correction on model and tool errors (ADK reflect-and-retry plugins). On by default.'),
172
306
  variables: z
173
307
  .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
174
308
  .optional()
@@ -206,7 +340,7 @@ export const syndicateSchema = z
206
340
  .int()
207
341
  .positive()
208
342
  .optional()
209
- .describe('Hard cap on runner loops (LLM → tool cycles).'),
343
+ .describe('Hard cap on model calls per turn, subagents included. Default 50 (DEFAULT_MAX_STEPS) when unset.'),
210
344
  bundled_references: z
211
345
  .record(z.string(), z.record(z.string(), z.unknown()))
212
346
  .optional()
@@ -387,19 +521,85 @@ function crossFieldProblems(raw) {
387
521
  return;
388
522
  }
389
523
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
524
+ // An OpenAPI operation can be gated once it is named under `operations`,
525
+ // and an MCP tool once it is named under `mcp_tools`.
526
+ const operations = (Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations : []));
527
+ const mcpTools = Array.isArray(agent.mcp_tools) ? agent.mcp_tools : [];
390
528
  (Array.isArray(agent.require_approval) ? agent.require_approval : []).forEach((name, j) => {
391
- if (typeof name === 'string' && !tools.includes(name)) {
392
- out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools` });
529
+ if (typeof name === 'string' && !tools.includes(name) && !operations.includes(name) && !mcpTools.includes(name)) {
530
+ out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools, its openapi operations or its mcp_tools` });
531
+ }
532
+ });
533
+ };
534
+ // A skill script run pauses for approval the same way, so it is allowed in
535
+ // the same places; `skills.tools` may only unlock tools the agent does not
536
+ // already carry outright.
537
+ const skillProblems = (agent, path, allowed) => {
538
+ if (!isObj(agent.skills))
539
+ return;
540
+ if (agent.skills.scripts === 'local' && !allowed) {
541
+ out.push({
542
+ path: [...path, 'skills', 'scripts'],
543
+ message: 'skill scripts pause for approval, which only the orchestrator or a plan-dispatch route can do; a delegated subagent cannot pause the turn (ADR 0028)',
544
+ });
545
+ }
546
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
547
+ (Array.isArray(agent.skills.tools) ? agent.skills.tools : []).forEach((name, j) => {
548
+ if (typeof name === 'string' && tools.includes(name)) {
549
+ out.push({ path: [...path, 'skills', 'tools', j], message: `'${name}' is already in this agent's tools; a skill cannot unlock what is always on` });
393
550
  }
394
551
  });
395
552
  };
553
+ // ask_user ends the turn waiting for the person, the same pause as an
554
+ // approval, so it is allowed in the same places (lib/runtime/questions.ts).
555
+ const questionProblems = (agent, path, allowed) => {
556
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
557
+ const at = tools.indexOf('ask_user');
558
+ if (at === -1)
559
+ return;
560
+ if (isObj(raw.workflow)) {
561
+ out.push({ path: [...path, 'tools', at], message: 'ask_user is not supported on a workflow node yet; use an ask_user node (workflow.nodes)' });
562
+ }
563
+ else if (!allowed) {
564
+ out.push({ path: [...path, 'tools', at], message: 'ask_user pauses the turn, which only the orchestrator or a plan-dispatch route can do; a delegated subagent cannot' });
565
+ }
566
+ };
567
+ // Execution keys (ADR 0033): where each one means something.
568
+ const executionProblems = (agent, path, isOrchestrator) => {
569
+ const model = typeof agent.model === 'string' ? agent.model : isObj(raw.orchestrator) && typeof raw.orchestrator.model === 'string' ? raw.orchestrator.model : '';
570
+ if (agent.code_execution !== undefined && model && !/^gemini-/.test(model)) {
571
+ out.push({ path: [...path, 'code_execution'], message: `code_execution: gemini needs a gemini-* model (this agent's is ${model})` });
572
+ }
573
+ if (agent.context !== undefined && (!isOrchestrator || isObj(raw.dispatch) || isObj(raw.workflow))) {
574
+ out.push({
575
+ path: [...path, 'context'],
576
+ message: 'context compaction applies to the orchestrator of a delegate syndicate: a dispatch route reads a bounded projection, a workflow node sees only its input, and a subagent starts fresh on every call',
577
+ });
578
+ }
579
+ if (agent.mode !== undefined && !isObj(raw.workflow)) {
580
+ out.push({ path: [...path, 'mode'], message: 'mode: task applies to workflow nodes, whose output is the finish_task arguments' });
581
+ }
582
+ if (agent.mcp_tools !== undefined && typeof agent.mcp_server_url !== 'string') {
583
+ out.push({ path: [...path, 'mcp_tools'], message: 'mcp_tools chooses among an MCP server\'s tools; it needs mcp_server_url' });
584
+ }
585
+ };
396
586
  if (isObj(raw.orchestrator))
587
+ executionProblems(raw.orchestrator, ['orchestrator'], true);
588
+ for (const [i, sub] of (Array.isArray(raw.subagents) ? raw.subagents : []).entries())
589
+ if (isObj(sub))
590
+ executionProblems(sub, ['subagents', i], false);
591
+ if (isObj(raw.orchestrator)) {
397
592
  gateProblems(raw.orchestrator, ['orchestrator'], true);
593
+ skillProblems(raw.orchestrator, ['orchestrator'], true);
594
+ questionProblems(raw.orchestrator, ['orchestrator'], true);
595
+ }
398
596
  const dispatching = isObj(raw.dispatch);
399
597
  subs.forEach((sub, i) => {
400
598
  if (!isObj(sub))
401
599
  return;
402
600
  gateProblems(sub, ['subagents', i], dispatching);
601
+ skillProblems(sub, ['subagents', i], dispatching);
602
+ questionProblems(sub, ['subagents', i], dispatching);
403
603
  const hasRef = typeof sub.yaml_reference === 'string';
404
604
  const hasRemote = typeof sub.a2a_agent_url === 'string';
405
605
  if (hasRef && hasRemote) {
@@ -427,6 +627,8 @@ function crossFieldProblems(raw) {
427
627
  }
428
628
  }
429
629
  });
630
+ if (isObj(raw.workflow))
631
+ out.push(...workflowProblems(raw, subs));
430
632
  // Names must be unique across the tree this file declares: two AgentTools
431
633
  // with one name collide, and dispatch routes by name.
432
634
  const seen = new Map();
@@ -457,6 +659,144 @@ function crossFieldProblems(raw) {
457
659
  }
458
660
  return out;
459
661
  }
662
+ /**
663
+ * The rules a `workflow:` block must keep (lib/workflow.ts), which a JSON
664
+ * Schema cannot say: every name is an agent or a declared node, `START`
665
+ * opens a chain and nothing else, a routing map follows the node it routes,
666
+ * a declared node is exactly one kind, and the pauses and remotes the graph
667
+ * cannot carry yet are refused with the reason.
668
+ */
669
+ function workflowProblems(raw, subs) {
670
+ const out = [];
671
+ const wf = raw.workflow;
672
+ if (isObj(raw.dispatch)) {
673
+ out.push({ path: ['workflow'], message: 'a syndicate is a workflow or a plan-dispatch router, not both; remove `dispatch`' });
674
+ }
675
+ const agentNames = [];
676
+ if (isObj(raw.orchestrator) && typeof raw.orchestrator.name === 'string')
677
+ agentNames.push(raw.orchestrator.name);
678
+ for (const sub of subs)
679
+ if (isObj(sub) && typeof sub.name === 'string')
680
+ agentNames.push(sub.name);
681
+ const nodes = isObj(wf.nodes) ? wf.nodes : {};
682
+ const declared = Object.keys(nodes);
683
+ const known = new Set([...agentNames, ...declared]);
684
+ const mapped = new Set();
685
+ // Declared nodes: an agent takes modifiers only; anything else is one kind.
686
+ for (const [name, entry] of Object.entries(nodes)) {
687
+ if (!isObj(entry))
688
+ continue;
689
+ const kinds = NODE_KINDS.filter((k) => entry[k] !== undefined);
690
+ const isAgent = agentNames.includes(name);
691
+ if (isAgent && kinds.length > 0) {
692
+ out.push({ path: ['workflow', 'nodes', name], message: `'${name}' is an agent; its node entry may carry only route_key, retry and timeout` });
693
+ }
694
+ else if (!isAgent && kinds.length !== 1) {
695
+ out.push({ path: ['workflow', 'nodes', name], message: `a declared node is exactly one of ${NODE_KINDS.join(', ')}${kinds.length ? ` (has ${kinds.join(', ')})` : ''}` });
696
+ }
697
+ if (name.endsWith(ROUTE_STEP_SUFFIX) || name === START_NAME) {
698
+ out.push({ path: ['workflow', 'nodes', name], message: `'${name}' is reserved` });
699
+ }
700
+ const kind = nodeKind(entry);
701
+ if (kind === 'map') {
702
+ const target = entry.map;
703
+ if (!agentNames.includes(target)) {
704
+ const hint = suggest(target, agentNames);
705
+ out.push({ path: ['workflow', 'nodes', name, 'map'], message: `'${target}' is not an agent of this syndicate${hint ? ` (did you mean "${hint}"?)` : ''}` });
706
+ }
707
+ else {
708
+ mapped.add(target);
709
+ }
710
+ }
711
+ if (kind !== 'ask_user' && entry.schema !== undefined) {
712
+ out.push({ path: ['workflow', 'nodes', name, 'schema'], message: 'schema applies to ask_user only' });
713
+ }
714
+ if (kind !== 'map' && entry.max_parallel !== undefined) {
715
+ out.push({ path: ['workflow', 'nodes', name, 'max_parallel'], message: 'max_parallel applies to map only' });
716
+ }
717
+ }
718
+ for (const name of agentNames) {
719
+ if (name.endsWith(ROUTE_STEP_SUFFIX))
720
+ out.push({ path: ['workflow'], message: `agent name '${name}' ends with the reserved suffix ${ROUTE_STEP_SUFFIX}` });
721
+ }
722
+ // Edges.
723
+ const edges = Array.isArray(wf.edges) ? wf.edges : [];
724
+ let starts = 0;
725
+ const referenced = new Set();
726
+ const checkName = (name, path) => {
727
+ if (known.has(name)) {
728
+ referenced.add(name);
729
+ return;
730
+ }
731
+ const hint = suggest(name, [...known]);
732
+ out.push({ path, message: `'${name}' is not an agent or a declared node${hint ? ` (did you mean "${hint}"?)` : ''}` });
733
+ };
734
+ edges.forEach((chain, i) => {
735
+ if (!Array.isArray(chain))
736
+ return;
737
+ chain.forEach((element, j) => {
738
+ const path = ['workflow', 'edges', i, j];
739
+ if (typeof element === 'string') {
740
+ if (element === START_NAME) {
741
+ if (j !== 0)
742
+ out.push({ path, message: 'START opens a chain; it cannot follow a node' });
743
+ else
744
+ starts++;
745
+ return;
746
+ }
747
+ checkName(element, path);
748
+ }
749
+ else if (Array.isArray(element)) {
750
+ element.forEach((name, k) => typeof name === 'string' && checkName(name, [...path, k]));
751
+ }
752
+ else if (isObj(element)) {
753
+ const previous = chain[j - 1];
754
+ if (typeof previous !== 'string' || previous === START_NAME) {
755
+ out.push({ path, message: 'a routing map follows the name of the node whose output it routes' });
756
+ }
757
+ else if (!agentNames.includes(previous) && nodeKind(nodes[previous]) !== 'tool') {
758
+ out.push({ path, message: `'${previous}' is a ${nodeKind(nodes[previous]) ?? 'node'}; only an agent or a tool node emits a route` });
759
+ }
760
+ if (j !== chain.length - 1)
761
+ out.push({ path, message: 'a routing map ends its chain; start another chain from each target' });
762
+ for (const [key, target] of Object.entries(element)) {
763
+ const targets = Array.isArray(target) ? target : [target];
764
+ targets.forEach((name, k) => typeof name === 'string' && checkName(name, [...path, key, k]));
765
+ }
766
+ }
767
+ for (const name of isObj(element) || Array.isArray(element) || typeof element === 'string' ? elementNames(element) : []) {
768
+ if (mapped.has(name))
769
+ out.push({ path, message: `'${name}' is run by a map node; it cannot also appear in an edge` });
770
+ }
771
+ });
772
+ });
773
+ if (edges.length && starts === 0)
774
+ out.push({ path: ['workflow', 'edges'], message: `no chain begins with ${START_NAME}` });
775
+ for (const name of declared) {
776
+ if (!referenced.has(name) && !agentNames.includes(name))
777
+ out.push({ path: ['workflow', 'nodes', name], message: 'declared but used in no edge' });
778
+ }
779
+ void DEFAULT_ROUTE_KEY;
780
+ // What a node cannot carry yet (lib/workflow.ts, "Not in this version").
781
+ const agents = [
782
+ [['orchestrator'], raw.orchestrator],
783
+ ...subs.map((sub, i) => [['subagents', i], sub]),
784
+ ];
785
+ for (const [path, agent] of agents) {
786
+ if (!isObj(agent))
787
+ continue;
788
+ if (Array.isArray(agent.require_approval) && agent.require_approval.length) {
789
+ out.push({ path: [...path, 'require_approval'], message: 'approval gates are not supported inside a workflow yet' });
790
+ }
791
+ if (isObj(agent.skills) && agent.skills.scripts === 'local') {
792
+ out.push({ path: [...path, 'skills', 'scripts'], message: 'skill scripts (an approval pause) are not supported inside a workflow yet' });
793
+ }
794
+ if (typeof agent.a2a_agent_url === 'string') {
795
+ out.push({ path: [...path, 'a2a_agent_url'], message: 'a remote agent cannot be a workflow node yet' });
796
+ }
797
+ }
798
+ return out;
799
+ }
460
800
  // ── Public API ───────────────────────────────────────────────────────────────
461
801
  /**
462
802
  * Thrown by validateSyndicateConfig. `problems` holds one `<file>: <path> —
@@ -18,6 +18,8 @@ import { WIKI_AGENT_TOOL_CONTRACTS } from './tools/wikiTools.js';
18
18
  import { SCIENCE_TOOL_CONTRACTS } from './tools/scienceTools.js';
19
19
  import { TASK_TOOL_CONTRACTS } from './tools/taskTools.js';
20
20
  import { X_SEARCH } from './tools/xSearchTool.js';
21
+ import { askUserTool } from './runtime/questions.js';
22
+ import { URL_CONTEXT } from './tools/urlContextTool.js';
21
23
  import { xApiSearchTool } from './tools/xApiSearchTool.js';
22
24
  // Knowledge-bundle tools, derived from their contracts so the YAML names
23
25
  // can never drift from the definitions. The agentic composites
@@ -52,6 +54,9 @@ const BUILTIN_TOOLS = {
52
54
  // reading (keyless — works on every provider, including local Ollama).
53
55
  // augustin.yaml and librarian-style research agents declare it.
54
56
  web_extract: webExtractTool,
57
+ // Gemini reads URLs in the conversation server-side; a no-op (reported as
58
+ // dropped by the doctor) on other providers. lib/tools/urlContextTool.ts.
59
+ url_context: URL_CONTEXT,
55
60
  x_search: X_SEARCH,
56
61
  // X API v2 recent search as a client-side contract, photos transcribed
57
62
  // inline — runs on every provider; needs X_BEARER_TOKEN in the server env.
@@ -62,6 +67,10 @@ const BUILTIN_TOOLS = {
62
67
  google_search: GOOGLE_SEARCH,
63
68
  generate_image: generateImageTool,
64
69
  inspect_image: inspectImageTool,
70
+ // Ask the person mid-turn (lib/runtime/questions.ts): a long-running call
71
+ // that ends the turn input-required; the next message is its answer. Only
72
+ // on an agent the turn runs directly (the schema enforces it).
73
+ ask_user: askUserTool,
65
74
  load_memory: LOAD_MEMORY,
66
75
  preload_memory: PRELOAD_MEMORY,
67
76
  };
@@ -1,4 +1,30 @@
1
1
  import { FunctionTool } from '@google/adk';
2
+ import type { RedirectPolicy } from '../net/redirects.ts';
2
3
  export declare function assertSafeMcpUrl(raw: string, env?: NodeJS.ProcessEnv): Promise<URL>;
3
4
  export declare function mcpAuthHeaders(url: URL, env?: NodeJS.ProcessEnv): Record<string, string>;
5
+ /**
6
+ * Where an MCP connection may be redirected (ADR 0036's rule, applied to MCP):
7
+ * a hop on the server's own origin gets the server's check (ALLOW_PRIVATE_MCP
8
+ * applies), a hop anywhere else the full guard with no development exception,
9
+ * and lib/net/redirects.ts strips every credential header from it, so a
10
+ * bearer token from MCP_BEARER_TOKENS never follows a redirect off its host.
11
+ * The SDK already refuses a POST endpoint on another origin.
12
+ */
13
+ export declare const MCP_REDIRECTS: RedirectPolicy;
14
+ /** The fetch an MCP transport uses: every redirect hop checked before it is followed. */
15
+ export declare const mcpFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
16
+ /**
17
+ * A remote MCP server is an untrusted tool vendor: its tool descriptions go
18
+ * into the prompt and its results into the conversation. Both are bounded,
19
+ * so a server cannot flood the context (or the bill) with either.
20
+ */
21
+ export declare const MAX_MCP_DESCRIPTION_CHARS = 1000;
22
+ export declare const MAX_MCP_RESULT_CHARS = 20000;
23
+ /**
24
+ * Close every open MCP connection. A connection otherwise lives as long as
25
+ * the process (its tools are called for the life of the compiled agent), and
26
+ * its stream reconnects when the server restarts; an embedding app that
27
+ * stops cleanly, or a test, calls this.
28
+ */
29
+ export declare function closeMcpConnections(): Promise<void>;
4
30
  export declare function createMcpTools(mcpServerUrl: string): Promise<FunctionTool[]>;
@@ -2,6 +2,7 @@ import { FunctionTool } from '@google/adk';
2
2
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
3
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
4
4
  import { checkHost } from '../net/addressGuard.js';
5
+ import { fetchWithRedirectPolicy } from '../net/redirects.js';
5
6
  // Security (SSRF): mcp_server_url can arrive from a registry-stored syndicate
6
7
  // config. Only http(s), and the host must pass lib/net/addressGuard.ts (the
7
8
  // same guard web_extract uses): local names and non-public addresses —
@@ -53,11 +54,61 @@ export function mcpAuthHeaders(url, env = process.env) {
53
54
  const token = tokens[host];
54
55
  return typeof token === 'string' && token ? { Authorization: `Bearer ${token}` } : {};
55
56
  }
57
+ /**
58
+ * Where an MCP connection may be redirected (ADR 0036's rule, applied to MCP):
59
+ * a hop on the server's own origin gets the server's check (ALLOW_PRIVATE_MCP
60
+ * applies), a hop anywhere else the full guard with no development exception,
61
+ * and lib/net/redirects.ts strips every credential header from it, so a
62
+ * bearer token from MCP_BEARER_TOKENS never follows a redirect off its host.
63
+ * The SDK already refuses a POST endpoint on another origin.
64
+ */
65
+ export const MCP_REDIRECTS = {
66
+ async hopProblem(url, crossOrigin) {
67
+ if (!crossOrigin) {
68
+ try {
69
+ await assertSafeMcpUrl(url.href);
70
+ return null;
71
+ }
72
+ catch (err) {
73
+ return err instanceof Error ? err.message : String(err);
74
+ }
75
+ }
76
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
77
+ return `${url.protocol} is not http(s)`;
78
+ const reason = await checkHost(url.hostname);
79
+ return reason ? `refusing MCP redirect to ${url.hostname}: ${reason}` : null;
80
+ },
81
+ };
82
+ /** The fetch an MCP transport uses: every redirect hop checked before it is followed. */
83
+ export const mcpFetch = (input, init) => fetchWithRedirectPolicy(input, init, MCP_REDIRECTS, (i, o) => fetch(i, o));
84
+ /**
85
+ * A remote MCP server is an untrusted tool vendor: its tool descriptions go
86
+ * into the prompt and its results into the conversation. Both are bounded,
87
+ * so a server cannot flood the context (or the bill) with either.
88
+ */
89
+ export const MAX_MCP_DESCRIPTION_CHARS = 1_000;
90
+ export const MAX_MCP_RESULT_CHARS = 20_000;
91
+ const bounded = (text, max, what) => text.length <= max ? text : `${text.slice(0, max)}… [${what} cut at ${max} characters]`;
92
+ /** Every MCP connection opened and still in use, so a shutdown can close them. */
93
+ const openTransports = new Set();
94
+ /**
95
+ * Close every open MCP connection. A connection otherwise lives as long as
96
+ * the process (its tools are called for the life of the compiled agent), and
97
+ * its stream reconnects when the server restarts; an embedding app that
98
+ * stops cleanly, or a test, calls this.
99
+ */
100
+ export async function closeMcpConnections() {
101
+ const all = [...openTransports];
102
+ openTransports.clear();
103
+ await Promise.all(all.map((t) => t.close().catch(() => { })));
104
+ }
56
105
  export async function createMcpTools(mcpServerUrl) {
106
+ let transport;
57
107
  try {
58
108
  const url = await assertSafeMcpUrl(mcpServerUrl);
59
- const transport = new SSEClientTransport(url, {
60
- requestInit: { headers: mcpAuthHeaders(url) }
109
+ transport = new SSEClientTransport(url, {
110
+ requestInit: { headers: mcpAuthHeaders(url) },
111
+ fetch: mcpFetch,
61
112
  });
62
113
  const client = new Client({
63
114
  name: 'melchizedek-a2a-client',
@@ -66,6 +117,7 @@ export async function createMcpTools(mcpServerUrl) {
66
117
  capabilities: {}
67
118
  });
68
119
  await client.connect(transport);
120
+ openTransports.add(transport);
69
121
  // Fetch available tools from the MCP server
70
122
  const toolsResponse = await client.listTools();
71
123
  // MCP servers describe tools in standard lowercase JSON Schema; the ADK
@@ -106,7 +158,7 @@ export async function createMcpTools(mcpServerUrl) {
106
158
  }
107
159
  return new FunctionTool({
108
160
  name: tool.name,
109
- description: tool.description || `MCP Tool: ${tool.name}`,
161
+ description: bounded(tool.description || `MCP Tool: ${tool.name}`, MAX_MCP_DESCRIPTION_CHARS, 'description'),
110
162
  parameters: {
111
163
  type: 'OBJECT',
112
164
  properties,
@@ -120,7 +172,7 @@ export async function createMcpTools(mcpServerUrl) {
120
172
  });
121
173
  const content = result.content;
122
174
  const texts = content.filter(c => c.type === 'text').map(c => c.text ?? '');
123
- return texts.join('\n');
175
+ return bounded(texts.join('\n'), MAX_MCP_RESULT_CHARS, 'result');
124
176
  }
125
177
  catch (error) {
126
178
  return `[MCP ERROR] Tool ${tool.name} failed: ${error.message}`;
@@ -131,6 +183,11 @@ export async function createMcpTools(mcpServerUrl) {
131
183
  }
132
184
  catch (error) {
133
185
  console.warn(`[MCP] Failed to connect or load tools from ${mcpServerUrl}`, error);
186
+ // A failed connect leaves the SSE stream's reconnect timer running; close
187
+ // it, or every unreachable server keeps retrying for the process's life.
188
+ if (transport)
189
+ openTransports.delete(transport);
190
+ await transport?.close().catch(() => { });
134
191
  return [];
135
192
  }
136
193
  }
@@ -0,0 +1,52 @@
1
+ import { BaseTool } from '@google/adk';
2
+ export interface OpenApiAuthConfig {
3
+ /** Environment variable holding a bearer token (`Authorization: Bearer …`). */
4
+ bearer_env?: string;
5
+ /** An API key from an environment variable, sent in a header or the query. */
6
+ api_key?: {
7
+ env: string;
8
+ in: 'header' | 'query';
9
+ name: string;
10
+ };
11
+ }
12
+ /** One entry of an agent's `openapi:` list. */
13
+ export interface OpenApiConfig {
14
+ /** Path to an OpenAPI 3 spec (.yaml, .yml or .json). Relative paths resolve beside the syndicate file. */
15
+ spec: string;
16
+ /** Operations to expose, by operationId or tool name. Omitted: every GET operation. */
17
+ operations?: string[];
18
+ /** Overrides the spec's `servers[0].url`. */
19
+ base_url?: string;
20
+ /** Prepended to every tool name (`<prefix>_<tool>`), to keep two APIs apart. */
21
+ prefix?: string;
22
+ auth?: OpenApiAuthConfig;
23
+ }
24
+ /** Characters a single API result may carry into the conversation. */
25
+ export declare const MAX_RESULT_CHARS = 20000;
26
+ /** Marks a tool built here, so require_approval may gate it (lib/compile.ts). */
27
+ export declare const OPENAPI_TOOL: unique symbol;
28
+ /** snake_case, the way ADK names a tool from an operationId. */
29
+ export declare function toSnake(name: string): string;
30
+ /** True when a configured operation name (an operationId or a tool name) names this tool. */
31
+ export declare function namesTool(configured: string, tool: {
32
+ name: string;
33
+ operation?: {
34
+ operationId?: string;
35
+ };
36
+ }): boolean;
37
+ /**
38
+ * Why an `auth` may not read this variable, or null. With
39
+ * OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
40
+ * it, anything but the framework's own variables is.
41
+ */
42
+ export declare function credentialEnvProblem(name: string, env?: NodeJS.ProcessEnv): string | null;
43
+ /** Cut an API result to MAX_RESULT_CHARS, saying so. */
44
+ export declare function boundResult(result: unknown): unknown;
45
+ /**
46
+ * The tools one `openapi:` entry gives an agent. Throws (failing the compile)
47
+ * on a missing spec, an unset auth variable, an operation the spec does not
48
+ * have, or a server the guard refuses.
49
+ */
50
+ export declare function buildOpenApiTools(entry: OpenApiConfig, baseDir?: string): Promise<BaseTool[]>;
51
+ /** True for a tool built from an `openapi:` entry. */
52
+ export declare function isOpenApiTool(tool: unknown): boolean;