melchizedek-agents 1.3.1 → 1.4.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.
@@ -70,7 +70,7 @@ import { traceAgentRun } from '../observability/tracer.js';
70
70
  import { ProjectedSessionService, renderTranscriptDigest } from '../session/transcript.js';
71
71
  import { approvalDecisionIn, describeApproval, interruptedTurnStart, pendingApproval } from './approvals.js';
72
72
  import { pendingQuestion, questionAnswerPart, questionFrom, turnStartOfCall } from './questions.js';
73
- import { deepestPause, delegatedPauses, nestedWorkflowPause, routePause } from './native/interrupts.js';
73
+ import { deepestPause, delegatedPauses, nestedWorkflowPause, routePause, walkConsents } from './native/interrupts.js';
74
74
  import { consentPinnedTo, entrySession } from './native/delegate.js';
75
75
  import { workflowPauseEvent } from '../workflow/pause.js';
76
76
  import { INPUT_REQUEST } from '../workflowConfig.js';
@@ -485,6 +485,15 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
485
485
  ev.log?.(`⏸ Authorization needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.provider}`);
486
486
  return finish();
487
487
  };
488
+ /** Whether the grant a consent request waits for is stored (an expired or unreadable one is not). */
489
+ const grantStored = async (open) => {
490
+ try {
491
+ return !!open.provider && !!(await credentialStore?.get({ appName: credentialApp, userId, provider: open.provider }));
492
+ }
493
+ catch {
494
+ return false;
495
+ }
496
+ };
488
497
  let granting;
489
498
  // A call waiting for a grant inside a delegated subagent (ADR 0118): the grant's answer is stored here, and the open call carries it down.
490
499
  let grantingBelow;
@@ -508,14 +517,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
508
517
  result.error = { code: 'CONSENT_UNAVAILABLE', message: `A call waits for a ${open.provider} authorization, but this turn has no credential store.` };
509
518
  return finish();
510
519
  }
511
- let granted = false;
512
- try {
513
- granted = !!open.provider && !!(await credentialStore.get({ appName: credentialApp, userId, provider: open.provider }));
514
- }
515
- catch {
516
- // Expired or unreadable: not granted. The request stands.
517
- }
518
- if (!granted)
520
+ if (!(await grantStored(open)))
519
521
  return consentPause(open);
520
522
  // The route's own turn carries the grant's answer down to its call; the message goes to it as it came.
521
523
  if (routeWaits)
@@ -527,6 +529,28 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
527
529
  ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
528
530
  }
529
531
  }
532
+ // A walk waiting for grants (ADR 0128): a node's own consent request, or one a node raised again from below it. Once every
533
+ // grant is stored, the message becomes their answers and the walk resumes those nodes; until then it repeats the first
534
+ // request still missing and stores nothing, as at the top.
535
+ if (!decision && isWorkflowSyndicate(config)) {
536
+ const waiting = await walkConsents(pauseKey, existing?.events ?? []);
537
+ if (waiting.length > 0) {
538
+ if (!credentialStore) {
539
+ result.status = 'failed';
540
+ result.error = { code: 'CONSENT_UNAVAILABLE', message: `A call waits for a ${waiting[0].provider} authorization, but this turn has no credential store.` };
541
+ return finish();
542
+ }
543
+ for (const open of waiting)
544
+ if (!(await grantStored(open)))
545
+ return consentPause(open);
546
+ granting = waiting[0];
547
+ parts = waiting.map((open) => credentialResponsePart(open.id, open.provider));
548
+ for (const open of waiting)
549
+ ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
550
+ }
551
+ }
552
+ /** The parts a paused workflow route's walk resumes on once its grant is stored (ADR 0128): the grant's answer to its credential request. */
553
+ const routeGrant = consentRoute?.consent ? [credentialResponsePart(consentRoute.consent.id, consentRoute.consent.provider)] : undefined;
530
554
  // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
531
555
  // While an agent's `ask_user` call is open, a plain-text message is its
532
556
  // answer: it becomes that call's response, and the agent that asked
@@ -651,6 +675,8 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
651
675
  memory: nativeMemory(opts.memoryService),
652
676
  ...(selfCorrection ? { selfCorrection } : {}),
653
677
  ...(credentialStore ? { credentials: credentialStore } : {}),
678
+ // The consent step, its flows bound to the app the grants are read under, wherever the walk's session is filed (ADR 0128).
679
+ ...(consentStep ? { consent: consentPinnedTo(consentStep, credentialApp) } : {}),
654
680
  ...(compileOpts.log ? { log: compileOpts.log } : {}),
