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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * lib/runtime/questions.ts — an agent asks the person a question mid-turn,
3
+ * and the next message is the answer.
4
+ *
5
+ * WHY: A syndicate that is put in front of users needs to ask: "which of
6
+ * these three accounts?", "what is the order number?". Before this, an
7
+ * agent could only end its turn with a question in its text and hope the
8
+ * next message answered it, with nothing in the session saying a question
9
+ * was open. `ask_user` is ADK's long-running tool mechanism under one YAML
10
+ * name: the call is recorded, the run ends without a response to it, the
11
+ * turn ends `input-required` (`result.input`), and the next message on the
12
+ * conversation becomes the call's response, so the agent resumes its own
13
+ * tool loop where it asked, with the question and the answer side by side
14
+ * in its history.
15
+ *
16
+ * The same `input-required` path carries a workflow's `ask_user` node
17
+ * (lib/workflow.ts) and an approval (ADR 0028); a client handles all three
18
+ * by showing `result.input` (or the approval) and sending the next message.
19
+ *
20
+ * WHERE: only on an agent the turn runs directly (the orchestrator, or a
21
+ * plan-dispatch route), as with approval gates: inside a delegated
22
+ * subagent ADK swallows the pause. Not inside a workflow node yet. The
23
+ * schema refuses both.
24
+ */
25
+ import { LongRunningFunctionTool } from '@google/adk';
26
+ import type { Event } from '@google/adk';
27
+ import { z } from 'zod';
28
+ import type { PendingInput } from '../workflowConfig.ts';
29
+ /** The registry name, and the function-call name an open question carries. */
30
+ export declare const ASK_USER = "ask_user";
31
+ export declare const MAX_OPTIONS = 10;
32
+ /**
33
+ * The tool. Its execute returns nothing: a long-running call with no result
34
+ * ends the run, and ADK waits for a function response with the call's id.
35
+ */
36
+ export declare const askUserTool: LongRunningFunctionTool<z.ZodObject<{
37
+ question: z.ZodString;
38
+ options: z.ZodOptional<z.ZodArray<z.ZodString>>;
39
+ }, z.core.$strip>>;
40
+ /** The question an `ask_user` call carries, as the turn result reports it. */
41
+ export declare function questionFrom(author: string | undefined, call: {
42
+ name?: string;
43
+ id?: string;
44
+ args?: Record<string, unknown>;
45
+ }): PendingInput | undefined;
46
+ /**
47
+ * The question still waiting for an answer in a session, or undefined. A
48
+ * question the person moved past (a text message after it was answered)
49
+ * is not pending; an answered one carries a function response with its id.
50
+ */
51
+ export declare function pendingQuestion(events: readonly Event[]): PendingInput | undefined;
52
+ /** The message part that answers question `id` with the person's text. */
53
+ export declare function questionAnswerPart(id: string, answer: string): Record<string, unknown>;
54
+ /** Index of the user message that started the turn holding call `id` (replayed raw on a dispatch resume). */
55
+ export declare function turnStartOfCall(events: readonly Event[], id: string): number;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * lib/runtime/questions.ts — an agent asks the person a question mid-turn,
3
+ * and the next message is the answer.
4
+ *
5
+ * WHY: A syndicate that is put in front of users needs to ask: "which of
6
+ * these three accounts?", "what is the order number?". Before this, an
7
+ * agent could only end its turn with a question in its text and hope the
8
+ * next message answered it, with nothing in the session saying a question
9
+ * was open. `ask_user` is ADK's long-running tool mechanism under one YAML
10
+ * name: the call is recorded, the run ends without a response to it, the
11
+ * turn ends `input-required` (`result.input`), and the next message on the
12
+ * conversation becomes the call's response, so the agent resumes its own
13
+ * tool loop where it asked, with the question and the answer side by side
14
+ * in its history.
15
+ *
16
+ * The same `input-required` path carries a workflow's `ask_user` node
17
+ * (lib/workflow.ts) and an approval (ADR 0028); a client handles all three
18
+ * by showing `result.input` (or the approval) and sending the next message.
19
+ *
20
+ * WHERE: only on an agent the turn runs directly (the orchestrator, or a
21
+ * plan-dispatch route), as with approval gates: inside a delegated
22
+ * subagent ADK swallows the pause. Not inside a workflow node yet. The
23
+ * schema refuses both.
24
+ */
25
+ import { LongRunningFunctionTool } from '@google/adk';
26
+ import { z } from 'zod';
27
+ /** The registry name, and the function-call name an open question carries. */
28
+ export const ASK_USER = 'ask_user';
29
+ export const MAX_OPTIONS = 10;
30
+ /**
31
+ * The tool. Its execute returns nothing: a long-running call with no result
32
+ * ends the run, and ADK waits for a function response with the call's id.
33
+ */
34
+ export const askUserTool = new LongRunningFunctionTool({
35
+ name: ASK_USER,
36
+ description: 'Ask the person one question and wait for their answer before you continue: a missing detail you need, or a choice only they can make. ' +
37
+ 'Pass `options` when the answer is one of a few choices. The turn ends here; the person\'s next message is the answer, returned to you as this call\'s result. ' +
38
+ 'Ask only what you cannot find out or reasonably assume, one question per call.',
39
+ parameters: z.object({
40
+ question: z.string().min(1).max(500).describe('The question, as the person will read it.'),
41
+ options: z
42
+ .array(z.string().min(1).max(120))
43
+ .max(MAX_OPTIONS)
44
+ .optional()
45
+ .describe('The choices, when the answer is one of a few. Omit for a free answer.'),
46
+ }),
47
+ execute: async (_args, context) => {
48
+ if (context)
49
+ context.actions.skipSummarization = true;
50
+ return null;
51
+ },
52
+ });
53
+ const partsOf = (e) => (e.content?.parts ?? []);
54
+ const hasUserText = (e) => e.author === 'user' && partsOf(e).some((p) => typeof p.text === 'string' && p.text.trim() && !p.thought);
55
+ /** The question an `ask_user` call carries, as the turn result reports it. */
56
+ export function questionFrom(author, call) {
57
+ if (call.name !== ASK_USER || !call.id)
58
+ return undefined;
59
+ const args = call.args ?? {};
60
+ const options = Array.isArray(args.options) ? args.options.filter((o) => typeof o === 'string') : [];
61
+ return {
62
+ id: call.id,
63
+ node: author ?? '',
64
+ message: typeof args.question === 'string' ? args.question : '',
65
+ ...(options.length ? { payload: { options } } : {}),
66
+ };
67
+ }
68
+ /**
69
+ * The question still waiting for an answer in a session, or undefined. A
70
+ * question the person moved past (a text message after it was answered)
71
+ * is not pending; an answered one carries a function response with its id.
72
+ */
73
+ export function pendingQuestion(events) {
74
+ const answered = new Set();
75
+ for (let i = events.length - 1; i >= 0; i--) {
76
+ const e = events[i];
77
+ if (hasUserText(e))
78
+ return undefined;
79
+ for (const p of partsOf(e)) {
80
+ if (p.functionResponse?.name === ASK_USER && p.functionResponse.id)
81
+ answered.add(p.functionResponse.id);
82
+ }
83
+ for (const p of partsOf(e)) {
84
+ const call = p.functionCall;
85
+ if (call?.name !== ASK_USER || !call.id || answered.has(call.id))
86
+ continue;
87
+ return questionFrom(e.author, call);
88
+ }
89
+ }
90
+ return undefined;
91
+ }
92
+ /** The message part that answers question `id` with the person's text. */
93
+ export function questionAnswerPart(id, answer) {
94
+ return { functionResponse: { id, name: ASK_USER, response: { result: answer } } };
95
+ }
96
+ /** Index of the user message that started the turn holding call `id` (replayed raw on a dispatch resume). */
97
+ export function turnStartOfCall(events, id) {
98
+ let callAt = -1;
99
+ for (let i = events.length - 1; i >= 0 && callAt === -1; i--) {
100
+ if (partsOf(events[i]).some((p) => p.functionCall?.id === id))
101
+ callAt = i;
102
+ }
103
+ for (let i = callAt; i >= 0; i--)
104
+ if (hasUserText(events[i]))
105
+ return i;
106
+ return Math.max(0, callAt);
107
+ }
@@ -26,12 +26,17 @@
26
26
  * time, and an outer AbortSignal cancels it. All three abort the provider
