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
@@ -105,7 +105,7 @@ export declare function serverToolEvents(ev: any): Array<{
105
105
  }>;
106
106
  export declare function traceAgentRun(stream: AsyncIterableIterator<Event>, metadata: TraceMetadata): AsyncGenerator<Event, void, void>;
107
107
  export interface LlmCallMeta {
108
- /** Provider id, e.g. 'anthropic', 'openai', 'xai', 'ollama', 'gemini'. */
108
+ /** Provider id, e.g. 'anthropic', 'openai', 'xai', 'moonshot', 'ollama', 'gemini'. */
109
109
  provider: string;
110
110
  /** The model id as declared in the agent YAML (e.g. 'ollama/qwen3:8b'). */
111
111
  model: string;
@@ -9,12 +9,8 @@
9
9
  */
10
10
  import type { BaseSessionService, BaseMemoryService } from '@google/adk';
11
11
  import type { Embedder, MemoryExtractor } from '../memory/providers.ts';
12
- export interface RlsHardeningStatus {
13
- /** true only when db/hardening.sql has been applied and RLS is on for both tables. */
14
- applied: boolean;
15
- /** Human-readable explanation for logs. */
16
- detail: string;
17
- }
12
+ import type { RlsHardeningStatus } from '../storage/rlsStatus.ts';
13
+ export type { RlsHardeningStatus } from '../storage/rlsStatus.ts';
18
14
  export interface PersistenceServices {
19
15
  sessionService: BaseSessionService;
20
16
  memoryService: BaseMemoryService | undefined;
@@ -8,6 +8,7 @@
8
8
  * - Service construction (SupabaseSessionService, SupabaseVectorMemoryService)
9
9
  */
10
10
  import { isPlaceholderValue } from '../loadEnv.js';
11
+ import { evaluateRlsRows } from '../storage/rlsStatus.js';
11
12
  /**
12
13
  * Returns true if all required Supabase credentials are present in the
13
14
  * environment. Does NOT throw — callers use this to decide whether to
@@ -59,37 +60,7 @@ export async function createSupabaseServices(options) {
59
60
  detail: 'db/hardening.sql has not been applied (status function missing)',
60
61
  };
61
62
  }
62
- const rows = (data ?? []);
63
- const unprotected = ['adk_memory_facts', 'adk_sessions'].filter((t) => !rows.some((r) => r.table_name === t && r.rls_enabled));
64
- if (unprotected.length > 0) {
65
- return {
66
- applied: false,
67
- detail: `RLS is disabled on: ${unprotected.join(', ')}`,
68
- };
69
- }
70
- // adk_agent_registry is optional (only deployments that boot
71
- // `registry:<id>` create it) but NOT advisory: an unprotected registry
72
- // is agent takeover via the anon key, so a present-and-open table
73
- // fails the check outright. Absent table => no row => nothing to say.
74
- const registryRow = rows.find((r) => r.table_name === 'adk_agent_registry');
75
- if (registryRow && !registryRow.rls_enabled) {
76
- return {
77
- applied: false,
78
- detail: 'RLS is disabled on: adk_agent_registry (agent definitions are ' +
79
- 'writable with the anon key) — re-run db/hardening.sql',
80
- };
81
- }
82
- // Advisory only — adk_telemetry is optional (db/telemetry.sql). If it
83
- // exists but hardening.sql wasn't re-run afterwards, say so without
84
- // failing the check (the mandatory tables are protected).
85
- const telemetryRow = rows.find((r) => r.table_name === 'adk_telemetry');
86
- if (telemetryRow && !telemetryRow.rls_enabled) {
87
- return {
88
- applied: true,
89
- detail: 'RLS enabled on adk_memory_facts and adk_sessions — but NOT on adk_telemetry; re-run db/hardening.sql',
90
- };
91
- }
92
- return { applied: true, detail: 'RLS enabled on adk_memory_facts and adk_sessions' };
63
+ return evaluateRlsRows((data ?? []));
93
64
  }
94
65
  catch (err) {
95
66
  const msg = err instanceof Error ? err.message : String(err);
@@ -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
@@ -140,11 +145,20 @@ export interface DrainedRun {
140
145
  code: string;
141
146
  message: string;
142
147
  };
148
+ /** Workflow runs: questions `ask_user` nodes raised (`adk_request_input`). */
149
+ inputRequests: PendingInput[];
150
+ /** Workflow runs: errors nodes reported along the way (a retried attempt, a failed node). */
151
+ nodeErrors: Array<{
152
+ node: string;
153
+ code: string;
154
+ message: string;
155
+ }>;
143
156
  }
144
- export type TurnStage = 'classify' | 'dispatch' | 'delegate';
157
+ export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow';
145
158
  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';
159
+ /** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
160
+ * `answer`: the message answered the route's open `ask_user` question. */
161
+ decidedBy: 'override' | 'forced' | 'classifier' | 'approval' | 'answer';
148
162
  }