655
681
  });
656
682
  }
@@ -747,7 +773,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
747
773
  throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
748
774
  const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
749
775
  // Filed under the agent path (ADR 0119), else continued under the route's name alone where ADR 0106 filed it.
750
- const resumingWalk = resumingRoute !== undefined || routeAnswer !== undefined;
776
+ const resumingWalk = resumingRoute !== undefined || routeAnswer !== undefined || consentRoute !== undefined;
751
777
  const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingWalk);
752
778
  const childKey = { appName: child.appName, userId, sessionId };
753
779
  const before = new Map(Object.entries(kept(child.state)).map(([k, v]) => [k, JSON.stringify(v)]));
@@ -760,7 +786,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
760
786
  };
761
787
  let walked;
762
788
  try {
763
- walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
789
+ walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? routeGrant ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
764
790
  }
765
791
  catch (err) {
766
792
  const last = control.stopReason ? undefined : err;
@@ -1087,6 +1113,10 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
1087
1113
  const pending = pendingApproval(after?.events ?? []);
1088
1114
  if (pending)
1089
1115
  return pause(await deepen(pending));
1116
+ // A node waiting for a grant (ADR 0128): its own request, or one from below it, with the path from the node down.
1117
+ const [consent] = await walkConsents(pauseKey, after?.events ?? []);
1118
+ if (consent)
1119
+ return consentPause(consent);
1090
1120
  if (answer.inputRequests.length)
1091
1121
  return question(await deepen(answer.inputRequests[answer.inputRequests.length - 1]));
1092
1122
  result.text = answer.text;
@@ -1153,12 +1153,30 @@ function workflowProblems(raw, subs) {
1153
1153
  if (typeof agent.name === 'string' && mapped.has(agent.name) && isObj(agent.skills) && agent.skills.scripts === 'local') {
1154
1154
  out.push({ path: [...path, 'skills', 'scripts'], message: 'skill scripts (an approval pause) are not supported on an agent a map node runs: a map item cannot pause the walk' });
1155
1155
  }
1156
+ // An authorization_code grant pauses for consent (ADR 0128): allowed on a node, not on a map item.
1157
+ if (typeof agent.name === 'string' && mapped.has(agent.name)) {
1158
+ for (const where of authorizationCodeGrants(agent)) {
1159
+ out.push({ path: [...path, ...where], message: 'an authorization_code grant (a consent pause) is not supported on an agent a map node runs: a map item cannot pause the walk' });
1160
+ }
1161
+ }
1156
1162
  if (typeof agent.a2a_agent_url === 'string') {
1157
1163
  out.push({ path: [...path, 'a2a_agent_url'], message: 'a remote agent cannot be a workflow node yet' });
1158
1164
  }
1159
1165
  }
1160
1166
  return out;
1161
1167
  }
1168
+ /** Where an agent declares an authorization_code grant (mcp_auth, mcp_servers[].auth, openapi[].auth), by its path below the agent. */
1169
+ function authorizationCodeGrants(agent) {
1170
+ const out = [];
1171
+ const isCode = (auth) => isObj(auth) && isObj(auth.oauth2) && auth.oauth2.grant === 'authorization_code';
1172
+ if (isCode(agent.mcp_auth))
1173
+ out.push(['mcp_auth', 'oauth2', 'grant']);
1174
+ if (Array.isArray(agent.mcp_servers))
1175
+ agent.mcp_servers.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['mcp_servers', i, 'auth', 'oauth2', 'grant']));
1176
+ if (Array.isArray(agent.openapi))
1177
+ agent.openapi.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['openapi', i, 'auth', 'oauth2', 'grant']));
1178
+ return out;
1179
+ }
1162
1180
  // ── Public API ───────────────────────────────────────────────────────────────