27
27
  * request in flight, not just the loop around it.
28
28
  */
29
+ import type { BasePlugin } from '@google/adk';
29
30
  import type { BaseMemoryService, BaseSessionService, Event, LlmAgent } from '@google/adk';
31
+ import type { PendingInput } from '../workflow.ts';
32
+ export { describeInput } from '../workflow.ts';
33
+ export type { PendingInput } from '../workflow.ts';
30
34
  import type { CompileOptions } from '../compile.ts';
31
35
  import type { RouteResolution } from '../dispatch.ts';
32
36
  import type { SyndicateYamlConfig } from '../loadSyndicate.ts';
37
+ export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.ts';
33
38
  import type { PendingApproval } from './approvals.ts';
34
- export { approvalResponsePart, pendingApproval } from './approvals.ts';
39
+ export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.ts';
35
40
  export type { PendingApproval } from './approvals.ts';
36
41
  import type { TurnStopReason } from './turnControl.ts';
37
42
  /** One part of the user's message. Text is the common case; the genai part
@@ -72,6 +77,8 @@ export interface TraceOptions {
72
77
  traceId: string;
73
78
  spanId: string;
74
79
  }) => void;
80
+ /** A caller's W3C traceparent: the turn's root span links to it (never joins it). */
81
+ traceparent?: string;
75
82
  }