149
163
  export interface SyndicateTurnResult {
150
164
  /** completed: an answer was produced. failed: an error, a deadline or the
@@ -154,6 +168,9 @@ export interface SyndicateTurnResult {
154
168
  /** The call waiting for approval, when status is input-required. Answer it
155
169
  * with `approvalResponsePart(approval.id, approved)` as the next turn's part. */
156
170
  approval?: PendingApproval;
171
+ /** The question a workflow's `ask_user` node asked, when status is
172
+ * input-required. The next message on the conversation is the answer. */
173
+ input?: PendingInput;
157
174
  /** The text the user receives (relay fallback and guards applied). */
158
175
  text: string;
159
176
  error?: {
@@ -188,6 +205,20 @@ export interface TurnUsage {
188
205
  outputTokens: number;
189
206
  thinkingTokens: number;
190
207
  }
208
+ export declare const DEFAULT_MODEL_ERROR_RETRIES = 2;
209
+ export declare const DEFAULT_TOOL_ERROR_RETRIES = 3;
210
+ /**
211
+ * ADK's reflect-and-retry plugins, on by default. The model plugin turns a
212
+ * reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
213
+ * guidance instead of a failed turn; the tool plugin answers a tool that
214
+ * threw with structured guidance and caps its retries. Every retry is a
215
+ * model call under the turn's max_steps. Fresh instances per run: their
216
+ * counters are per invocation, but a run is the unit a surface reasons in.
217
+ */
218
+ export declare function retryPlugins(retries: {
219
+ model_errors?: number;
220
+ tool_errors?: number;
221
+ } | undefined): BasePlugin[];
191
222
  /**
192
223
  * Drains one ADK event stream into a DrainedRun. The answer is the LAST
193
224
  * event's text, not every event's: a tool-using agent narrates between
@@ -203,6 +234,13 @@ export declare function drainAgentStream(stream: AsyncIterable<Event>, opts?: {
203
234
  subagentNames?: Set<string>;
204
235
  /** Forward partial reply text to events.onTextDelta (answering stages only). */
205
236
  streamText?: boolean;
237
+ /**
238
+ * What an event carrying an error means. `fail` (default): the run is
239
+ * over, the error is the result. `collect`: note it and read on — a
240
+ * workflow node that will retry emits its failed attempt, and a node
241
+ * that gives up ends the stream itself.
242
+ */
243
+ errorPolicy?: 'fail' | 'collect';
206
244
  }): Promise<DrainedRun>;