1163
1181
  /**
1164
1182
  * Thrown by validateSyndicateConfig. `problems` holds one `<file>: <path> —
@@ -5,8 +5,9 @@
5
5
  * An agent's `mcp_server_url:` (or each entry of `mcp_servers:`, ADR 0124)
6
6
  * connects here over Streamable HTTP, falling back to the legacy SSE
7
7
  * transport when the server answers the initialize POST with the spec's
8
- * fallback signal (a 4xx other than 401/403), or over the transport the YAML
9
- * names. Every tool the server lists becomes an own Tool whose declaration is the server's
8
+ * fallback signal (a 4xx other than 401/403), or with a 401 when no OAuth
9
+ * grant is configured for the connection, or over the transport the YAML
10
+ * names; the transport that worked is remembered per URL. Every tool the server lists becomes an own Tool whose declaration is the server's
10
11
  * description (bounded) and input schema, and whose execute calls the
11
12
  * server and returns its text (bounded). loadMcpTools returns them;
12
13
  * createMcpTools is the name the compiler calls.
@@ -134,14 +135,56 @@ export function isSseFallbackSignal(error) {
134
135
  const code = error.code;
135
136
  return typeof code === 'number' && code >= 400 && code < 500 && code !== 401 && code !== 403;
136
137
  }
138
+ /**
139
+ * True when a Streamable HTTP connect failed with a 401: the server refused
140
+ * the request's credential (or its absence). Where the connection carries no
141
+ * OAuth grant, an SSE-only server behind a bearer can answer the initialize
142
+ * POST to its stream URL that way, so connectMcp tries SSE once (ADR 0124's
143
+ * note of 2026-10-11).
144
+ */
145
+ const isUnauthorized = (error) => error instanceof StreamableHTTPError && error.code === 401;
146
+ /** Server URLs whose transport `auto` has learned, at most; the least recently used is forgotten first. */
147
+ const MAX_REMEMBERED_MCP_TRANSPORTS = 256;
148
+ /**
149
+ * The transport that last connected to each server URL under `auto`, for the
150
+ * life of the process, so a later connection skips the probe. It holds a URL
151
+ * and a transport name, never a credential.
152
+ */
153
+ const rememberedTransports = new Map();
154
+ function rememberTransport(href, kind) {
155
+ rememberedTransports.delete(href);
156
+ rememberedTransports.set(href, kind);
157
+ while (rememberedTransports.size > MAX_REMEMBERED_MCP_TRANSPORTS) {
158
+ const oldest = rememberedTransports.keys().next().value;
159
+ rememberedTransports.delete(oldest);
160
+ }
161
+ }
162
+ const STREAMABLE_PREFIX = 'Streamable HTTP error: ';
163
+ /** `message` with the credential header's value, and its token, cut out. */
164
+ function withoutCredential(message, headers) {
165
+ const auth = headers.Authorization;
166
+ if (!auth)
167
+ return message;
168
+ const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
169
+ return redact(redact(message, auth), token);
170
+ }
137
171
  /**
138
172
  * A client connected to `url` over `kind`, every request through `fetchFn`
139
173
  * (the redirect rule, and the credential when there is one). A transport
140
174
  * that fails to connect is closed before the error leaves, so nothing keeps
141
- * reconnecting; with `auto`, a failed Streamable HTTP attempt carrying the
142
- * fallback signal is closed and the SSE transport tried once.
175
+ * reconnecting. With `auto`:
176
+ * - a transport remembered for this URL is tried first; if it fails, it is
177
+ * forgotten and the probe runs;
178
+ * - the probe posts over Streamable HTTP. On the spec's fallback signal, or
179
+ * on a 401 when the connection has no OAuth grant (`MCP_BEARER_TOKENS`
180
+ * or no credential), it closes that attempt and tries SSE once with the
181
+ * same fetch and headers. When SSE fails after a 401, the 401 is what is
182
+ * thrown, naming both attempts and no credential. A 401 under an OAuth
183
+ * grant (its consent and discovery path, ADR 0112 and 0124), a 403, a 5xx
184
+ * and a network failure do not fall back;
185
+ * - the transport that connected is remembered for this URL.
143
186
  */
