melchizedek-agents 0.16.2 → 0.17.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 (82) hide show
  1. package/.env.example +17 -0
  2. package/AGENT_SETUP.md +2 -1
  3. package/CHANGELOG.md +142 -0
  4. package/DOCUMENTATION.md +237 -7
  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 +112 -1
  14. package/db/migrations/0011_erase_expired.sql +198 -0
  15. package/dist/lib/a2a/app.d.ts +7 -0
  16. package/dist/lib/a2a/app.js +7 -1
  17. package/dist/lib/a2a/executor.js +41 -0
  18. package/dist/lib/compile.d.ts +40 -1
  19. package/dist/lib/compile.js +132 -14
  20. package/dist/lib/config.d.ts +17 -0
  21. package/dist/lib/config.js +17 -0
  22. package/dist/lib/doctor.js +17 -4
  23. package/dist/lib/index.d.ts +2 -0
  24. package/dist/lib/index.js +1 -0
  25. package/dist/lib/loadSyndicate.d.ts +46 -0
  26. package/dist/lib/loadSyndicate.js +19 -3
  27. package/dist/lib/memory/erase.d.ts +5 -0
  28. package/dist/lib/memory/erase.js +5 -0
  29. package/dist/lib/models/capabilities.js +10 -0
  30. package/dist/lib/models/endpoints.d.ts +1 -1
  31. package/dist/lib/models/endpoints.js +4 -1
  32. package/dist/lib/models/gateway.d.ts +2 -2
  33. package/dist/lib/models/gateway.js +4 -4
  34. package/dist/lib/models/kimiLlm.d.ts +123 -0
  35. package/dist/lib/models/kimiLlm.js +171 -0
  36. package/dist/lib/models/providerMap.d.ts +2 -2
  37. package/dist/lib/models/providerMap.js +4 -1
  38. package/dist/lib/models/registry.d.ts +1 -1
  39. package/dist/lib/models/registry.js +11 -3
  40. package/dist/lib/net/redirects.d.ts +14 -0
  41. package/dist/lib/net/redirects.js +94 -0
  42. package/dist/lib/observability/tracer.d.ts +1 -1
  43. package/dist/lib/persistence/supabaseProvider.d.ts +2 -6
  44. package/dist/lib/persistence/supabaseProvider.js +2 -31
  45. package/dist/lib/runtime/questions.d.ts +55 -0
  46. package/dist/lib/runtime/questions.js +107 -0
  47. package/dist/lib/runtime/syndicateTurn.d.ts +42 -4
  48. package/dist/lib/runtime/syndicateTurn.js +162 -16
  49. package/dist/lib/storage/postgres/index.d.ts +6 -0
  50. package/dist/lib/storage/postgres/index.js +11 -0
  51. package/dist/lib/storage/rlsStatus.d.ts +45 -0
  52. package/dist/lib/storage/rlsStatus.js +69 -0
  53. package/dist/lib/syndicateSchema.d.ts +169 -0
  54. package/dist/lib/syndicateSchema.js +327 -2
  55. package/dist/lib/toolRegistry.js +9 -0
  56. package/dist/lib/tools/openapiTools.d.ts +46 -0
  57. package/dist/lib/tools/openapiTools.js +195 -0
  58. package/dist/lib/tools/skillToolset.d.ts +135 -0
  59. package/dist/lib/tools/skillToolset.js +247 -0
  60. package/dist/lib/tools/urlContextTool.d.ts +29 -0
  61. package/dist/lib/tools/urlContextTool.js +39 -0
  62. package/dist/lib/workflow.d.ts +72 -0
  63. package/dist/lib/workflow.js +180 -0
  64. package/dist/lib/workflowConfig.d.ts +95 -0
  65. package/dist/lib/workflowConfig.js +93 -0
  66. package/dist/scripts/syndicate_chat.js +39 -5
  67. package/package.json +4 -1
  68. package/skills/briefs/_shared.md +1 -1
  69. package/skills/briefs/melchizedek-author.md +8 -4
  70. package/skills/briefs/melchizedek-memory.md +1 -1
  71. package/skills/briefs/melchizedek-models.md +1 -1
  72. package/skills/briefs/melchizedek-scribe.md +4 -4
  73. package/skills/briefs/melchizedek-serve.md +1 -1
  74. package/skills/briefs/melchizedek.md +4 -1
  75. package/skills/melchizedek/SKILL.md +4 -1
  76. package/skills/melchizedek-author/SKILL.md +7 -3
  77. package/skills/melchizedek-memory/SKILL.md +1 -1
  78. package/skills/melchizedek-models/SKILL.md +1 -1
  79. package/skills/melchizedek-scribe/SKILL.md +3 -3
  80. package/skills/melchizedek-serve/SKILL.md +1 -1
  81. /package/skills/melchizedek-author/{templates → assets}/minimal.yaml +0 -0
  82. /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,