76
83
  export interface SyndicateTurnOptions {
77
84
  config: SyndicateYamlConfig;
@@ -95,7 +102,7 @@ export interface SyndicateTurnOptions {
95
102
  signal?: AbortSignal;
96
103
  /** Wall-clock budget for the turn, in ms. Default: none. */
97
104
  deadlineMs?: number;
98
- /** Model-call ceiling for the whole turn. Default: the YAML's `max_steps`. */
105
+ /** Model-call ceiling for the whole turn. Default: the YAML's `max_steps`, else DEFAULT_MAX_STEPS (50). */
99
106
  maxLlmCalls?: number;
100
107
  /** Ask adapters for token-by-token partial events (the REPL's live view). */
101
108
  streaming?: boolean;
@@ -140,11 +147,20 @@ export interface DrainedRun {
140
147
  code: string;
141
148
  message: string;
142
149
  };
150
+ /** Workflow runs: questions `ask_user` nodes raised (`adk_request_input`). */
151
+ inputRequests: PendingInput[];
152
+ /** Workflow runs: errors nodes reported along the way (a retried attempt, a failed node). */
153
+ nodeErrors: Array<{
154
+ node: string;
155
+ code: string;
156
+ message: string;
157
+ }>;
143
158
  }
144
- export type TurnStage = 'classify' | 'dispatch' | 'delegate';
159
+ export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow';
145
160
  export interface RouteDecision extends RouteResolution {
146
- /** `approval`: the turn resumed the route that asked for an approval (ADR 0028). */
147
- decidedBy: 'override' | 'forced' | 'classifier' | 'approval';
161
+ /** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
162
+ * `answer`: the message answered the route's open `ask_user` question. */
163
+ decidedBy: 'override' | 'forced' | 'classifier' | 'approval' | 'answer';
148
164
  }
149
165
  export interface SyndicateTurnResult {
150
166
  /** completed: an answer was produced. failed: an error, a deadline or the
@@ -154,6 +170,9 @@ export interface SyndicateTurnResult {
154
170
  /** The call waiting for approval, when status is input-required. Answer it
155
171
  * with `approvalResponsePart(approval.id, approved)` as the next turn's part. */
156
172
  approval?: PendingApproval;
173
+ /** The question a workflow's `ask_user` node asked, when status is
174
+ * input-required. The next message on the conversation is the answer. */
175
+ input?: PendingInput;
157
176
  /** The text the user receives (relay fallback and guards applied). */
158
177
  text: string;
159
178
  error?: {
@@ -188,6 +207,20 @@ export interface TurnUsage {
188
207
  outputTokens: number;
189
208
  thinkingTokens: number;
190
209
  }
210
+ export declare const DEFAULT_MODEL_ERROR_RETRIES = 2;
211
+ export declare const DEFAULT_TOOL_ERROR_RETRIES = 3;
212
+ /**
213
+ * ADK's reflect-and-retry plugins, on by default. The model plugin turns a
214
+ * reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
215
+ * guidance instead of a failed turn; the tool plugin answers a tool that
216
+ * threw with structured guidance and caps its retries. Every retry is a
217
+ * model call under the turn's max_steps. Fresh instances per run: their
218
+ * counters are per invocation, but a run is the unit a surface reasons in.
219
+ */
220
+ export declare function retryPlugins(retries: {
221
+ model_errors?: number;
222
+ tool_errors?: number;
223
+ } | undefined): BasePlugin[];
191
224
  /**
192
225
  * Drains one ADK event stream into a DrainedRun. The answer is the LAST
193
226
  * event's text, not every event's: a tool-using agent narrates between
@@ -203,6 +236,13 @@ export declare function drainAgentStream(stream: AsyncIterable<Event>, opts?: {
203
236
  subagentNames?: Set<string>;
204
237
  /** Forward partial reply text to events.onTextDelta (answering stages only). */
205
238
  streamText?: boolean;
239
+ /**
240
+ * What an event carrying an error means. `fail` (default): the run is
241
+ * over, the error is the result. `collect`: note it and read on — a
242
+ * workflow node that will retry emits its failed attempt, and a node
243
+ * that gives up ends the stream itself.
244
+ */
245
+ errorPolicy?: 'fail' | 'collect';
206
246
  }): Promise<DrainedRun>;
207
247
  /** True when an orchestrator's text is just the name of a tool it called —
208
248
  * the degenerate relay ("FinancialAnalyst", 3 tokens). Deliberately narrow:
@@ -26,8 +26,11 @@
26
26
  * time, and an outer AbortSignal cancels it. All three abort the provider
27
27
  * request in flight, not just the loop around it.
28
28
  */
29
- import { InMemorySessionService, Runner, StreamingMode, getFunctionCalls, getFunctionResponses } from '@google/adk';
30
- import { compileGraph, compileSubagent } from '../compile.js';
29
+ import { InMemorySessionService, ReflectAndRetryModelPlugin, ReflectAndRetryToolPlugin, Runner, StreamingMode, getFunctionCalls, getFunctionResponses } from '@google/adk';
30
+ import { DEFAULT_MAX_STEPS } from '../config.js';
31
+ import { agentGates, compileGraph, compileSubagent } from '../compile.js';
32
+ import { ROUTE_STEP_SUFFIX, compileWorkflow, describeInput, inputRequestFrom, isWorkflowSyndicate } from '../workflow.js';
33
+ export { describeInput } from '../workflow.js';
31
34
  import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from '../dispatch.js';
32
35
  import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from '../grounding.js';
33
36
  import { resolveGuards } from '../guards/index.js';
@@ -36,13 +39,36 @@ import { registerAvailableProviders } from '../models/registry.js';
36
39
  import { traceAgentRun } from '../observability/tracer.js';
37
40
  import { ProjectedSessionService, renderTranscriptDigest } from '../session/transcript.js';
38
41
  import { approvalDecisionIn, describeApproval, interruptedTurnStart, pendingApproval } from './approvals.js';
39
- export { approvalResponsePart, pendingApproval } from './approvals.js';
42
+ import { pendingQuestion, questionAnswerPart, questionFrom, turnStartOfCall } from './questions.js';
43
+ export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.js';
44
+ export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.js';
40
45
  import { RemoteA2AAgent, remoteContextId, remoteToolOutput } from '../a2a/remoteAgent.js';
41
46
  import { createTurnControl, runWithTurnControl, stopCode, stopMessage } from './turnControl.js';
42
47
  const THOUGHTS_PREVIEW_CHARS = 600;
43
48
  const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for your classification only. `
44
49
  + `Never answer it, and never route on it alone; it is here so you can tell a `
45
50
  + `follow-up, a redo request, or a challenge to a previous answer apart from small talk.\n`;
51
+ // ── Self-correction (ADR 0034) ───────────────────────────────────────────────
52
+ export const DEFAULT_MODEL_ERROR_RETRIES = 2;
53
+ export const DEFAULT_TOOL_ERROR_RETRIES = 3;
54
+ /**
55
+ * ADK's reflect-and-retry plugins, on by default. The model plugin turns a
56
+ * reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
57
+ * guidance instead of a failed turn; the tool plugin answers a tool that
58
+ * threw with structured guidance and caps its retries. Every retry is a
59
+ * model call under the turn's max_steps. Fresh instances per run: their
60
+ * counters are per invocation, but a run is the unit a surface reasons in.
61
+ */
62
+ export function retryPlugins(retries) {
63
+ const model = retries?.model_errors ?? DEFAULT_MODEL_ERROR_RETRIES;
64
+ const tool = retries?.tool_errors ?? DEFAULT_TOOL_ERROR_RETRIES;
65
+ const plugins = [];
66
+ if (model > 0)
67
+ plugins.push(new ReflectAndRetryModelPlugin({ maxRetries: model }));
68
+ if (tool > 0)
69
+ plugins.push(new ReflectAndRetryToolPlugin({ maxRetries: tool, throwExceptionIfRetryExceeded: false }));
70
+ return plugins;
71
+ }
46
72
  // ── Draining one agent's stream ──────────────────────────────────────────────
47
73
  function formatToolArgs(args) {
48
74
  if (!args || Object.keys(args).length === 0)
@@ -75,10 +101,13 @@ export async function drainAgentStream(stream, opts = {}) {
75
101
  thoughts: '',
76
102
  tokens: { input: 0, output: 0, thinking: 0 },
77
103
  eventCount: 0,
104
+ inputRequests: [],
105
+ nodeErrors: [],
78
106
  };
79
107
  const grounding = newGroundingState();
80
108
  let groundingAnnounced = false;
81
109
  let streamed = false;
110
+ let lastNode = '';
82
111
  for await (const event of stream) {
83
112
  const e = event;
84
113
  d.eventCount++;
@@ -95,8 +124,24 @@ export async function drainAgentStream(stream, opts = {}) {
95
124
  ev.onProgress?.('Invoking tool: web_search');
96
125
  groundingAnnounced = true;
97
126
  }
127
+ // A workflow event names its node; say when the graph moves on. The
128
+ // root (a path with no dot) and the hidden route steps are not nodes a
129
+ // person declared, so they stay out of the progress.
130
+ const nodePath = e.nodeInfo?.path;
131
+ if (nodePath && nodePath.includes('.') && e.author && !String(e.author).endsWith(ROUTE_STEP_SUFFIX) && e.author !== lastNode && e.partial !== true) {
132
+ lastNode = e.author;
133
+ ev.log?.(`⇢ Node: ${e.author}`);
134
+ if (opts.publishToolStatus)
135
+ ev.onProgress?.(`Running node: ${e.author}`);
136
+ }
98
137
  if ((e.errorCode || e.errorMessage) && e.errorCode !== 'STOP') {
99
- d.error = { code: String(e.errorCode ?? 'ERROR'), message: String(e.errorMessage ?? '') };
138
+ const error = { code: String(e.errorCode ?? 'ERROR'), message: String(e.errorMessage ?? '') };
139
+ if (opts.errorPolicy === 'collect') {
140
+ d.nodeErrors.push({ node: String(e.author ?? ''), ...error });
141
+ ev.warn?.(`Node ${e.author ?? '?'} reported [${error.code}]: ${error.message}`);
142
+ continue;
143
+ }
144
+ d.error = error;
100
145
  ev.warn?.(`Error [${d.error.code}]: ${d.error.message}`);
101
146
  break;
102
147
  }
@@ -123,6 +168,13 @@ export async function drainAgentStream(stream, opts = {}) {
123
168
  ev.onProgress?.(`Delegating to subagent: ${target}`);
124
169
  continue;
125
170
  }
171
+ const inputRequest = inputRequestFrom(e.author, call) ?? questionFrom(e.author, call);
172
+ if (inputRequest) {
173
+ // An ask_user node: the workflow waits for the person (lib/workflow.ts).
174
+ d.inputRequests.push(inputRequest);
175
+ ev.log?.(`⏸ ${describeInput(inputRequest)}`);
176
+ continue;
177
+ }
126
178
  if (!name)
127
179
  continue;
128
180
  d.invokedToolNames.add(name);
@@ -214,7 +266,7 @@ export async function runSyndicateTurn(opts) {
214
266
  if (!opts.compile?.resolveModel)
215
267
  registerAvailableProviders();
216
268
  const control = createTurnControl({
217
- maxLlmCalls: opts.maxLlmCalls ?? config.max_steps,
269
+ maxLlmCalls: opts.maxLlmCalls ?? config.max_steps ?? DEFAULT_MAX_STEPS,
218
270
  deadlineMs: opts.deadlineMs,
219
271
  signal: opts.signal,
220
272
  });
@@ -234,7 +286,7 @@ async function runTurnInner(opts, control) {
234
286
  const transform = opts.transformAgent ?? ((a) => a);
235
287
  const subagentNames = new Set((config.subagents ?? []).map((s) => s.name));
236
288
  const trace = opts.trace === false ? undefined : opts.trace ?? {};
237
- const parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
289
+ let parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
238
290
  const messageText = parts.map((p) => (typeof p.text === 'string' ? p.text : '')).join('\n');
239
291
  const result = {
240
292
  status: 'completed',
@@ -286,12 +338,39 @@ async function runTurnInner(opts, control) {
286
338
  // The log names the call, not its arguments: they are user content.
287
339
  ev.log?.(`✓ Approval ${decision.approved ? 'granted' : 'refused'}: ${resuming.agent} → ${resuming.tool}`);
288
340
  }
341
+ // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
342
+ // While an agent's `ask_user` call is open, a plain-text message is its
343
+ // answer: it becomes that call's response, and the agent that asked
344
+ // resumes its own tool loop. A workflow's pauses are ADK's own business.
345
+ let answering;
346
+ if (!decision && !isWorkflowSyndicate(config)) {
347
+ const question = pendingQuestion(existing?.events ?? []);
348
+ const plainText = parts.length > 0 && parts.every((p) => typeof p.text === 'string');
349
+ if (question && plainText) {
350
+ answering = { agent: question.node, id: question.id };
351
+ parts = [questionAnswerPart(question.id, messageText)];
352
+ ev.log?.(`✓ Answer to ${question.node}'s question`);
353
+ }
354
+ }
355
+ /** The agent a dispatch turn must resume, and the call its interrupted turn holds. */
356
+ const resumeTarget = resuming ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' } : answering ? { ...answering, why: 'answering its question' } : undefined;
289
357
  /** After the answering run: is a gated call now waiting? */