144
- async function connectMcp(url, kind, fetchFn, headers = {}) {
187
+ async function connectMcp(url, kind, fetchFn, headers = {}, options = {}) {
145
188
  const attempt = async (which) => {
146
189
  const requestInit = Object.keys(headers).length ? { requestInit: { headers } } : {};
147
190
  const transport = which === 'sse' ? new SSEClientTransport(url, { ...requestInit, fetch: fetchFn }) : new StreamableHTTPClientTransport(url, { ...requestInit, fetch: fetchFn });
@@ -160,14 +203,43 @@ async function connectMcp(url, kind, fetchFn, headers = {}) {
160
203
  return attempt('sse');
161
204
  if (kind === 'streamable_http')
162
205
  return attempt('streamable_http');
206
+ const href = url.href;
207
+ const known = rememberedTransports.get(href);
208
+ if (known) {
209
+ try {
210
+ const conn = await attempt(known);
211
+ rememberTransport(href, known);
212
+ return conn;
213
+ }
214
+ catch {
215
+ // The server may have changed: forget it and probe as if new.
216
+ rememberedTransports.delete(href);
217
+ }
218
+ }
219
+ let conn;
163
220
  try {
164
- return await attempt('streamable_http');
221
+ conn = await attempt('streamable_http');
165
222
  }
166
223
  catch (error) {
167
- if (!isSseFallbackSignal(error))
224
+ if (isSseFallbackSignal(error)) {
225
+ conn = await attempt('sse');
226
+ }
227
+ else if (isUnauthorized(error) && !options.oauth) {
228
+ try {
229
+ conn = await attempt('sse');
230
+ }
231
+ catch (sseError) {
232
+ const original = error.message.startsWith(STREAMABLE_PREFIX) ? error.message.slice(STREAMABLE_PREFIX.length) : error.message;
233
+ const sseMessage = sseError instanceof Error ? sseError.message : String(sseError);
234
+ throw new StreamableHTTPError(401, withoutCredential(`${original} (Streamable HTTP answered 401; the SSE transport was tried once after it and failed too: ${sseMessage})`, headers));
235
+ }
236
+ }
237
+ else {
168
238
  throw error;
169
- return attempt('sse');
239
+ }
170
240
  }
241
+ rememberTransport(href, conn.kind);
242
+ return conn;
171
243
  }
172
244
  /**
173
245
  * Close every open MCP connection. A connection otherwise lives as long as
@@ -180,6 +252,7 @@ export async function closeMcpConnections() {
180
252
  openTransports.clear();
181
253
  for (const pool of userPools)
182
254
  pool.clear();
255
+ rememberedTransports.clear();
183
256
  await Promise.all(all.map((t) => t.close().catch(() => { })));
184
257
  }
185
258
  /**
@@ -265,7 +338,7 @@ export async function loadMcpTools(mcpServerUrl, options = {}) {
265
338
  try {
266
339
  const url = await assertSafeMcpUrl(mcpServerUrl);
267
340
  conn = tokenSource
268
- ? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)))
341
+ ? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)), {}, { oauth: true })
269
342
  : await connectMcp(url, kind, mcpFetch, mcpAuthHeaders(url));
270
343
  const client = conn.client;
271
344
  // Fetch available tools from the MCP server
@@ -335,7 +408,7 @@ function userGrantMcpTools(mcpServerUrl, oauth2, names, kind) {
335
408
  conn.holder = holder;
336
409
  conn.ready = (async () => {
337
410
  const url = await assertSafeMcpUrl(mcpServerUrl);
338
- const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token));
411
+ const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token), {}, { oauth: true });
339
412
  conn.transport = transport;
340
413
  if (!listed) {
341
414
  const offered = await client.listTools();
@@ -5,8 +5,10 @@
5
5
  *
6
6
  * `auto` (the default) posts the initialize request over Streamable HTTP, the
7
7
  * spec's current transport, and falls back to the legacy HTTP+SSE transport
8
- * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal);
9
- * `streamable_http` and `sse` use that one transport only.
8
+ * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
9
+ * on a 401 when the connection carries no OAuth grant, remembering per URL
10
+ * the transport that worked; `streamable_http` and `sse` use that one
11
+ * transport only.
10
12
  */
11
13
  export declare const MCP_TRANSPORTS: readonly ['auto', 'streamable_http', 'sse'];
12
14
  export type McpTransportKind = (typeof MCP_TRANSPORTS)[number];
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * `auto` (the default) posts the initialize request over Streamable HTTP, the
7
7
  * spec's current transport, and falls back to the legacy HTTP+SSE transport
8
- * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal);
9
- * `streamable_http` and `sse` use that one transport only.
8
+ * on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
9
+ * on a 401 when the connection carries no OAuth grant, remembering per URL
10
+ * the transport that worked; `streamable_http` and `sse` use that one
11
+ * transport only.
10
12
  */
11
13
  export const MCP_TRANSPORTS = ['auto', 'streamable_http', 'sse'];
@@ -44,7 +44,8 @@
44
44
  * run's `error`, and the scheduler fails the attempt with
45
45
  * NodeReportedError, ADK's message, retried or stopping the walk as
46
46
  * ADK's does, and reported once (by the node's own event). A run the turn stopped throws. A run that pauses on
47
- * an ask_user tool call is refused by name.
47
+ * an ask_user tool call, or on an approval or a question inside its
48
+ * delegation, is refused by name.
48
49
  * 8. APPROVALS (ADR 0098). A run that pauses on approval requests (a tool
49
50
  * in the agent's `require_approval`) resolves with their ids as the
50
51
  * node's interrupts: the node waits and the walk ends paused, as an
@@ -62,6 +63,20 @@
62
63
  * from its input on. ADK's runLlmAgentAsNode stores the input again and
63
64
  * starts the agent afresh, so the pinned call never runs there; ADK
64
65
  * turns refuse gates on workflow nodes instead.
66
+ * 9. OAUTH CONSENT (ADR 0128). The node's loop gets the turn's consent
67
+ * step, its flows bound to the turn's app. A tool whose grant is
68
+ * missing stores its `adk_request_credential` call as the node's event
69
+ * (stamped, its id in longRunningToolIds) and the run ends paused: that
70
+ * id is the node's interrupt, as an approval request's is. A delegated
71
+ * call whose child waits for a grant (ADR 0118) leaves the call open;
72
+ * the node raises a copy of the child's request on one event of its own
73
+ * (`pausedOn`), under the same id. On the resume, a grant's answer
74
+ * (`{ credentialKey, granted: true }`, never plain text) lets the run
75
+ * continue as rule 8 lets an approval's: the loop's auth preprocessor
76
+ * (interrupts.ts grantedCalls) runs the paused call again, or its
77
+ * delegation resume carries the answer down the open call. Without one
78
+ * the node waits again, its request raised again (`waitAgain`). A map
79
+ * item runs without the consent step: it cannot pause the walk.
65
80
  *
66
81
  * `agentNodeRuntime` puts it together for the scheduler: a `runNode` for
67
82
  * agent nodes and map items, and an `onEvent` that stores the event ADK
@@ -69,7 +84,8 @@
69
84
  * (lib/workflow/nodeEvents.ts), so a session the native walk writes holds
70
85
  * what ADK's holds, in the same order.
71
86
  *
72
- * NOT HERE: an ask_user tool call inside a node, a pause inside a map item,
87
+ * NOT HERE: an ask_user tool call inside a node, an approval or a question
88
+ * inside a node's own delegation, a pause inside a map item,
73
89
  * tool nodes (lib/workflow/toolNode.ts) and ask_user nodes
74
90
  * (lib/workflow/pause.ts). It imports nothing from ADK.
75
91
  */
@@ -119,7 +135,7 @@ export interface AgentNodeContext {
119
135
  * one with no output. Throws NodeStoppedError when the turn stopped it, and
120
136
  * an Error when it paused on a person.
121
137
  */
122
- export declare function runAgentNode(agent: NativeAgent, run: Pick<NodeRun, 'input' | 'path' | 'branch' | 'signal' | 'resumeInputs' | 'resumedInterruptIds'>, ctx: AgentNodeContext): Promise<NodeResult>;
138
+ export declare function runAgentNode(agent: NativeAgent, run: Pick<NodeRun, 'input' | 'path' | 'branch' | 'signal' | 'resumeInputs' | 'resumedInterruptIds'> & Partial<Pick<NodeRun, 'target'>>, ctx: AgentNodeContext): Promise<NodeResult>;
123
139
  export interface AgentNodeRuntimeOptions extends Omit<AgentNodeContext, 'appendInput'> {
124
140
  /** Every agent of the syndicate, compiled for native, by YAML name. */
125
141
  agents: ReadonlyMap<string, NativeAgent>;
@@ -44,7 +44,8 @@
44
44
  * run's `error`, and the scheduler fails the attempt with
45
45
  * NodeReportedError, ADK's message, retried or stopping the walk as
46
46
  * ADK's does, and reported once (by the node's own event). A run the turn stopped throws. A run that pauses on
47
- * an ask_user tool call is refused by name.
47
+ * an ask_user tool call, or on an approval or a question inside its
48
+ * delegation, is refused by name.
48
49
  * 8. APPROVALS (ADR 0098). A run that pauses on approval requests (a tool
49
50
  * in the agent's `require_approval`) resolves with their ids as the
50
51
  * node's interrupts: the node waits and the walk ends paused, as an
@@ -62,6 +63,20 @@
62
63
  * from its input on. ADK's runLlmAgentAsNode stores the input again and
63
64
  * starts the agent afresh, so the pinned call never runs there; ADK
64
65
  * turns refuse gates on workflow nodes instead.
66
+ * 9. OAUTH CONSENT (ADR 0128). The node's loop gets the turn's consent
67
+ * step, its flows bound to the turn's app. A tool whose grant is
68
+ * missing stores its `adk_request_credential` call as the node's event
69
+ * (stamped, its id in longRunningToolIds) and the run ends paused: that
70
+ * id is the node's interrupt, as an approval request's is. A delegated
71
+ * call whose child waits for a grant (ADR 0118) leaves the call open;
72
+ * the node raises a copy of the child's request on one event of its own
73
+ * (`pausedOn`), under the same id. On the resume, a grant's answer
74
+ * (`{ credentialKey, granted: true }`, never plain text) lets the run
75
+ * continue as rule 8 lets an approval's: the loop's auth preprocessor
76
+ * (interrupts.ts grantedCalls) runs the paused call again, or its
77
+ * delegation resume carries the answer down the open call. Without one
78
+ * the node waits again, its request raised again (`waitAgain`). A map
79
+ * item runs without the consent step: it cannot pause the walk.
65
80
  *
66
81
  * `agentNodeRuntime` puts it together for the scheduler: a `runNode` for
67
82
  * agent nodes and map items, and an `onEvent` that stores the event ADK
@@ -69,13 +84,16 @@
69
84
  * (lib/workflow/nodeEvents.ts), so a session the native walk writes holds
70
85
  * what ADK's holds, in the same order.
71
86
  *
72
- * NOT HERE: an ask_user tool call inside a node, a pause inside a map item,
87
+ * NOT HERE: an ask_user tool call inside a node, an approval or a question
88
+ * inside a node's own delegation, a pause inside a map item,
73
89
  * tool nodes (lib/workflow/toolNode.ts) and ask_user nodes
74
90
  * (lib/workflow/pause.ts). It imports nothing from ADK.
75
91
  */
76
92
  import { APPROVAL_REQUEST } from '../runtime/approvals.js';
93
+ import { CREDENTIAL_REQUEST, credentialRequestCall, isGrantAnswer } from '../runtime/credentials.js';
77
94
  import { createTurnEvent, getFunctionCalls } from '../runtime/events.js';
78
95
  import { runAgentLoop } from '../runtime/native/agentLoop.js';
96
+ import { delegatedPauses } from '../runtime/native/interrupts.js';
79
97
  import { predecessorOutputs } from '../runtime/native/request.js';
80
98
  import { joinNodeEvent, mapNodeEvent } from './nodeEvents.js';
81
99
  import { routeStepEvent } from './route.js';
@@ -161,6 +179,28 @@ function nodeStamp(agent, run, invocationId, state) {
161
179
  function isApprovalRequest(event, id) {
162
180
  return !!event && getFunctionCalls(event).some((call) => call.id === id && call.name === APPROVAL_REQUEST);
163
181
  }
182
+ /** The requests a node waits on and raises again: an approval request, or an OAuth consent request (ADR 0128). */
183
+ const NODE_REQUESTS = new Set([APPROVAL_REQUEST, CREDENTIAL_REQUEST]);
184
+ /** The name of the request call `id` the node's agent (`author`) stored, the latest first: an approval or a credential request, else undefined. */
185
+ function requestNamed(events, author, id) {
186
+ for (let i = events.length - 1; i >= 0; i--) {
187
+ const event = events[i];
188
+ if (event.author !== author)
189
+ continue;
190
+ const call = getFunctionCalls(event).find((c) => c.id === id && c.name !== undefined && NODE_REQUESTS.has(c.name));
191
+ if (call)
192
+ return call.name;
193
+ }
194
+ return undefined;
195
+ }
196
+ /**
197
+ * Whether a resumed walk's answer to the node's request `id` answers it: a
198
+ * decision for an approval request (isApprovalAnswer), a grant's answer for
199
+ * a credential request (isGrantAnswer, ADR 0128).
200
+ */
201
+ function answersRequest(events, author, id, value) {
202
+ return requestNamed(events, author, id) === CREDENTIAL_REQUEST ? isGrantAnswer(value) : isApprovalAnswer(value);
203
+ }
164
204
  /**
165
205
  * Whether a resumed walk's answer to an approval request is a decision: a
166
206
  * ToolConfirmation (`{ confirmed }`), or ADK's `{ response: <json> }` form.
@@ -183,7 +223,7 @@ function outputAfter(events, path, ids) {
183
223
  const open = new Set(ids);
184
224
  let start = -1;
185
225
  events.forEach((event, i) => {
186
- if (getFunctionCalls(event).some((call) => call.name === APPROVAL_REQUEST && call.id !== undefined && open.has(call.id)))
226
+ if (getFunctionCalls(event).some((call) => call.name !== undefined && NODE_REQUESTS.has(call.name) && call.id !== undefined && open.has(call.id)))
187
227
  start = i;
188
228
  });
189
229
  if (start === -1)
@@ -203,9 +243,20 @@ function outputAfter(events, path, ids) {
203
243
  */
204
244
  async function waitAgain(agent, run, ctx, waiting) {
205
245
  const open = new Set(waiting);
206
- const calls = ctx.session.events.flatMap((event) => getFunctionCalls(event).filter((call) => call.name === APPROVAL_REQUEST && call.id !== undefined && open.has(call.id)));
246
+ const calls = ctx.session.events
247
+ .filter((event) => event.author === agent.name)
248
+ .flatMap((event) => getFunctionCalls(event).filter((call) => call.name !== undefined && NODE_REQUESTS.has(call.name) && call.id !== undefined && open.has(call.id)));
207
249
  const seen = new Set();
208
250
  const parts = calls.filter((call) => !seen.has(call.id) && seen.add(call.id)).map((call) => ({ functionCall: structuredClone(call) }));
251
+ return raise(agent, run, ctx, parts);
252
+ }
253
+ /**
254
+ * Stores `parts` (request calls) as one event of the node's run, stamped as
255
+ * the node's, their ids in `longRunningToolIds` (the walk's interrupts), and
256
+ * resolves with those ids: the node waits on them.
257
+ */
258
+ async function raise(agent, run, ctx, parts) {
259
+ const seen = new Set(parts.map((p) => p.functionCall?.id));
209
260
  const event = createTurnEvent({ author: agent.name, invocationId: ctx.invocationId, content: { role: 'model', parts } });
210
261
  event.longRunningToolIds = [...seen];
211
262
  nodeStamp(agent, run, ctx.invocationId, { output: undefined })(event);
@@ -213,6 +264,50 @@ async function waitAgain(agent, run, ctx, waiting) {
213
264
  ctx.onEvent?.(stored);
214
265
  return { interruptIds: [...seen] };
215
266
  }
267
+ /**
268
+ * Rules 8 and 9, a run that ended paused: the ids the node waits on. An
269
+ * approval request and the node's own OAuth consent request (its
270
+ * `adk_request_credential` call, already stored as the node's event) are
271
+ * the walk's interrupts as they are. A delegated call the agent left open
272
+ * whose child waits for a grant (ADR 0118) is raised again on the walk as a
273
+ * copy of the child's credential request, under its id (ADR 0128), so the
274
+ * walk pauses on what the turn asks the person for and its resume reruns
275
+ * the node with the grant's answer, which the loop's delegation resume
276
+ * carries down the open call. Any other pause (an ask_user call, an
277
+ * approval or a question inside the node's delegation) is refused by name.
278
+ */
279
+ async function pausedOn(agent, run, ctx, end) {
280
+ const pending = end.pending ?? [];
281
+ const refuse = () => {
282
+ throw new Error(`Node '${agent.name}' paused on ${pending.join(', ')}: a pause inside an agent node other than an approval or an OAuth consent does not run on the native runtime yet.`);
283
+ };
284
+ if (pending.length === 0)
285
+ refuse();
286
+ if (pending.every((id) => isApprovalRequest(end.lastEvent, id)))
287
+ return { interruptIds: [...pending] };
288
+ // A map item never pauses: it runs without the consent step (agentNodeRuntime), so it has no request to wait on.
289
+ if (run.target?.kind === 'map_item')
290
+ refuse();
291
+ const { session, sessions } = ctx;
292
+ const ids = [];
293
+ const raised = [];
294
+ let below;
295
+ for (const id of pending) {
296
+ if (isApprovalRequest(end.lastEvent, id) || requestNamed(session.events, agent.name, id) === CREDENTIAL_REQUEST) {
297
+ ids.push(id);
298
+ continue;
299
+ }
300
+ below ??= await delegatedPauses({ sessions, userId: session.userId, sessionId: session.id, appName: session.appName }, session.events, agent.name);
301
+ const consent = below.find((pause) => pause.callIds[0] === id)?.consent;
302
+ if (!consent)
303
+ refuse();
304
+ ids.push(consent.id);
305
+ raised.push({ functionCall: credentialRequestCall(consent) });
306
+ }
307
+ if (raised.length > 0)
308
+ await raise(agent, run, ctx, raised);
309
+ return { interruptIds: ids };
310
+ }
216
311
  /**
217
312
  * Runs `agent` as the node `run` names, on the native loop, and resolves
218
313
  * with the node's output, or with the error it reported when it ended on
@@ -226,7 +321,7 @@ export async function runAgentNode(agent, run, ctx) {
226
321
  // Rule 8: a node that waited on an approval resumes its own run once every request it raised is answered.
227
322
  const resumed = run.resumedInterruptIds ?? [];
228
323
  if (resumed.length > 0) {
229
- const waiting = resumed.filter((id) => !isApprovalAnswer(run.resumeInputs?.[id]));
324
+ const waiting = resumed.filter((id) => !answersRequest(ctx.session.events, agent.name, id, run.resumeInputs?.[id]));
230
325
  if (waiting.length > 0)
231
326
  return waitAgain(agent, run, ctx, waiting);
232
327
  const finished = outputAfter(ctx.session.events, run.path, resumed);
@@ -265,13 +360,8 @@ export async function runAgentNode(agent, run, ctx) {
265
360
  else
266
361
  ctx.onEvent?.(next.value);
267
362
  }
268
- if (end.reason === 'paused') {
269
- // Rule 8: an approval request pauses the node, and the walk with it; any other pause (an ask_user call) is refused.
270
- const pending = end.pending ?? [];
271
- if (pending.length > 0 && pending.every((id) => isApprovalRequest(end.lastEvent, id)))
272
- return { interruptIds: [...pending] };
273
- throw new Error(`Node '${agent.name}' paused on ${pending.join(', ')}: a pause inside an agent node other than an approval does not run on the native runtime yet.`);
274
- }
363
+ if (end.reason === 'paused')
364
+ return pausedOn(agent, run, ctx, end);
275
365
  if (end.reason === 'stopped')
276
366
  throw new NodeStoppedError(agent.name, end.stop);
277
367
  // ADK's failIfNodeReportedError: an error with an output is not the node's failure. Without one the error is
@@ -320,6 +410,8 @@ export function agentNodeRuntime(options) {
320
410
  return stored;
321
411
  },
322
412
  };
413
+ const { consent: _consent, ...mapLoop } = ctx.loop ?? {};
414
+ const mapCtx = { ...ctx, loop: mapLoop };
323
415
  const agentNamed = (name) => {
324
416
  const agent = agents.get(name);
325
417
  if (!agent)
@@ -337,8 +429,9 @@ export function agentNodeRuntime(options) {
337
429
  return options.workflowNodes.run(target.name, run);
338
430
  if (target.kind === 'agent')
339
431
  return runAgentNode(agentNamed(target.name), run, ctx);
432
+ // A map item cannot pause the walk (ADR 0094): it runs without the consent step, so a missing grant is its tool's error (ADR 0128).
340
433
  if (target.kind === 'map_item')
341
- return runAgentNode(agentNamed(target.agent), run, ctx);
434
+ return runAgentNode(agentNamed(target.agent), run, mapCtx);
342
435
  if (options.next)
343
436
  return options.next(run);
344
437
  throw new Error(`agentNodeRunner runs agent nodes and map items only; ${target.name} is a ${target.kind} run`);
@@ -120,11 +120,11 @@ export declare function workflowNodeInput(content: TurnContent | undefined): unk
120
120
  export declare function resumeInputsFromPlainText(content: TurnContent | undefined, events: readonly StoredEvent[], invocationId: string): Record<string, unknown>;
121
121
  /**
122
122
  * A pause the native walk cannot resume: one raised inside an agent node
123
- * other than an approval (an `ask_user` tool call, a credential request) or
124
- * inside a map item. ADK resumes those inside the node (the agent's own
125
- * history, the ParallelWorker's item); the native agent node pauses only on
126
- * an approval (lib/workflow/agentNode.ts, ADR 0098), so it cannot pick the
127
- * others up. Resuming the walk anyway would run the node again from its
123
+ * other than an approval or an OAuth consent request (an `ask_user` tool
124
+ * call) or inside a map item. ADK resumes those inside the node (the
125
+ * agent's own history, the ParallelWorker's item); the native agent node
126
+ * pauses only on an approval (lib/workflow/agentNode.ts, ADR 0098) or a
127
+ * consent request (ADR 0128), so it cannot pick the others up. Resuming the walk anyway would run the node again from its
128
128
  * input and ask again, so the resume refuses by name instead.
129
129
  */
130
130
  export declare class UnsupportedWorkflowResumeError extends Error {
@@ -135,8 +135,9 @@ export declare class UnsupportedWorkflowResumeError extends Error {
135
135
  /**
136
136
  * Throws UnsupportedWorkflowResumeError for the first interrupt in the run's
137
137
  * events that is neither an ask_user node's input request nor an agent
138
- * node's approval request at a direct child of the workflow (the workflow's
139
- * own record aside).
138
+ * node's approval or credential request at a direct child of the workflow
139
+ * (the workflow's own record aside). A credential request it cannot read
140
+ * (no paused call's id, no auth config) does not resume.
140
141
  */
141
142
  export declare function assertResumable(runEvents: readonly StoredEvent[], workflowPath: string): void;
142
143
  export interface WorkflowResumeRequest {