@@ -104,6 +154,30 @@ const agentFields = {
104
154
  .string()
105
155
  .optional()
106
156
  .describe('MCP server (SSE) whose tools are discovered at runtime and merged with `tools`.'),
157
+ skills: skillsSchema.optional(),
158
+ openapi: z.array(openapiEntry).min(1).optional(),
159
+ code_execution: z
160
+ .literal('gemini')
161
+ .optional()
162
+ .describe('"gemini": the model writes and runs Python in Gemini\'s server-side sandbox; nothing runs on this host. Gemini models only.'),
163
+ context: z
164
+ .strictObject({
165
+ compact_after_tokens: z.number().int().min(1000).describe('Compact when the last request\'s prompt passed this many tokens.'),
166
+ keep_recent_events: z.number().int().min(1).max(100).optional().describe('Events kept verbatim after the summary. Default 6.'),
167
+ summary_model: z.string().min(1).optional().describe('The model that writes the summary. Default: the agent\'s own.'),
168
+ })
169
+ .optional()
170
+ .describe('Summarize a long conversation instead of overflowing the window. The orchestrator of a delegate syndicate only.'),
171
+ examples: z
172
+ .array(z.strictObject({ input: z.string().min(1), output: z.string().min(1) }))
173
+ .min(1)
174
+ .max(20)
175
+ .optional()
176
+ .describe('Few-shot exchanges (input, output) added to every request\'s instruction.'),
177
+ mode: z
178
+ .literal('task')
179
+ .optional()
180
+ .describe('"task": the agent works until it calls finish_task; its arguments (matching outputSchema) are the node\'s output. Workflow nodes only.'),
107
181
  orchestration: z
108
182
  .strictObject({
109
183
  role: z.enum(['primary', 'sub-agent']).optional(),
@@ -159,6 +233,48 @@ const dispatchSchema = z
159
233
  route_overrides: z.array(routeOverride).optional(),
160
234
  })
161
235
  .describe('Opts into PLAN-DISPATCH orchestration (lib/dispatch.ts).');
236
+ // ── Workflow ─────────────────────────────────────────────────────────────────
237
+ const nodeName = z.string().min(1);
238
+ const edgeTargets = z.union([nodeName, z.array(nodeName).min(1)]);
239
+ /** One element of an edge chain: a name, several names, or a routing map. */
240
+ const edgeElement = z.union([
241
+ nodeName,
242
+ z.array(nodeName).min(1).describe('Several nodes: fan-out when they follow one node, fan-in when one node follows them.'),
243
+ z
244
+ .record(z.string().min(1), edgeTargets)
245
+ .describe('A routing map after a node: `{ <route>: <node or nodes>, default: <node> }`, matched against that node\'s output.'),
246
+ ]);
247
+ const retrySchema = z
248
+ .strictObject({
249
+ max_attempts: z.number().int().positive().optional().describe('Attempts including the first; 1 = no retry. ADK default 5.'),
250
+ initial_delay: z.number().nonnegative().optional().describe('Seconds before the first retry.'),
251
+ max_delay: z.number().nonnegative().optional(),
252
+ backoff_factor: z.number().positive().optional(),
253
+ })
254
+ .describe('Retry a node on failure (lib/workflow.ts).');
255
+ const workflowNodeSchema = z
256
+ .strictObject({
257
+ ask_user: z.string().min(1).optional().describe('Pause and ask the person this; the reply becomes the node\'s output.'),
258
+ schema: z.record(z.string(), z.unknown()).optional().describe('JSON Schema a structured reply to ask_user must satisfy; plain text passes as is.'),
259
+ join: z.literal(true).optional().describe('Wait for every predecessor; output `{ <predecessor>: <output> }`.'),
260
+ map: z.string().min(1).optional().describe('Run this agent once per item of a list input, concurrently; output the list of results.'),
261
+ max_parallel: z.number().int().positive().optional().describe('Concurrency of map. Default 8.'),
262
+ tool: z.string().min(1).optional().describe('Run this registry tool with the node input as its arguments.'),
263
+ route_key: z.string().min(1).optional().describe('Property of a JSON output holding the route. Default "route".'),
264
+ retry: retrySchema.optional(),
265
+ timeout: z.number().positive().optional().describe('Seconds this node may run before it fails.'),
266
+ })
267
+ .describe('A declared node (exactly one of ask_user, join, map, tool) or modifiers for an agent node (retry, timeout, route_key).');
268
+ const workflowSchema = z
269
+ .strictObject({
270
+ edges: z
271
+ .array(z.array(edgeElement).min(2))
272
+ .min(1)
273
+ .describe('Chains of nodes, each a list; `START` begins at least one. A routing map follows the node whose output it routes.'),
274
+ nodes: z.record(nodeName, workflowNodeSchema).optional(),
275
+ max_concurrency: z.number().int().positive().optional().describe('Nodes that may run at once. Default: unbounded.'),
276
+ })
277
+ .describe('Opts into a WORKFLOW: the syndicate as a graph (lib/workflow.ts). Cannot be combined with dispatch.');
162
278
  // ── Syndicate ────────────────────────────────────────────────────────────────
163
279
  export const syndicateSchema = z
164
280
  .strictObject({
@@ -169,6 +285,14 @@ export const syndicateSchema = z
169
285
  .optional()
170
286
  .describe('The orchestrator\'s team. Omit it (or write `subagents: []`) for a single-agent syndicate.'),
171
287
  dispatch: dispatchSchema.optional(),
288
+ workflow: workflowSchema.optional(),
289
+ retries: z
290
+ .strictObject({
291
+ model_errors: z.number().int().min(0).max(5).optional().describe('Retries of a malformed model reply. Default 2; 0 off.'),
292
+ tool_errors: z.number().int().min(0).max(5).optional().describe('Retries of a tool that threw, with reflection guidance. Default 3; 0 off.'),
293
+ })
294
+ .optional()
295
+ .describe('Self-correction on model and tool errors (ADK reflect-and-retry plugins). On by default.'),
172
296
  variables: z
173
297
  .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
174
298
  .optional()
@@ -387,19 +511,80 @@ function crossFieldProblems(raw) {
387
511
  return;
388
512
  }
389
513
  const tools = Array.isArray(agent.tools) ? agent.tools : [];
514
+ // An OpenAPI operation can be gated once it is named under `operations`.
515
+ const operations = (Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations : []));
390
516
  (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` });
517
+ if (typeof name === 'string' && !tools.includes(name) && !operations.includes(name)) {
518
+ out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools or its openapi operations` });
393
519
  }