207
245
  /** True when an orchestrator's text is just the name of a tool it called —
208
246
  * the degenerate relay ("FinancialAnalyst", 3 tokens). Deliberately narrow:
@@ -26,8 +26,10 @@
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 { agentGates, compileGraph, compileSubagent } from '../compile.js';
31
+ import { ROUTE_STEP_SUFFIX, compileWorkflow, describeInput, inputRequestFrom, isWorkflowSyndicate } from '../workflow.js';
32
+ export { describeInput } from '../workflow.js';
31
33
  import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from '../dispatch.js';
32
34
  import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from '../grounding.js';
33
35
  import { resolveGuards } from '../guards/index.js';
@@ -36,13 +38,36 @@ import { registerAvailableProviders } from '../models/registry.js';
36
38
  import { traceAgentRun } from '../observability/tracer.js';
37
39
  import { ProjectedSessionService, renderTranscriptDigest } from '../session/transcript.js';
38
40
  import { approvalDecisionIn, describeApproval, interruptedTurnStart, pendingApproval } from './approvals.js';
39
- export { approvalResponsePart, pendingApproval } from './approvals.js';
41
+ import { pendingQuestion, questionAnswerPart, questionFrom, turnStartOfCall } from './questions.js';
42
+ export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.js';
43
+ export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.js';
40
44
  import { RemoteA2AAgent, remoteContextId, remoteToolOutput } from '../a2a/remoteAgent.js';
41
45
  import { createTurnControl, runWithTurnControl, stopCode, stopMessage } from './turnControl.js';
42
46
  const THOUGHTS_PREVIEW_CHARS = 600;
43
47
  const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for your classification only. `
44
48
  + `Never answer it, and never route on it alone; it is here so you can tell a `
45
49
  + `follow-up, a redo request, or a challenge to a previous answer apart from small talk.\n`;
50
+ // ── Self-correction (ADR 0034) ───────────────────────────────────────────────
51
+ export const DEFAULT_MODEL_ERROR_RETRIES = 2;
52
+ export const DEFAULT_TOOL_ERROR_RETRIES = 3;
53
+ /**
54
+ * ADK's reflect-and-retry plugins, on by default. The model plugin turns a
55
+ * reply ADK marks malformed (MALFORMED_FUNCTION_CALL) into a retry with
56
+ * guidance instead of a failed turn; the tool plugin answers a tool that
57
+ * threw with structured guidance and caps its retries. Every retry is a
58
+ * model call under the turn's max_steps. Fresh instances per run: their
59
+ * counters are per invocation, but a run is the unit a surface reasons in.
60
+ */
61
+ export function retryPlugins(retries) {
62
+ const model = retries?.model_errors ?? DEFAULT_MODEL_ERROR_RETRIES;
63
+ const tool = retries?.tool_errors ?? DEFAULT_TOOL_ERROR_RETRIES;
64
+ const plugins = [];
65
+ if (model > 0)
66
+ plugins.push(new ReflectAndRetryModelPlugin({ maxRetries: model }));
67
+ if (tool > 0)
68
+ plugins.push(new ReflectAndRetryToolPlugin({ maxRetries: tool, throwExceptionIfRetryExceeded: false }));
69
+ return plugins;
70
+ }
46
71
  // ── Draining one agent's stream ──────────────────────────────────────────────