290
358
  const awaitingApproval = async (agentName) => {
291
359
  const after = await sessionService.getSession({ appName, userId, sessionId });
292
360
  const pending = pendingApproval(after?.events ?? []);
293
361
  return pending && pending.agent === agentName ? pending : undefined;
294
362
  };
363
+ /** After the answering run: did the agent ask the person something? */
364
+ const asked = (run) => {
365
+ const input = run.inputRequests[run.inputRequests.length - 1];
366
+ if (!input)
367
+ return undefined;
368
+ result.status = 'input-required';
369
+ result.input = input;
370
+ result.text = input.message;
371
+ ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
372
+ return finish();
373
+ };
295
374
  const pause = (pending) => {
296
375
  result.status = 'input-required';
297
376
  result.approval = pending;
@@ -305,6 +384,7 @@ async function runTurnInner(opts, control) {
305
384
  agent: params.agent,
306
385
  appName,
307
386
  sessionService: params.sessions,
387
+ plugins: retryPlugins(config.retries),
308
388
  ...(opts.memoryService ? { memoryService: opts.memoryService } : {}),
309
389
  });
310
390
  let stream = runner.runAsync({
@@ -329,6 +409,7 @@ async function runTurnInner(opts, control) {
329
409
  configHash: trace.configHash,
330
410
  attributes: trace.attributes,
331
411
  onSpanStart: trace.onSpanStart,
412
+ traceparent: trace.traceparent,
332
413
  onEnd: () => ({
333
414
  'syndicate.relay_fallback': params.stage === 'delegate' && !!params.relayFallback?.(),
334
415
  ...(control.stopReason ? { 'syndicate.stop_reason': control.stopReason } : {}),
@@ -342,6 +423,7 @@ async function runTurnInner(opts, control) {
342
423
  publishToolStatus: params.publishToolStatus,
343
424
  subagentNames,
344
425
  streamText: params.stage !== 'classify' && !guarded,
426
+ errorPolicy: params.errorPolicy,
345
427
  });
346
428
  }
347
429
  catch (err) {
@@ -359,16 +441,16 @@ async function runTurnInner(opts, control) {
359
441
  // Deterministic overrides first: when the message itself decides the
360
442
  // route there is nothing to classify, and the classifier call is skipped.
361
443
  const warnings = [];
362
- let resolution = resuming ? null : matchRouteOverride(messageText, config, warnings);
444
+ let resolution = resumeTarget ? null : matchRouteOverride(messageText, config, warnings);
363
445
  let decidedBy = 'override';
364
446
  for (const w of warnings)
365
447
  ev.warn?.(w);
366
- if (resuming) {
367
- if (!subagentNames.has(resuming.agent)) {
368
- throw new Error(`Approval ${resuming.id} was raised by '${resuming.agent}', which is not a route of this syndicate.`);
448
+ if (resumeTarget) {
449
+ if (!subagentNames.has(resumeTarget.agent)) {
450
+ throw new Error(`Call ${resumeTarget.id} was raised by '${resumeTarget.agent}', which is not a route of this syndicate.`);
369
451
  }
370
- resolution = { route: resuming.agent, reason: 'resuming an approval', fellBack: false, fallbackReason: '', viaOverride: false };
371
- decidedBy = 'approval';
452
+ resolution = { route: resumeTarget.agent, reason: resumeTarget.why, fellBack: false, fallbackReason: '', viaOverride: false };
453
+ decidedBy = resuming ? 'approval' : 'answer';
372
454
  }
373
455
  else if (resolution) {
374
456
  ev.log?.(`⇄ Route pinned by override: ${resolution.route}`);
@@ -437,9 +519,13 @@ async function runTurnInner(opts, control) {
437
519
  agent: routeAgent,
438
520
  sid: sessionId,
439
521
  userParts: parts,
440
- // Resuming an approval: the interrupted turn is replayed raw, so ADK
441
- // finds the call the approval answers (ADR 0028).
442
- sessions: new ProjectedSessionService(sessionService, routeCfg.name, resuming ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) } : {}),
522
+ // Resuming an approval or answering a question: the interrupted turn
523
+ // is replayed raw, so ADK finds the call the message answers.
524
+ sessions: new ProjectedSessionService(sessionService, routeCfg.name, resuming
525
+ ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
526
+ : answering
527
+ ? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
528
+ : {}),
443
529
  stage: 'dispatch',
444
530
  route: resolution,
445
531
  publishToolStatus: true,
@@ -452,11 +538,14 @@ async function runTurnInner(opts, control) {
452
538
  result.error = answer.error;
453
539
  return finish();
454
540
  }
455
- if (!routeCfg.a2a_agent_url && routeCfg.require_approval?.length) {
541
+ if (!routeCfg.a2a_agent_url && agentGates(routeCfg)) {
456
542
  const pending = await awaitingApproval(routeCfg.name);
457
543
  if (pending)
458
544
  return pause(pending);
459
545
  }
546
+ const askedRoute = asked(answer);
547
+ if (askedRoute)
548
+ return askedRoute;
460
549
  result.text = answer.text;
461
550
  if (!result.text) {
462
551
  // Naming the route turns a blank reply into a lead (the XScout outage
@@ -466,6 +555,60 @@ async function runTurnInner(opts, control) {
466
555
  result.text = `${resolution.route} returned no output — the server logs carry the upstream error.`;
467
556
  }
468
557
  }
558
+ else if (isWorkflowSyndicate(config)) {
559
+ // ══ WORKFLOW ═════════════════════════════════════════════════════════
560
+ // The syndicate is a graph (lib/workflow.ts): every agent a node, run
561
+ // by ADK's Workflow in the shared session. A node agent sees only its
562
+ // input unless its YAML says otherwise, so no projection is needed. An
563
+ // `ask_user` node ends the turn input-required; the next message
564
+ // resumes the graph where it waited.
565
+ const compiled = await compileWorkflow(config, compileOpts, transform);
566
+ try {
567
+ answer = await runAgent({
568
+ agent: compiled.workflow,
569
+ sid: sessionId,
570
+ userParts: parts,
571
+ sessions: sessionService,
572
+ stage: 'workflow',
573
+ publishToolStatus: true,
574
+ errorPolicy: 'collect',
575
+ });
576
+ }
577
+ catch (err) {
578
+ // A node that gave up (its retries spent, a timeout, a schema it could
579
+ // not satisfy) ends the run by throwing; the last reported error names it.
580
+ const last = control.stopReason ? undefined : err;
581
+ if (!last)
582
+ throw err;
583
+ result.status = 'failed';
584
+ result.failedStage = 'workflow';
585
+ result.error = { code: 'NODE_FAILED', message: last.message };
586
+ return finish();
587
+ }
588
+ result.answer = answer;
589
+ if (answer.error || control.stopReason) {
590
+ result.status = 'failed';
591
+ result.failedStage = 'workflow';
592
+ result.error = answer.error;
593
+ return finish();
594
+ }
595
+ if (answer.inputRequests.length) {
596
+ const input = answer.inputRequests[answer.inputRequests.length - 1];
597
+ result.status = 'input-required';
598
+ result.input = input;
599
+ result.text = input.message;
600
+ ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
601
+ return finish();
602
+ }
603
+ result.text = answer.text;
604
+ if (!result.text) {
605
+ const failed = answer.nodeErrors[answer.nodeErrors.length - 1];
606
+ ev.warn?.(`The workflow produced no output${failed ? ` (last node error: ${failed.node} [${failed.code}] ${failed.message})` : ''}.`);
607
+ result.text = failed
608
+ ? `The workflow ended without an answer — ${failed.node} failed [${failed.code}]: ${failed.message}`
609
+ : 'The workflow ended without an answer.';
610
+ }
611
+ }
469
612
  else {
470
613
  // ══ DELEGATE ═════════════════════════════════════════════════════════
471
614
  // Subagents are AgentTools; the orchestrator relays the answer it got.
@@ -488,11 +631,14 @@ async function runTurnInner(opts, control) {
488
631
  result.error = answer.error;
489
632
  return finish();
490
633
  }
491
- if (config.orchestrator.require_approval?.length) {
634
+ if (agentGates(config.orchestrator)) {
492
635
  const pending = await awaitingApproval(config.orchestrator.name);
493
636
  if (pending)
494
637
  return pause(pending);
495
638
  }
639
+ const askedOrchestrator = asked(answer);
640
+ if (askedOrchestrator)
641
+ return askedOrchestrator;
496
642
  result.text = answer.text;
497
643
  // Failed-relay fallback: an orchestrator can botch the hop that relays a
498
644
  // specialist's answer — STOP with no text, or the bare tool NAME as its
@@ -563,6 +709,8 @@ async function runRemoteRoute(name, url, messageText, sessionId, control, ev) {
563
709
  }
564
710
  function emptyRun(error) {
565
711
  return {
712
+ inputRequests: [],
713
+ nodeErrors: [],
566
714
  text: '',
567
715
  lastToolResultText: '',
568
716
  toolResultTexts: [],
@@ -17,6 +17,8 @@ import type { Embedder, MemoryExtractor } from '../../memory/providers.ts';
17
17
  import type { EraseCounts, EraseOptions } from '../../memory/erase.ts';
18
18
  import { PostgresSessionService } from './sessionService.ts';
19
19
  import { PostgresTaskStore } from './taskStore.ts';
20
+ import type { AuditSink } from '../../observability/audit.ts';
21
+ import type { RlsHardeningStatus } from '../rlsStatus.ts';
20
22
  import type { TaskBackend } from '../../tools/taskTools.ts';
21
23
  import type { TurnLock } from '../../a2a/turnLock.ts';
22
24
  import type { PostgresTaskStoreOptions } from './taskStore.ts';
@@ -70,6 +72,13 @@ export interface PostgresStorage {
70
72
  erase: (scopeKey: string, options?: EraseOptions) => Promise<EraseCounts>;
71
73
  /** The highest migration recorded in melchizedek_schema_version; null if none. */
72
74
  schemaVersion: () => Promise<number | null>;
75
+ /**
76
+ * Whether db/hardening.sql is in force where an API could expose the
77
+ * tables (lib/storage/rlsStatus.ts). Never throws.
78
+ */
79
+ rlsHardening: () => Promise<RlsHardeningStatus>;
80
+ /** Appends to melchizedek_audit (migration 0012, ADR 0042). */
81
+ audit: AuditSink;
73
82
  /**
74
83
  * One turn at a time per conversation, across every instance on this
75
84
  * database: a session-level advisory lock on a dedicated connection, so a
@@ -19,6 +19,8 @@ import { PostgresSessionService } from './sessionService.js';
19
19
  import { PostgresTaskStore, reapExpiredTasks, renewTaskLeases } from './taskStore.js';
20
20
  import { postgresTaskBackend } from './taskQueue.js';
21
21
  import { searchPathOption } from '../schema.js';
22
+ import { POSTGRES_RLS_QUERY, evaluatePostgresRls } from '../rlsStatus.js';
23
+ import { postgresAuditSink } from '../../observability/audit.js';
22
24
  import { createHash, randomUUID } from 'node:crypto';
23
25
  import { hostname } from 'node:os';
24
26
  export { PostgresSessionService } from './sessionService.js';
@@ -121,6 +123,17 @@ export function postgresStorage(options) {
121
123
  throw err;
122
124
  }
123
125
  },
126
+ audit: postgresAuditSink(pool),
127
+ async rlsHardening() {
128
+ try {
129
+ const r = await pool.query(POSTGRES_RLS_QUERY);
130
+ const row = r.rows[0];
131
+ return evaluatePostgresRls({ apiRoles: row.api_roles, schema: row.schema, rows: row.rows });
132
+ }
133
+ catch (err) {
134
+ return { applied: false, detail: `hardening check failed: ${err instanceof Error ? err.message : String(err)}` };
135
+ }
136
+ },
124
137
  async erase(scopeKey, eraseOptions = {}) {
125
138
  if (!scopeKey?.trim())
126
139
  throw new Error('erase: a scope key is required');