394
520
  });
395
521
  };
522
+ // A skill script run pauses for approval the same way, so it is allowed in
523
+ // the same places; `skills.tools` may only unlock tools the agent does not
524
+ // already carry outright.
525
+ const skillProblems = (agent, path, allowed) => {
526
+ if (!isObj(agent.skills))
527
+ return;
528
+ if (agent.skills.scripts === 'local' && !allowed) {
529
+ out.push({
530
+ path: [...path, 'skills', 'scripts'],
531
+ 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)',
532
+ });
533
+ }
534
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
535
+ (Array.isArray(agent.skills.tools) ? agent.skills.tools : []).forEach((name, j) => {
536
+ if (typeof name === 'string' && tools.includes(name)) {
537
+ out.push({ path: [...path, 'skills', 'tools', j], message: `'${name}' is already in this agent's tools; a skill cannot unlock what is always on` });
538
+ }
539
+ });
540
+ };
541
+ // ask_user ends the turn waiting for the person, the same pause as an
542
+ // approval, so it is allowed in the same places (lib/runtime/questions.ts).
543
+ const questionProblems = (agent, path, allowed) => {
544
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
545
+ const at = tools.indexOf('ask_user');
546
+ if (at === -1)
547
+ return;
548
+ if (isObj(raw.workflow)) {
549
+ out.push({ path: [...path, 'tools', at], message: 'ask_user is not supported on a workflow node yet; use an ask_user node (workflow.nodes)' });
550
+ }
551
+ else if (!allowed) {
552
+ 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' });
553
+ }
554
+ };
555
+ // Execution keys (ADR 0033): where each one means something.
556
+ const executionProblems = (agent, path, isOrchestrator) => {
557
+ const model = typeof agent.model === 'string' ? agent.model : isObj(raw.orchestrator) && typeof raw.orchestrator.model === 'string' ? raw.orchestrator.model : '';
558
+ if (agent.code_execution !== undefined && model && !/^gemini-/.test(model)) {
559
+ out.push({ path: [...path, 'code_execution'], message: `code_execution: gemini needs a gemini-* model (this agent's is ${model})` });
560
+ }
561
+ if (agent.context !== undefined && (!isOrchestrator || isObj(raw.dispatch) || isObj(raw.workflow))) {
562
+ out.push({
563
+ path: [...path, 'context'],
564
+ 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',
565
+ });
566
+ }
567
+ if (agent.mode !== undefined && !isObj(raw.workflow)) {
568
+ out.push({ path: [...path, 'mode'], message: 'mode: task applies to workflow nodes, whose output is the finish_task arguments' });
569
+ }
570
+ };
396
571
  if (isObj(raw.orchestrator))