47
72
  function formatToolArgs(args) {
48
73
  if (!args || Object.keys(args).length === 0)
@@ -75,10 +100,13 @@ export async function drainAgentStream(stream, opts = {}) {
75
100
  thoughts: '',
76
101
  tokens: { input: 0, output: 0, thinking: 0 },
77
102
  eventCount: 0,
103
+ inputRequests: [],
104
+ nodeErrors: [],
78
105
  };
79
106
  const grounding = newGroundingState();
80
107
  let groundingAnnounced = false;
81
108
  let streamed = false;
109
+ let lastNode = '';
82
110
  for await (const event of stream) {
83
111
  const e = event;
84
112
  d.eventCount++;
@@ -95,8 +123,24 @@ export async function drainAgentStream(stream, opts = {}) {
95
123
  ev.onProgress?.('Invoking tool: web_search');
96
124
  groundingAnnounced = true;
97
125
  }
126
+ // A workflow event names its node; say when the graph moves on. The
127
+ // root (a path with no dot) and the hidden route steps are not nodes a
128
+ // person declared, so they stay out of the progress.
129
+ const nodePath = e.nodeInfo?.path;
130
+ if (nodePath && nodePath.includes('.') && e.author && !String(e.author).endsWith(ROUTE_STEP_SUFFIX) && e.author !== lastNode && e.partial !== true) {
131
+ lastNode = e.author;
132
+ ev.log?.(`⇢ Node: ${e.author}`);
133
+ if (opts.publishToolStatus)
134
+ ev.onProgress?.(`Running node: ${e.author}`);
135
+ }
98
136
  if ((e.errorCode || e.errorMessage) && e.errorCode !== 'STOP') {
99
- d.error = { code: String(e.errorCode ?? 'ERROR'), message: String(e.errorMessage ?? '') };
137
+ const error = { code: String(e.errorCode ?? 'ERROR'), message: String(e.errorMessage ?? '') };
138
+ if (opts.errorPolicy === 'collect') {
139
+ d.nodeErrors.push({ node: String(e.author ?? ''), ...error });
140
+ ev.warn?.(`Node ${e.author ?? '?'} reported [${error.code}]: ${error.message}`);
141
+ continue;
142
+ }
143
+ d.error = error;
100
144
  ev.warn?.(`Error [${d.error.code}]: ${d.error.message}`);
101
145
  break;
102
146
  }
@@ -123,6 +167,13 @@ export async function drainAgentStream(stream, opts = {}) {
123
167
  ev.onProgress?.(`Delegating to subagent: ${target}`);
124
168
  continue;
125
169
  }
170
+ const inputRequest = inputRequestFrom(e.author, call) ?? questionFrom(e.author, call);
171
+ if (inputRequest) {
172
+ // An ask_user node: the workflow waits for the person (lib/workflow.ts).
173
+ d.inputRequests.push(inputRequest);
174
+ ev.log?.(`⏸ ${describeInput(inputRequest)}`);
175
+ continue;
176
+ }
126
177
  if (!name)
127
178
  continue;
128
179
  d.invokedToolNames.add(name);
@@ -234,7 +285,7 @@ async function runTurnInner(opts, control) {
234
285
  const transform = opts.transformAgent ?? ((a) => a);
235
286
  const subagentNames = new Set((config.subagents ?? []).map((s) => s.name));
236
287
  const trace = opts.trace === false ? undefined : opts.trace ?? {};
237
- const parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
288
+ let parts = opts.parts.map((p) => (typeof p === 'string' ? { text: p } : p));
238
289
  const messageText = parts.map((p) => (typeof p.text === 'string' ? p.text : '')).join('\n');
239
290
  const result = {
240
291
  status: 'completed',
@@ -286,12 +337,39 @@ async function runTurnInner(opts, control) {
286
337
  // The log names the call, not its arguments: they are user content.
287
338
  ev.log?.(`✓ Approval ${decision.approved ? 'granted' : 'refused'}: ${resuming.agent} → ${resuming.tool}`);
288
339
  }
340
+ // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
341
+ // While an agent's `ask_user` call is open, a plain-text message is its
342
+ // answer: it becomes that call's response, and the agent that asked
343
+ // resumes its own tool loop. A workflow's pauses are ADK's own business.
344
+ let answering;
345
+ if (!decision && !isWorkflowSyndicate(config)) {
346
+ const question = pendingQuestion(existing?.events ?? []);
347
+ const plainText = parts.length > 0 && parts.every((p) => typeof p.text === 'string');
348
+ if (question && plainText) {
349
+ answering = { agent: question.node, id: question.id };
350
+ parts = [questionAnswerPart(question.id, messageText)];
351
+ ev.log?.(`✓ Answer to ${question.node}'s question`);
352
+ }
353
+ }
354
+ /** The agent a dispatch turn must resume, and the call its interrupted turn holds. */
355
+ const resumeTarget = resuming ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' } : answering ? { ...answering, why: 'answering its question' } : undefined;
289
356
  /** After the answering run: is a gated call now waiting? */
290
357
  const awaitingApproval = async (agentName) => {
291
358
  const after = await sessionService.getSession({ appName, userId, sessionId });
292
359
  const pending = pendingApproval(after?.events ?? []);
293
360
  return pending && pending.agent === agentName ? pending : undefined;
294
361
  };
362
+ /** After the answering run: did the agent ask the person something? */
363
+ const asked = (run) => {
364
+ const input = run.inputRequests[run.inputRequests.length - 1];
365
+ if (!input)
366
+ return undefined;
367
+ result.status = 'input-required';
368
+ result.input = input;
369
+ result.text = input.message;
370
+ ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
371
+ return finish();
372
+ };
295
373
  const pause = (pending) => {
296
374
  result.status = 'input-required';
297
375
  result.approval = pending;
@@ -305,6 +383,7 @@ async function runTurnInner(opts, control) {
305
383
  agent: params.agent,
306
384
  appName,
307
385
  sessionService: params.sessions,
386
+ plugins: retryPlugins(config.retries),
308
387
  ...(opts.memoryService ? { memoryService: opts.memoryService } : {}),
309
388
  });
310
389
  let stream = runner.runAsync({
@@ -342,6 +421,7 @@ async function runTurnInner(opts, control) {
342
421
  publishToolStatus: params.publishToolStatus,
343
422
  subagentNames,
344
423
  streamText: params.stage !== 'classify' && !guarded,
424
+ errorPolicy: params.errorPolicy,
345
425
  });
346
426
  }
347
427
  catch (err) {
@@ -359,16 +439,16 @@ async function runTurnInner(opts, control) {
359
439
  // Deterministic overrides first: when the message itself decides the
360
440
  // route there is nothing to classify, and the classifier call is skipped.
361
441
  const warnings = [];
362
- let resolution = resuming ? null : matchRouteOverride(messageText, config, warnings);
442
+ let resolution = resumeTarget ? null : matchRouteOverride(messageText, config, warnings);
363
443
  let decidedBy = 'override';
364
444
  for (const w of warnings)
365
445
  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.`);
446
+ if (resumeTarget) {
447
+ if (!subagentNames.has(resumeTarget.agent)) {
448
+ throw new Error(`Call ${resumeTarget.id} was raised by '${resumeTarget.agent}', which is not a route of this syndicate.`);
369
449
  }
370
- resolution = { route: resuming.agent, reason: 'resuming an approval', fellBack: false, fallbackReason: '', viaOverride: false };
371
- decidedBy = 'approval';
450
+ resolution = { route: resumeTarget.agent, reason: resumeTarget.why, fellBack: false, fallbackReason: '', viaOverride: false };
451
+ decidedBy = resuming ? 'approval' : 'answer';
372
452
  }
373
453
  else if (resolution) {
374
454
  ev.log?.(`⇄ Route pinned by override: ${resolution.route}`);
@@ -437,9 +517,13 @@ async function runTurnInner(opts, control) {
437
517
  agent: routeAgent,
438
518
  sid: sessionId,
439
519
  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) } : {}),
520
+ // Resuming an approval or answering a question: the interrupted turn
521
+ // is replayed raw, so ADK finds the call the message answers.
522
+ sessions: new ProjectedSessionService(sessionService, routeCfg.name, resuming
523
+ ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
524
+ : answering
525
+ ? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
526
+ : {}),
443
527
  stage: 'dispatch',
444
528
  route: resolution,
445
529
  publishToolStatus: true,
@@ -452,11 +536,14 @@ async function runTurnInner(opts, control) {
452
536
  result.error = answer.error;
453
537
  return finish();
454
538
  }
455
- if (!routeCfg.a2a_agent_url && routeCfg.require_approval?.length) {
539
+ if (!routeCfg.a2a_agent_url && agentGates(routeCfg)) {
456
540
  const pending = await awaitingApproval(routeCfg.name);
457
541
  if (pending)
458
542
  return pause(pending);
459
543
  }
544
+ const askedRoute = asked(answer);
545
+ if (askedRoute)
546
+ return askedRoute;
460
547
  result.text = answer.text;
461
548
  if (!result.text) {
462
549
  // Naming the route turns a blank reply into a lead (the XScout outage
@@ -466,6 +553,60 @@ async function runTurnInner(opts, control) {
466
553
  result.text = `${resolution.route} returned no output — the server logs carry the upstream error.`;
467
554
  }
468
555
  }
556
+ else if (isWorkflowSyndicate(config)) {
557
+ // ══ WORKFLOW ═════════════════════════════════════════════════════════
558
+ // The syndicate is a graph (lib/workflow.ts): every agent a node, run
559
+ // by ADK's Workflow in the shared session. A node agent sees only its
560
+ // input unless its YAML says otherwise, so no projection is needed. An
561
+ // `ask_user` node ends the turn input-required; the next message
562
+ // resumes the graph where it waited.
563
+ const compiled = await compileWorkflow(config, compileOpts, transform);
564
+ try {
565
+ answer = await runAgent({
566
+ agent: compiled.workflow,
567
+ sid: sessionId,
568
+ userParts: parts,
569
+ sessions: sessionService,
570
+ stage: 'workflow',
571
+ publishToolStatus: true,
572
+ errorPolicy: 'collect',
573
+ });
574
+ }
575
+ catch (err) {
576
+ // A node that gave up (its retries spent, a timeout, a schema it could
577
+ // not satisfy) ends the run by throwing; the last reported error names it.
578
+ const last = control.stopReason ? undefined : err;
579
+ if (!last)
580
+ throw err;
581
+ result.status = 'failed';
582
+ result.failedStage = 'workflow';
583
+ result.error = { code: 'NODE_FAILED', message: last.message };
584
+ return finish();
585
+ }
586
+ result.answer = answer;
587
+ if (answer.error || control.stopReason) {
588
+ result.status = 'failed';
589
+ result.failedStage = 'workflow';
590
+ result.error = answer.error;
591
+ return finish();
592
+ }
593
+ if (answer.inputRequests.length) {
594
+ const input = answer.inputRequests[answer.inputRequests.length - 1];
595
+ result.status = 'input-required';
596
+ result.input = input;
597
+ result.text = input.message;
598
+ ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
599
+ return finish();
600
+ }
601
+ result.text = answer.text;
602
+ if (!result.text) {
603
+ const failed = answer.nodeErrors[answer.nodeErrors.length - 1];
604
+ ev.warn?.(`The workflow produced no output${failed ? ` (last node error: ${failed.node} [${failed.code}] ${failed.message})` : ''}.`);
605
+ result.text = failed
606
+ ? `The workflow ended without an answer — ${failed.node} failed [${failed.code}]: ${failed.message}`
607
+ : 'The workflow ended without an answer.';
608
+ }
609
+ }
469
610
  else {
470
611
  // ══ DELEGATE ═════════════════════════════════════════════════════════
471
612
  // Subagents are AgentTools; the orchestrator relays the answer it got.
@@ -488,11 +629,14 @@ async function runTurnInner(opts, control) {
488
629
  result.error = answer.error;
489
630
  return finish();
490
631
  }
491
- if (config.orchestrator.require_approval?.length) {
632
+ if (agentGates(config.orchestrator)) {
492
633
  const pending = await awaitingApproval(config.orchestrator.name);
493
634
  if (pending)
494
635
  return pause(pending);
495
636
  }
637
+ const askedOrchestrator = asked(answer);
638
+ if (askedOrchestrator)
639
+ return askedOrchestrator;
496
640
  result.text = answer.text;
497
641
  // Failed-relay fallback: an orchestrator can botch the hop that relays a
498
642
  // specialist's answer — STOP with no text, or the bare tool NAME as its
@@ -563,6 +707,8 @@ async function runRemoteRoute(name, url, messageText, sessionId, control, ev) {
563
707
  }
564
708
  function emptyRun(error) {
565
709
  return {
710
+ inputRequests: [],
711
+ nodeErrors: [],
566
712
  text: '',
567
713
  lastToolResultText: '',
568
714
  toolResultTexts: [],
@@ -17,6 +17,7 @@ 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 { RlsHardeningStatus } from '../rlsStatus.ts';
20
21
  import type { TaskBackend } from '../../tools/taskTools.ts';
21
22
  import type { TurnLock } from '../../a2a/turnLock.ts';
22
23
  import type { PostgresTaskStoreOptions } from './taskStore.ts';
@@ -70,6 +71,11 @@ export interface PostgresStorage {
70
71
  erase: (scopeKey: string, options?: EraseOptions) => Promise<EraseCounts>;
71
72
  /** The highest migration recorded in melchizedek_schema_version; null if none. */
72
73
  schemaVersion: () => Promise<number | null>;
74
+ /**
75
+ * Whether db/hardening.sql is in force where an API could expose the
76
+ * tables (lib/storage/rlsStatus.ts). Never throws.
77
+ */
78
+ rlsHardening: () => Promise<RlsHardeningStatus>;
73
79
  /**
74
80
  * One turn at a time per conversation, across every instance on this
75
81
  * database: a session-level advisory lock on a dedicated connection, so a