572
+ executionProblems(raw.orchestrator, ['orchestrator'], true);
573
+ for (const [i, sub] of (Array.isArray(raw.subagents) ? raw.subagents : []).entries())
574
+ if (isObj(sub))
575
+ executionProblems(sub, ['subagents', i], false);
576
+ if (isObj(raw.orchestrator)) {
397
577
  gateProblems(raw.orchestrator, ['orchestrator'], true);
578
+ skillProblems(raw.orchestrator, ['orchestrator'], true);
579
+ questionProblems(raw.orchestrator, ['orchestrator'], true);
580
+ }
398
581
  const dispatching = isObj(raw.dispatch);
399
582
  subs.forEach((sub, i) => {
400
583
  if (!isObj(sub))
401
584
  return;
402
585
  gateProblems(sub, ['subagents', i], dispatching);
586
+ skillProblems(sub, ['subagents', i], dispatching);
587
+ questionProblems(sub, ['subagents', i], dispatching);
403
588
  const hasRef = typeof sub.yaml_reference === 'string';
404
589
  const hasRemote = typeof sub.a2a_agent_url === 'string';
405
590
  if (hasRef && hasRemote) {
@@ -427,6 +612,8 @@ function crossFieldProblems(raw) {
427
612
  }
428
613
  }
429
614
  });
615
+ if (isObj(raw.workflow))
616
+ out.push(...workflowProblems(raw, subs));
430
617
  // Names must be unique across the tree this file declares: two AgentTools
431
618
  // with one name collide, and dispatch routes by name.
432
619
  const seen = new Map();
@@ -457,6 +644,144 @@ function crossFieldProblems(raw) {
457
644
  }
458
645
  return out;
459
646
  }
647
+ /**
648
+ * The rules a `workflow:` block must keep (lib/workflow.ts), which a JSON
649
+ * Schema cannot say: every name is an agent or a declared node, `START`
650
+ * opens a chain and nothing else, a routing map follows the node it routes,
651
+ * a declared node is exactly one kind, and the pauses and remotes the graph
652
+ * cannot carry yet are refused with the reason.
653
+ */
654
+ function workflowProblems(raw, subs) {
655
+ const out = [];
656
+ const wf = raw.workflow;
657
+ if (isObj(raw.dispatch)) {
658
+ out.push({ path: ['workflow'], message: 'a syndicate is a workflow or a plan-dispatch router, not both; remove `dispatch`' });
659
+ }
660
+ const agentNames = [];
661
+ if (isObj(raw.orchestrator) && typeof raw.orchestrator.name === 'string')
662
+ agentNames.push(raw.orchestrator.name);
663
+ for (const sub of subs)
664
+ if (isObj(sub) && typeof sub.name === 'string')
665
+ agentNames.push(sub.name);
666
+ const nodes = isObj(wf.nodes) ? wf.nodes : {};
667
+ const declared = Object.keys(nodes);
668
+ const known = new Set([...agentNames, ...declared]);
669
+ const mapped = new Set();
670
+ // Declared nodes: an agent takes modifiers only; anything else is one kind.
671
+ for (const [name, entry] of Object.entries(nodes)) {
672
+ if (!isObj(entry))
673
+ continue;
674
+ const kinds = NODE_KINDS.filter((k) => entry[k] !== undefined);
675
+ const isAgent = agentNames.includes(name);
676
+ if (isAgent && kinds.length > 0) {
677
+ out.push({ path: ['workflow', 'nodes', name], message: `'${name}' is an agent; its node entry may carry only route_key, retry and timeout` });
678
+ }
679
+ else if (!isAgent && kinds.length !== 1) {
680
+ out.push({ path: ['workflow', 'nodes', name], message: `a declared node is exactly one of ${NODE_KINDS.join(', ')}${kinds.length ? ` (has ${kinds.join(', ')})` : ''}` });
681
+ }
682
+ if (name.endsWith(ROUTE_STEP_SUFFIX) || name === START_NAME) {
683
+ out.push({ path: ['workflow', 'nodes', name], message: `'${name}' is reserved` });
684
+ }
685
+ const kind = nodeKind(entry);
686
+ if (kind === 'map') {
687
+ const target = entry.map;
688
+ if (!agentNames.includes(target)) {
689
+ const hint = suggest(target, agentNames);
690
+ out.push({ path: ['workflow', 'nodes', name, 'map'], message: `'${target}' is not an agent of this syndicate${hint ? ` (did you mean "${hint}"?)` : ''}` });
691
+ }
692
+ else {
693
+ mapped.add(target);
694
+ }
695
+ }
696
+ if (kind !== 'ask_user' && entry.schema !== undefined) {
697
+ out.push({ path: ['workflow', 'nodes', name, 'schema'], message: 'schema applies to ask_user only' });
698
+ }
699
+ if (kind !== 'map' && entry.max_parallel !== undefined) {
700
+ out.push({ path: ['workflow', 'nodes', name, 'max_parallel'], message: 'max_parallel applies to map only' });
701
+ }
702
+ }
703
+ for (const name of agentNames) {
704
+ if (name.endsWith(ROUTE_STEP_SUFFIX))
705
+ out.push({ path: ['workflow'], message: `agent name '${name}' ends with the reserved suffix ${ROUTE_STEP_SUFFIX}` });
706
+ }
707
+ // Edges.
708
+ const edges = Array.isArray(wf.edges) ? wf.edges : [];
709
+ let starts = 0;
710
+ const referenced = new Set();
711
+ const checkName = (name, path) => {
712
+ if (known.has(name)) {
713
+ referenced.add(name);
714
+ return;
715
+ }
716
+ const hint = suggest(name, [...known]);
717
+ out.push({ path, message: `'${name}' is not an agent or a declared node${hint ? ` (did you mean "${hint}"?)` : ''}` });
718
+ };
719
+ edges.forEach((chain, i) => {
720
+ if (!Array.isArray(chain))
721
+ return;
722
+ chain.forEach((element, j) => {
723
+ const path = ['workflow', 'edges', i, j];
724
+ if (typeof element === 'string') {
725
+ if (element === START_NAME) {
726
+ if (j !== 0)
727
+ out.push({ path, message: 'START opens a chain; it cannot follow a node' });
728
+ else
729
+ starts++;
730
+ return;
731
+ }
732
+ checkName(element, path);
733
+ }
734
+ else if (Array.isArray(element)) {
735
+ element.forEach((name, k) => typeof name === 'string' && checkName(name, [...path, k]));
736
+ }
737
+ else if (isObj(element)) {
738
+ const previous = chain[j - 1];
739
+ if (typeof previous !== 'string' || previous === START_NAME) {
740
+ out.push({ path, message: 'a routing map follows the name of the node whose output it routes' });
741
+ }
742
+ else if (!agentNames.includes(previous) && nodeKind(nodes[previous]) !== 'tool') {
743
+ out.push({ path, message: `'${previous}' is a ${nodeKind(nodes[previous]) ?? 'node'}; only an agent or a tool node emits a route` });
744
+ }
745
+ if (j !== chain.length - 1)
746
+ out.push({ path, message: 'a routing map ends its chain; start another chain from each target' });
747
+ for (const [key, target] of Object.entries(element)) {
748
+ const targets = Array.isArray(target) ? target : [target];
749
+ targets.forEach((name, k) => typeof name === 'string' && checkName(name, [...path, key, k]));
750
+ }
751
+ }
752
+ for (const name of isObj(element) || Array.isArray(element) || typeof element === 'string' ? elementNames(element) : []) {
753
+ if (mapped.has(name))
754
+ out.push({ path, message: `'${name}' is run by a map node; it cannot also appear in an edge` });
755
+ }
756
+ });
757
+ });
758
+ if (edges.length && starts === 0)
759
+ out.push({ path: ['workflow', 'edges'], message: `no chain begins with ${START_NAME}` });
760
+ for (const name of declared) {
761
+ if (!referenced.has(name) && !agentNames.includes(name))
762
+ out.push({ path: ['workflow', 'nodes', name], message: 'declared but used in no edge' });
763
+ }
764
+ void DEFAULT_ROUTE_KEY;
765
+ // What a node cannot carry yet (lib/workflow.ts, "Not in this version").
766
+ const agents = [
767
+ [['orchestrator'], raw.orchestrator],
768
+ ...subs.map((sub, i) => [['subagents', i], sub]),
769
+ ];
770
+ for (const [path, agent] of agents) {
771
+ if (!isObj(agent))
772
+ continue;
773
+ if (Array.isArray(agent.require_approval) && agent.require_approval.length) {
774
+ out.push({ path: [...path, 'require_approval'], message: 'approval gates are not supported inside a workflow yet' });
775
+ }
776
+ if (isObj(agent.skills) && agent.skills.scripts === 'local') {
777
+ out.push({ path: [...path, 'skills', 'scripts'], message: 'skill scripts (an approval pause) are not supported inside a workflow yet' });
778
+ }
779
+ if (typeof agent.a2a_agent_url === 'string') {
780
+ out.push({ path: [...path, 'a2a_agent_url'], message: 'a remote agent cannot be a workflow node yet' });
781
+ }
782
+ }
783
+ return out;
784
+ }
460
785
  // ── Public API ───────────────────────────────────────────────────────────────
461
786
  /**
462
787
  * 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
  };
@@ -0,0 +1,46 @@
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
+ /** Cut an API result to MAX_RESULT_CHARS, saying so. */
38
+ export declare function boundResult(result: unknown): unknown;
39
+ /**
40
+ * The tools one `openapi:` entry gives an agent. Throws (failing the compile)
41
+ * on a missing spec, an unset auth variable, an operation the spec does not
42
+ * have, or a server the guard refuses.
43
+ */
44
+ export declare function buildOpenApiTools(entry: OpenApiConfig, baseDir?: string): Promise<BaseTool[]>;
45
+ /** True for a tool built from an `openapi:` entry. */
46
+ export declare function isOpenApiTool(tool: unknown): boolean;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * lib/tools/openapiTools.ts — any HTTP API with an OpenAPI spec becomes an
3
+ * agent's tools, from YAML.
4
+ *
5
+ * WHY: "Bring your own API" was a code task: write a `defineTool` per
6
+ * endpoint, register it, name it in YAML. ADK 2.2 ships `OpenAPIToolset`,
7
+ * which turns an OpenAPI 3 spec into one tool per operation (parameters and
8
+ * request body as the tool's schema, the operation's summary as its
9
+ * description). An agent's `openapi:` list hands it a spec file:
10
+ *
11
+ * openapi:
12
+ * - spec: "specs/weather.yaml" # beside this YAML file
13
+ * operations: [getForecast] # omitted: the GET operations only
14
+ * auth: { api_key: { env: "WEATHER_KEY", in: "header", name: "X-Api-Key" } }
15
+ *
16
+ * WHAT THE ENGINE ADDS to ADK's toolset, all of it exposure discipline:
17
+ * - READ-ONLY BY DEFAULT. Without `operations`, only GET operations become
18
+ * tools; anything that writes is exposed only by naming it, and a named
19
+ * operation can be listed under `require_approval` like a registry tool
20
+ * (ADR 0028), so a POST or a DELETE can wait for a person.
21
+ * - SECRETS FROM THE ENVIRONMENT. `auth` names an environment variable,
22
+ * never a value; a variable that is not set fails the compile, so an
23
+ * agent never runs half-authenticated.
24
+ * - THE SSRF GUARD. Every server URL the tools will call must be http(s)
25
+ * and pass lib/net/addressGuard.ts: its literal rules at compile time
26
+ * (offline), and the full check with DNS before each call (a name can
27
+ * re-resolve between calls). ALLOW_PRIVATE_OPENAPI=true permits
28
+ * private hosts for local development, as ALLOW_PRIVATE_MCP does for MCP.
29
+ * The model chooses arguments, never the host: path parameters are
30
+ * URL-encoded by ADK and dot segments refused.
31
+ * - REDIRECTS HELD TO THE SAME GUARD. ADK calls globalThis.fetch, which
32
+ * follows redirects; a call runs under lib/net/redirects.ts instead, so
33
+ * each hop is checked before it is fetched. A hop on the same origin
34
+ * gets the server's own check; a hop to another origin must pass the
35
+ * full guard even with ALLOW_PRIVATE_OPENAPI (that permits the server
36
+ * you configured, not wherever it points), and loses every header but
37
+ * content negotiation, credentials included.
38
+ * - BOUNDED RESULTS. A response larger than MAX_RESULT_CHARS is cut and
39
+ * says so; a network failure comes back as `{ error }`, never a throw.
40
+ *
41
+ * Spec files only, never URLs: a spec fetched at compile time would be a
42
+ * second outbound surface, and a spec that changes under a running agent
43
+ * changes its tools. Save the spec beside the YAML and review it like code.
44
+ */
45
+ import { existsSync, readFileSync } from 'node:fs';
46
+ import { extname, resolve } from 'node:path';
47
+ import { OpenAPIToolset, tokenToSchemeCredential } from '@google/adk';
48
+ import { blockedHostReason, checkHost } from '../net/addressGuard.js';
49
+ import { withRedirectGuard } from '../net/redirects.js';
50
+ /** Characters a single API result may carry into the conversation. */
51
+ export const MAX_RESULT_CHARS = 20_000;
52
+ /** Marks a tool built here, so require_approval may gate it (lib/compile.ts). */
53
+ export const OPENAPI_TOOL = Symbol.for('melchizedek.openapiTool');
54
+ /** snake_case, the way ADK names a tool from an operationId. */
55
+ export function toSnake(name) {
56
+ return name
57
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
58
+ .replace(/[^A-Za-z0-9]+/g, '_')
59
+ .replace(/^_+|_+$/g, '')
60
+ .toLowerCase();
61
+ }
62
+ /** True when a configured operation name (an operationId or a tool name) names this tool. */
63
+ export function namesTool(configured, tool) {
64
+ return configured === tool.name || configured === tool.operation?.operationId || toSnake(configured) === tool.name;
65
+ }
66
+ /**
67
+ * Why a server may not be called, or null. At compile time only the literal
68
+ * rules apply (no connection is made, and a build must not need DNS); before
69
+ * each call the name is resolved too, which guards the connection itself.
70
+ */
71
+ async function hostProblem(baseUrl, resolve) {
72
+ let url;
73
+ try {
74
+ url = new URL(baseUrl);
75
+ }
76
+ catch {
77
+ return `'${baseUrl}' is not a URL`;
78
+ }
79
+ if (!/^https?:$/.test(url.protocol))
80
+ return `'${baseUrl}' must be http(s)`;
81
+ if (process.env.ALLOW_PRIVATE_OPENAPI === 'true')
82
+ return null;
83
+ const reason = resolve ? await checkHost(url.hostname) : blockedHostReason(url.hostname);
84
+ return reason ? `refusing ${url.hostname}: ${reason} (set ALLOW_PRIVATE_OPENAPI=true for local development)` : null;
85
+ }
86
+ function credentialFor(auth, spec) {
87
+ if (!auth)
88
+ return {};
89
+ const read = (env) => {
90
+ const value = process.env[env]?.trim();
91
+ if (!value)
92
+ throw new Error(`openapi ${spec}: ${env} is not set (auth reads it from the environment)`);
93
+ return value;
94
+ };
95
+ if (auth.bearer_env) {
96
+ const [authScheme, authCredential] = tokenToSchemeCredential('oauth2Token', undefined, undefined, read(auth.bearer_env));
97
+ return { authScheme, authCredential };
98
+ }
99
+ if (auth.api_key) {
100
+ const [authScheme, authCredential] = tokenToSchemeCredential('apikey', auth.api_key.in, auth.api_key.name, read(auth.api_key.env));
101
+ return { authScheme, authCredential };
102
+ }
103
+ return {};
104
+ }
105
+ /**
106
+ * Where an API call may be redirected: the server's own rule on its origin,
107
+ * the full guard (no development exception) anywhere else.
108
+ */
109
+ const REDIRECTS = {
110
+ async hopProblem(url, crossOrigin) {
111
+ if (!/^https?:$/.test(url.protocol))
112
+ return `${url.protocol} is not http(s)`;
113
+ if (!crossOrigin)
114
+ return hostProblem(url.href, true);
115
+ const reason = await checkHost(url.hostname);
116
+ return reason ? `refusing ${url.hostname}: ${reason}` : null;
117
+ },
118
+ };
119
+ /** Cut an API result to MAX_RESULT_CHARS, saying so. */
120
+ export function boundResult(result) {
121
+ const text = typeof result === 'string' ? result : JSON.stringify(result ?? null);
122
+ if (text.length <= MAX_RESULT_CHARS)
123
+ return result;
124
+ return { truncated: true, text: `${text.slice(0, MAX_RESULT_CHARS)}… [cut at ${MAX_RESULT_CHARS} characters]` };
125
+ }
126
+ /**
127
+ * The tools one `openapi:` entry gives an agent. Throws (failing the compile)
128
+ * on a missing spec, an unset auth variable, an operation the spec does not
129
+ * have, or a server the guard refuses.
130
+ */
131
+ export async function buildOpenApiTools(entry, baseDir = process.cwd()) {
132
+ const specPath = resolve(baseDir, entry.spec);
133
+ if (!existsSync(specPath))
134
+ throw new Error(`openapi: spec not found: ${entry.spec}`);
135
+ const ext = extname(specPath).toLowerCase();
136
+ if (!['.yaml', '.yml', '.json'].includes(ext))
137
+ throw new Error(`openapi ${entry.spec}: a spec is .yaml, .yml or .json`);
138
+ const toolset = new OpenAPIToolset({
139
+ specStr: readFileSync(specPath, 'utf-8'),
140
+ specType: ext === '.json' ? 'json' : 'yaml',
141
+ ...(entry.prefix ? { prefix: entry.prefix } : {}),
142
+ ...credentialFor(entry.auth, entry.spec),
143
+ });
144
+ const all = (await toolset.getTools());
145
+ let chosen;
146
+ if (entry.operations?.length) {
147
+ const unknown = entry.operations.filter((op) => !all.some((t) => namesTool(op, t) || namesTool(op, { name: t.name.replace(`${entry.prefix}_`, ''), operation: t.operation })));
148
+ if (unknown.length) {
149
+ throw new Error(`openapi ${entry.spec}: no operation ${unknown.map((u) => `'${u}'`).join(', ')} (the spec has ${all.map((t) => t.operation?.operationId ?? t.name).join(', ')})`);
150
+ }
151
+ chosen = all.filter((t) => entry.operations.some((op) => namesTool(op, t) || namesTool(op, { name: t.name.replace(`${entry.prefix}_`, ''), operation: t.operation })));
152
+ }
153
+ else {
154
+ chosen = all.filter((t) => t.endpoint.method.toLowerCase() === 'get');
155
+ }
156
+ if (entry.base_url)
157
+ for (const t of chosen)
158
+ t.endpoint.baseUrl = entry.base_url;
159
+ for (const baseUrl of new Set(chosen.map((t) => t.endpoint.baseUrl))) {
160
+ if (!baseUrl)
161
+ throw new Error(`openapi ${entry.spec}: the spec names no server; set base_url`);
162
+ const problem = await hostProblem(baseUrl, false);
163
+ if (problem)
164
+ throw new Error(`openapi ${entry.spec}: ${problem}`);
165
+ }
166
+ return chosen.map(guarded);
167
+ }
168
+ /**
169
+ * A copy of a generated tool whose call re-checks its host, follows
170
+ * redirects only where the guard allows, catches a network failure, and
171
+ * bounds the result. The original is untouched.
172
+ */
173
+ function guarded(tool) {
174
+ const copy = Object.create(tool);
175
+ const original = tool.runAsync.bind(tool);
176
+ Object.defineProperty(copy, OPENAPI_TOOL, { value: true });
177
+ Object.defineProperty(copy, 'runAsync', {
178
+ value: async (request) => {
179
+ const problem = await hostProblem(tool.endpoint.baseUrl, true);
180
+ if (problem)
181
+ return { error: `${tool.name} was not called: ${problem}` };
182
+ try {
183
+ return boundResult(await withRedirectGuard(REDIRECTS, () => original(request)));
184
+ }
185
+ catch (err) {
186
+ return { error: `${tool.name} failed: ${err instanceof Error ? err.message : String(err)}` };
187
+ }
188
+ },
189
+ });
190
+ return copy;
191
+ }
192
+ /** True for a tool built from an `openapi:` entry. */
193
+ export function isOpenApiTool(tool) {
194
+ return !!tool && typeof tool === 'object' && tool[OPENAPI_TOOL] === true;
195
+ }