melchizedek-agents 1.1.0 → 1.3.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 (146) hide show
  1. package/.env.example +19 -0
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +373 -0
  4. package/DOCUMENTATION.md +193 -21
  5. package/ONBOARDING.md +378 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +26 -3
  8. package/config/agents/examples/connectors.yaml +82 -0
  9. package/config/agents/examples/council.yaml +1 -1
  10. package/config/agents/examples/critic.yaml +8 -8
  11. package/config/agents/examples/image_production.yaml +4 -3
  12. package/config/agents/syndicate.schema.json +389 -33
  13. package/config/agents/syndicateSchema.yaml +18 -6
  14. package/config/agents/templates/intake_extractor.yaml +3 -2
  15. package/config/agents/templates/systems_operator.yaml +5 -2
  16. package/dist/lib/a2a/app.d.ts +12 -0
  17. package/dist/lib/a2a/app.js +25 -11
  18. package/dist/lib/a2a/executor.js +14 -6
  19. package/dist/lib/a2a/oauthSetup.d.ts +16 -0
  20. package/dist/lib/a2a/oauthSetup.js +77 -8
  21. package/dist/lib/agentDialect.d.ts +18 -8
  22. package/dist/lib/agentDialect.js +36 -19
  23. package/dist/lib/chatgpt/adapter.d.ts +50 -0
  24. package/dist/lib/chatgpt/adapter.js +107 -0
  25. package/dist/lib/chatgpt/cli.d.ts +23 -0
  26. package/dist/lib/chatgpt/cli.js +77 -0
  27. package/dist/lib/chatgpt/oauth.d.ts +103 -0
  28. package/dist/lib/chatgpt/oauth.js +448 -0
  29. package/dist/lib/chatgpt/state.d.ts +98 -0
  30. package/dist/lib/chatgpt/state.js +145 -0
  31. package/dist/lib/chatgpt/store.d.ts +24 -0
  32. package/dist/lib/chatgpt/store.js +94 -0
  33. package/dist/lib/compile.d.ts +38 -7
  34. package/dist/lib/compile.js +97 -62
  35. package/dist/lib/compileNative.d.ts +11 -3
  36. package/dist/lib/compileNative.js +18 -1
  37. package/dist/lib/dispatch.js +6 -2
  38. package/dist/lib/doctor.d.ts +107 -0
  39. package/dist/lib/doctor.js +217 -7
  40. package/dist/lib/loadSyndicate.d.ts +15 -0
  41. package/dist/lib/mcp/server.d.ts +195 -0
  42. package/dist/lib/mcp/server.js +564 -0
  43. package/dist/lib/memory/supabaseMemoryService.js +2 -2
  44. package/dist/lib/models/adapterResolver.js +6 -1
  45. package/dist/lib/models/capabilities.d.ts +9 -0
  46. package/dist/lib/models/capabilities.js +24 -0
  47. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  48. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  49. package/dist/lib/models/claudeAdapter.js +4 -0
  50. package/dist/lib/models/contract.d.ts +5 -2
  51. package/dist/lib/models/gateway.d.ts +3 -2
  52. package/dist/lib/models/gateway.js +8 -4
  53. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  54. package/dist/lib/models/gatewayAdapter.js +8 -0
  55. package/dist/lib/models/geminiAdapter.js +8 -2
  56. package/dist/lib/models/genaiMapping.d.ts +8 -4
  57. package/dist/lib/models/genaiMapping.js +23 -9
  58. package/dist/lib/models/gptAdapter.d.ts +4 -2
  59. package/dist/lib/models/gptAdapter.js +11 -4
  60. package/dist/lib/models/grokAdapter.d.ts +3 -1
  61. package/dist/lib/models/grokAdapter.js +11 -3
  62. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  63. package/dist/lib/models/kimiAdapter.js +3 -3
  64. package/dist/lib/models/reasoning.d.ts +30 -2
  65. package/dist/lib/models/reasoning.js +56 -5
  66. package/dist/lib/onboarding.d.ts +135 -0
  67. package/dist/lib/onboarding.js +578 -0
  68. package/dist/lib/runtime/approvals.d.ts +2 -0
  69. package/dist/lib/runtime/approvals.js +4 -3
  70. package/dist/lib/runtime/credentials.d.ts +6 -0
  71. package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
  72. package/dist/lib/runtime/native/agentLoop.js +2 -0
  73. package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
  74. package/dist/lib/runtime/native/checkpoint.js +13 -19
  75. package/dist/lib/runtime/native/delegate.d.ts +82 -1
  76. package/dist/lib/runtime/native/delegate.js +157 -2
  77. package/dist/lib/runtime/native/interrupts.d.ts +88 -5
  78. package/dist/lib/runtime/native/interrupts.js +220 -30
  79. package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
  80. package/dist/lib/runtime/syndicateTurn.js +324 -77
  81. package/dist/lib/runtime/turnControl.d.ts +41 -0
  82. package/dist/lib/session/transcript.d.ts +9 -0
  83. package/dist/lib/session/transcript.js +20 -1
  84. package/dist/lib/skills.d.ts +18 -0
  85. package/dist/lib/skills.js +73 -1
  86. package/dist/lib/storage/postgres/index.d.ts +6 -1
  87. package/dist/lib/storage/postgres/index.js +1 -1
  88. package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
  89. package/dist/lib/storage/postgres/taskQueue.js +10 -3
  90. package/dist/lib/syndicateSchema.d.ts +184 -9
  91. package/dist/lib/syndicateSchema.js +143 -29
  92. package/dist/lib/tools/auth.d.ts +13 -1
  93. package/dist/lib/tools/auth.js +10 -2
  94. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  95. package/dist/lib/tools/credentialHosts.js +165 -0
  96. package/dist/lib/tools/credentialUses.d.ts +98 -0
  97. package/dist/lib/tools/credentialUses.js +120 -0
  98. package/dist/lib/tools/generateImageTool.js +2 -1
  99. package/dist/lib/tools/inspectImageTool.js +1 -1
  100. package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
  101. package/dist/lib/tools/mcpToolFactory.js +72 -26
  102. package/dist/lib/tools/mcpTransports.d.ts +12 -0
  103. package/dist/lib/tools/mcpTransports.js +11 -0
  104. package/dist/lib/tools/oauthConsent.d.ts +18 -3
  105. package/dist/lib/tools/oauthConsent.js +57 -15
  106. package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
  107. package/dist/lib/tools/oauthDiscovery.js +616 -0
  108. package/dist/lib/tools/oauthHosts.d.ts +2 -0
  109. package/dist/lib/tools/oauthHosts.js +2 -2
  110. package/dist/lib/tools/oauthTools.d.ts +54 -13
  111. package/dist/lib/tools/oauthTools.js +106 -23
  112. package/dist/lib/tools/openapiTools.d.ts +7 -0
  113. package/dist/lib/tools/openapiTools.js +49 -3
  114. package/dist/lib/tools/taskTools.d.ts +32 -2
  115. package/dist/lib/tools/taskTools.js +93 -33
  116. package/dist/lib/tools/xApiSearchTool.js +2 -2
  117. package/dist/lib/workflow/turn.d.ts +22 -3
  118. package/dist/lib/workflow/turn.js +151 -31
  119. package/dist/lib/workflow.d.ts +10 -7
  120. package/dist/lib/workflow.js +10 -7
  121. package/dist/scripts/a2a_server.d.ts +2 -2
  122. package/dist/scripts/a2a_server.js +19 -2
  123. package/dist/scripts/assistant_worker.d.ts +2 -0
  124. package/dist/scripts/assistant_worker.js +24 -5
  125. package/dist/scripts/doctor.d.ts +4 -2
  126. package/dist/scripts/doctor.js +6 -4
  127. package/dist/scripts/mcp_server.d.ts +12 -0
  128. package/dist/scripts/mcp_server.js +345 -0
  129. package/dist/scripts/setup.d.ts +25 -0
  130. package/dist/scripts/setup.js +181 -0
  131. package/dist/scripts/skills_install.d.ts +3 -1
  132. package/dist/scripts/skills_install.js +17 -2
  133. package/dist/scripts/syndicate_chat.js +12 -3
  134. package/dist/scripts/yaml_codemod.d.ts +12 -2
  135. package/dist/scripts/yaml_codemod.js +115 -33
  136. package/package.json +8 -2
  137. package/skills/README.md +11 -2
  138. package/skills/briefs/_shared.md +1 -1
  139. package/skills/briefs/melchizedek-models.md +4 -2
  140. package/skills/briefs/skills-readme.md +3 -1
  141. package/skills/melchizedek-models/SKILL.md +6 -2
  142. package/skills/melchizedek-onboard/SKILL.md +71 -0
  143. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  144. package/skills/melchizedek-onboard-keys/SKILL.md +47 -0
  145. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  146. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -41,7 +41,7 @@
41
41
  */
42
42
  import { randomUUID } from 'node:crypto';
43
43
  import { DEFAULT_MAX_STEPS } from '../config.js';
44
- import { agentGates, compileEntrySpec, compileSpec, compileWorkflowSpec, declaresApprovals, workflowAgentSpecs } from '../compile.js';
44
+ import { agentGates, compileEntrySpec, compileSpec, compileWorkflowSpec, workflowAgentSpecs } from '../compile.js';
45
45
  import { compileNative, compileNativeWorkflow, nativeAdapterFor } from '../compileNative.js';
46
46
  import { nativeMemory, refuseOnNative, runNativeAgent } from './nativeTurn.js';
47
47
  import { createTurnEvent, getFunctionCalls, getFunctionResponses } from './events.js';
@@ -57,7 +57,7 @@ export { CREDENTIAL_REQUEST, credentialResponsePart, describeConsent, pendingCon
57
57
  export { chooseRuntime, describeRuntime, runtimeSetting, DEFAULT_RUNTIME, RUNTIMES, RuntimeRemovedError, UnsupportedOnRuntimeError } from './runtimeFlag.js';
58
58
  // The session store a turn takes when nothing durable is configured, and the engine's interfaces (ADR 0107).
59
59
  export { InProcessSessionService } from './sessions.js';
60
- import { ROUTE_STEP_SUFFIX, describeInput, inputRequestFrom, isWorkflowSyndicate } from '../workflow.js';
60
+ import { ROUTE_STEP_SUFFIX, inputRequestFrom, isWorkflowSyndicate } from '../workflow.js';
61
61
  export { describeInput } from '../workflow.js';
62
62
  import { isDispatchSyndicate, matchRouteOverride, resolveRoute } from '../dispatch.js';
63
63
  import { collectGrounding, describeGrounding, newGroundingState, webSourcesLine } from '../grounding.js';
@@ -70,7 +70,10 @@ 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 { delegatedPauses } from './native/interrupts.js';
73
+ import { deepestPause, delegatedPauses, nestedWorkflowPause, routePause } from './native/interrupts.js';
74
+ import { consentPinnedTo, entrySession } from './native/delegate.js';
75
+ import { workflowPauseEvent } from '../workflow/pause.js';
76
+ import { INPUT_REQUEST } from '../workflowConfig.js';
74
77
  export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.js';
75
78
  export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.js';
76
79
  import { RemoteA2AAgent, remoteContextId, remoteToolOutput } from '../a2a/remoteAgent.js';
@@ -83,15 +86,28 @@ const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for y
83
86
  // The defaults live with the loop's self-correction (lib/runtime/native/selfCorrection.ts).
84
87
  export { DEFAULT_MODEL_ERROR_RETRIES, DEFAULT_TOOL_ERROR_RETRIES } from './native/selfCorrection.js';
85
88
  // ── Draining one agent's stream ──────────────────────────────────────────────
86
- function formatToolArgs(args) {
87
- if (!args || Object.keys(args).length === 0)
88
- return '';
89
- return Object.entries(args)
90
- .map(([k, v]) => {
91
- const raw = typeof v === 'string' ? v : JSON.stringify(v);
92
- return `${k}=${raw.length > 60 ? `${raw.slice(0, 60)}...` : raw}`;
93
- })
94
- .join(', ');
89
+ /**
90
+ * The shape of a tool call's arguments for a log line: the key names and the
91
+ * size, never a value. Arguments are model-chosen and routinely carry the
92
+ * person's words, and a server log is not where those belong (the ledger is
93
+ * the deliberate record, with its own retention).
94
+ */
95
+ function describeToolArgs(args) {
96
+ const keys = args ? Object.keys(args) : [];
97
+ if (!keys.length)
98
+ return 'no args';
99
+ let bytes;
100
+ try {
101
+ bytes = Buffer.byteLength(JSON.stringify(args), 'utf8');
102
+ }
103
+ catch {
104
+ return `args: ${keys.join(', ')}`;
105
+ }
106
+ return `args: ${keys.join(', ')} (${bytes.toLocaleString()} bytes)`;
107
+ }
108
+ /** A pending question for a log line: who asks, never what. */
109
+ function describeInputForLog(input) {
110
+ return `${input.node} asks a question${input.path?.length ? ` (${input.path.join(' → ')})` : ''}`;
95
111
  }
96
112
  /**
97
113
  * Drains one agent's event stream into a DrainedRun. The answer is the LAST
@@ -185,7 +201,7 @@ export async function drainAgentStream(stream, opts = {}) {
185
201
  if (inputRequest) {
186
202
  // An ask_user node: the workflow waits for the person (lib/workflow.ts).
187
203
  d.inputRequests.push(inputRequest);
188
- ev.log?.(`⏸ ${describeInput(inputRequest)}`);
204
+ ev.log?.(`⏸ ${describeInputForLog(inputRequest)}`);
189
205
  continue;
190
206
  }
191
207
  if (!name)
@@ -194,7 +210,7 @@ export async function drainAgentStream(stream, opts = {}) {
194
210
  if (opts.subagentNames?.has(name))
195
211
  d.delegations.push(name);
196
212
  d.toolCalls.push({ agent: e.author ?? 'unknown', name, args });
197
- ev.log?.(`→ Tool: ${name}(${formatToolArgs(args)})`);
213
+ ev.log?.(`→ Tool: ${name} by ${e.author ?? 'unknown'} — ${describeToolArgs(args)}`);
198
214
  if (opts.publishToolStatus)
199
215
  ev.onProgress?.(`Invoking tool: ${name}`);
200
216
  }
@@ -281,6 +297,8 @@ export async function runSyndicateTurn(opts) {
281
297
  // `trace: false`: no span from anything the turn runs, to any sink.
282
298
  untraced: opts.trace === false,
283
299
  });
300
+ // A nested dispatch syndicate, wherever the turn reaches one, runs as a turn of its own under these controls (ADR 0120).
301
+ control.nestedDispatch = (run) => runNestedDispatch(opts, control, runtime, run);
284
302
  try {
285
303
  const run = () => runWithTurnControl(control, () => runTurnInner(opts, control, runtime));
286
304
  // A span a library opens through OpenTelemetry's own API (not the
@@ -307,7 +325,50 @@ function nativeWorkflowAgent(spec, opts) {
307
325
  const { graph, agents, resolveTool, workflows } = compileNativeWorkflow(spec);
308
326
  return { runtime: 'native-workflow', graph, agents, adapterFor: nativeAdapterFor(opts, workflowAgentSpecs(spec)), resolveTool, workflows };
309
327
  }
310
- async function runTurnInner(opts, control, _runtime) {
328
+ /**
329
+ * A nested dispatch syndicate's own turn (ADR 0120): runTurnInner on its
330
+ * conversation (`run`, opened by the caller), under the turn's controls,
331
+ * with the turn's store, memory, credentials and log, never streamed, its
332
+ * guards left to the turn at the top (collectGuards already lists them
333
+ * there). Its result is read as a walk's end: the route's final text, the
334
+ * conversation's state writes, and what it waits on, by id.
335
+ */
336
+ async function runNestedDispatch(opts, control, runtime, run) {
337
+ const sessions = run.sessions;
338
+ const key = { appName: run.appName, userId: run.userId, sessionId: run.sessionId };
339
+ const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
340
+ const before = new Map(Object.entries(kept((await sessions.get(key))?.state)).map(([k, v]) => [k, JSON.stringify(v)]));
341
+ const config = run.config;
342
+ const trace = opts.trace === false ? false : { ...(opts.trace ?? {}), syndicateName: config.syndicate_name ?? run.name };
343
+ const nested = await runTurnInner({
344
+ config,
345
+ parts: run.parts,
346
+ appName: run.appName,
347
+ userId: run.userId,
348
+ sessionId: run.sessionId,
349
+ sessionService: sessions,
350
+ ...(opts.memoryService ? { memoryService: opts.memoryService } : {}),
351
+ compile: run.compile,
352
+ ...(opts.toolCredentials ? { toolCredentials: opts.toolCredentials } : {}),
353
+ streaming: false,
354
+ trace,
355
+ events: { ...(opts.events?.log ? { log: opts.events.log } : {}), ...(opts.events?.warn ? { warn: opts.events.warn } : {}) },
356
+ }, control, runtime, { credentialApp: opts.appName });
357
+ const after = kept((await sessions.get(key))?.state);
358
+ const stateDelta = Object.fromEntries(Object.entries(after).filter(([k, v]) => before.get(k) !== JSON.stringify(v)));
359
+ const interruptId = nested.approval?.id ?? nested.input?.id ?? nested.consent?.id;
360
+ return {
361
+ status: nested.status,
362
+ text: nested.text,
363
+ stateDelta,
364
+ ...(interruptId ? { interruptId } : {}),
365
+ ...(nested.approval ? { approval: nested.approval } : {}),
366
+ ...(nested.input ? { input: nested.input } : {}),
367
+ ...(nested.consent ? { consent: nested.consent } : {}),
368
+ ...(nested.error ? { error: nested.error } : {}),
369
+ };
370
+ }
371
+ async function runTurnInner(opts, control, _runtime, nestedTurn) {
311
372
  const { config, appName, userId, sessionId, sessionService } = opts;
312
373
  const ev = opts.events ?? {};
313
374
  // Nested references load from the definition's bundle when it has one
@@ -322,11 +383,13 @@ async function runTurnInner(opts, control, _runtime) {
322
383
  const nativeOf = (spec) => ({ runtime: 'native', agent: compileNative(spec), adapterFor: nativeAdapterFor(compileOpts, spec) });
323
384
  /** The orchestrator (a DELEGATE root, or a dispatch classifier). */
324
385
  const compileRoot = async () => nativeOf(await compileSpec(config, compileOpts));
325
- /** A dispatch route: one agent, or a nested workflow's whole graph (ADR 0106). */
386
+ /** A dispatch route: one agent, a nested workflow's whole graph (ADR 0106), or a nested dispatch syndicate's own turn (ADR 0120). */
326
387
  const compileRoute = async (routeCfg) => {
327
388
  const entry = await compileEntrySpec(routeCfg, compileOpts);
328
389
  if (entry.kind === 'workflow')
329
390
  return { ...nativeWorkflowAgent(entry.workflow, compileOpts), workflow: entry.workflow };
391
+ if (entry.kind === 'dispatch')
392
+ return { dispatch: entry.dispatch };
330
393
  return nativeOf(entry.spec);
331
394
  };
332
395
  // Self-correction (ADR 0075): one per turn, from the YAML's retries:.
@@ -374,14 +437,27 @@ async function runTurnInner(opts, control, _runtime) {
374
437
  const delegationKey = { sessions: sessionService, userId, sessionId, appName };
375
438
  /** The first pause waiting below an open delegated call in this conversation, `author`'s calls only when given. */
376
439
  const pauseBelow = async (events, author) => isWorkflowSyndicate(config) ? undefined : (await delegatedPauses(delegationKey, events, author))[0];
440
+ // Pauses inside nested workflows run as a dispatch route or a workflow node (ADR 0119).
441
+ const pauseKey = { ...delegationKey, appName };
442
+ /** A request a workflow's own session holds, raised by a node that is a nested workflow: the one its walk waits on, with the path. */
443
+ const deepen = (pending) => deepestPause(pauseKey, pending);
444
+ /** A dispatch route that is a nested workflow, its walk paused: the conversation's last event is its pause record. */
445
+ const routePaused = isDispatchSyndicate(config) ? await routePause(pauseKey, existing?.events ?? [], subagentNames) : undefined;
377
446
  let resumingBelow;
447
+ let resumingRoute;
378
448
  let resuming;
379
449
  if (decision) {
380
450
  resuming = pendingApproval(existing?.events ?? []);
451
+ if (resuming && isWorkflowSyndicate(config))
452
+ resuming = await deepen(resuming);
381
453
  if (!resuming) {
382
454
  resumingBelow = await pauseBelow(existing?.events ?? []);
383
455
  resuming = resumingBelow?.approval;
384
456
  }
457
+ if (!resuming && routePaused?.approval) {
458
+ resumingRoute = routePaused;
459
+ resuming = routePaused.approval;
460
+ }
385
461
  if (!resuming || resuming.id !== decision.id) {
386
462
  result.status = 'failed';
387
463
  result.error = { code: 'NO_PENDING_APPROVAL', message: `No approval ${decision.id} is waiting in this conversation.` };
@@ -394,17 +470,38 @@ async function runTurnInner(opts, control, _runtime) {
394
470
  // While a call waits for an OAuth grant, the next message resumes it once
395
471
  // the callback has stored the grant; until then it repeats the request and
396
472
  // runs nothing.
397
- const credentialStore = opts.toolCredentials ? pinnedCredentialStore(opts.toolCredentials.store, appName) : undefined;
473
+ // A nested dispatch syndicate's turn reads and stores grants under the root's app, as a delegated subagent does (ADR 0118, ADR 0120).
474
+ const credentialApp = nestedTurn?.credentialApp ?? appName;
475
+ const credentialStore = opts.toolCredentials ? pinnedCredentialStore(opts.toolCredentials.store, credentialApp) : undefined;
476
+ const consentStep = opts.toolCredentials?.consent
477
+ ? nestedTurn
478
+ ? consentPinnedTo(opts.toolCredentials.consent, credentialApp)
479
+ : opts.toolCredentials.consent
480
+ : undefined;
398
481
  const consentPause = (pending) => {
399
482
  result.status = 'input-required';
400
483
  result.consent = pending;
401
484
  result.text = `Authorization needed: ${describeConsent(pending)}. Open the authorization link, then send any message to continue.`;
402
- ev.log?.(`⏸ Authorization needed: ${pending.agent} → ${pending.provider}`);
485
+ ev.log?.(`⏸ Authorization needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.provider}`);
403
486
  return finish();
404
487
  };
405
488
  let granting;
489
+ // 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
+ let grantingBelow;
491
+ // A dispatch route that is a nested dispatch syndicate, waiting for a grant (ADR 0120): its own turn checks the grant, then resumes or asks again.
492
+ let consentRoute;
406
493
  if (!decision && !isWorkflowSyndicate(config)) {
407
- const open = pendingConsent(existing?.events ?? []);
494
+ let open = pendingConsent(existing?.events ?? []);
495
+ if (!open && config.subagents?.length) {
496
+ const below = await pauseBelow(existing?.events ?? []);
497
+ if (below?.consent) {
498
+ grantingBelow = below;
499
+ open = below.consent;
500
+ }
501
+ }
502
+ const routeWaits = !open && !!routePaused?.consent;
503
+ if (routeWaits)
504
+ open = routePaused.consent;
408
505
  if (open) {
409
506
  if (!credentialStore) {
410
507
  result.status = 'failed';
@@ -413,16 +510,21 @@ async function runTurnInner(opts, control, _runtime) {
413
510
  }
414
511
  let granted = false;
415
512
  try {
416
- granted = !!open.provider && !!(await credentialStore.get({ appName, userId, provider: open.provider }));
513
+ granted = !!open.provider && !!(await credentialStore.get({ appName: credentialApp, userId, provider: open.provider }));
417
514
  }
418
515
  catch {
419
516
  // Expired or unreadable: not granted. The request stands.
420
517
  }
421
518
  if (!granted)
422
519
  return consentPause(open);
423
- granting = open;
424
- parts = [credentialResponsePart(open.id, open.provider)];
425
- ev.log?.(`✓ Authorization granted: ${open.agent} → ${open.provider}`);
520
+ // The route's own turn carries the grant's answer down to its call; the message goes to it as it came.
521
+ if (routeWaits)
522
+ consentRoute = routePaused;
523
+ else {
524
+ granting = open;
525
+ parts = [credentialResponsePart(open.id, open.provider)];
526
+ }
527
+ ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
426
528
  }
427
529
  }
428
530
  // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
@@ -433,12 +535,21 @@ async function runTurnInner(opts, control, _runtime) {
433
535
  // A question asked inside a delegated subagent (ADR 0110) is answered the same way: the answer is stored here, and the
434
536
  // open call that leads to the asker carries it down; `answering` names that call and the agent that made it.
435
537
  let answering;
436
- if (!decision && !granting && !isWorkflowSyndicate(config)) {
538
+ /** The parts a paused workflow route's walk resumes on, when the message answers its question: the answer as the explicit reply to its `adk_request_input` call (ADR 0119). */
539
+ let routeAnswer;
540
+ if (!decision && !granting && !consentRoute && !isWorkflowSyndicate(config)) {
437
541
  const plainText = parts.length > 0 && parts.every((p) => typeof p.text === 'string');
438
542
  const own = plainText ? pendingQuestion(existing?.events ?? []) : undefined;
439
543
  const below = plainText && !own ? await pauseBelow(existing?.events ?? []) : undefined;
440
544
  const question = own ?? below?.question;
441
- if (question) {
545
+ if (!question && plainText && routePaused?.question) {
546
+ // The conversation stores the person's words; the route's walk reads them as the reply to the node that asked.
547
+ const waiting = routePaused.question;
548
+ answering = { agent: routePaused.route, id: waiting.id };
549
+ routeAnswer = [{ functionResponse: { id: waiting.id, name: INPUT_REQUEST, response: { result: messageText } } }];
550
+ ev.log?.(`✓ Answer to ${(waiting.path ?? [waiting.node]).join(' → ')}'s question`);
551
+ }
552
+ else if (question) {
442
553
  answering = below ? { agent: below.path[0], id: below.callIds[0] } : { agent: question.node, id: question.id };
443
554
  parts = [questionAnswerPart(question.id, messageText)];
444
555
  ev.log?.(`✓ Answer to ${(question.path ?? [question.node]).join(' → ')}'s question`);
@@ -447,13 +558,19 @@ async function runTurnInner(opts, control, _runtime) {
447
558
  /** The agent a dispatch turn must resume, and the call its interrupted turn holds. */
448
559
  const resumeTarget = resumingBelow
449
560
  ? { agent: resumingBelow.path[0], id: resumingBelow.callIds[0], why: 'resuming an approval' }
450
- : resuming
451
- ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' }
452
- : granting
453
- ? { agent: granting.agent, id: granting.id, why: 'resuming after an authorization' }
454
- : answering
455
- ? { ...answering, why: 'answering its question' }
456
- : undefined;
561
+ : resumingRoute
562
+ ? { agent: resumingRoute.route, id: resuming.id, why: 'resuming an approval' }
563
+ : consentRoute
564
+ ? { agent: consentRoute.route, id: consentRoute.consent.id, why: 'resuming after an authorization' }
565
+ : grantingBelow
566
+ ? { agent: grantingBelow.path[0], id: grantingBelow.callIds[0], why: 'resuming after an authorization' }
567
+ : resuming
568
+ ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' }
569
+ : granting
570
+ ? { agent: granting.agent, id: granting.id, why: 'resuming after an authorization' }
571
+ : answering
572
+ ? { ...answering, why: 'answering its question' }
573
+ : undefined;
457
574
  /** After the answering run: is a call now waiting for a grant? */
458
575
  const awaitingConsent = async (agentName) => {
459
576
  if (!opts.toolCredentials?.consent)
@@ -476,7 +593,7 @@ async function runTurnInner(opts, control, _runtime) {
476
593
  result.status = 'input-required';
477
594
  result.input = input;
478
595
  result.text = input.message;
479
- ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
596
+ ev.log?.(`⏸ Input needed: ${describeInputForLog(input)}`);
480
597
  return finish();
481
598
  };
482
599
  /** After the answering run: does a pause wait inside a delegated call `agentName` left open (ADR 0110)? */
@@ -485,11 +602,13 @@ async function runTurnInner(opts, control, _runtime) {
485
602
  const below = await pauseBelow(after?.events ?? [], agentName);
486
603
  if (below?.approval)
487
604
  return pause(below.approval);
605
+ if (below?.consent)
606
+ return consentPause(below.consent);
488
607
  if (below?.question) {
489
608
  result.status = 'input-required';
490
609
  result.input = below.question;
491
610
  result.text = below.question.message;
492
- ev.log?.(`⏸ Input needed: ${describeInput(below.question)} (${below.path.join(' → ')})`);
611
+ ev.log?.(`⏸ Input needed: ${describeInputForLog({ node: below.question.node, path: below.path })}`);
493
612
  return finish();
494
613
  }
495
614
  return undefined;
@@ -501,6 +620,16 @@ async function runTurnInner(opts, control, _runtime) {
501
620
  ev.log?.(`⏸ Approval needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.tool}`);
502
621
  return finish();
503
622
  };
623
+ /** The turn waits on what a question asks. */
624
+ const question = (input) => {
625
+ result.status = 'input-required';
626
+ result.input = input;
627
+ result.text = input.message;
628
+ ev.log?.(`⏸ Input needed: ${describeInputForLog(input)}`);
629
+ return finish();
630
+ };
631
+ /** A nested workflow's walk, or a nested dispatch syndicate's turn, paused below a route (ADR 0119, ADR 0120): its approval request, its consent request, else its question. */
632
+ const pausedWalk = (paused) => paused.approval ? pause(paused.approval) : paused.consent ? consentPause(paused.consent) : question(paused.question);
504
633
  /** Run ONE agent against ONE session, under the turn's controls. */
505
634
  const runAgent = async (params) => {
506
635
  let stream;
@@ -541,7 +670,7 @@ async function runTurnInner(opts, control, _runtime) {
541
670
  ...(selfCorrection ? { selfCorrection } : {}),
542
671
  // Tool credentials and the consent step (ADR 0072, ADR 0085); the classifier's lane lists no tools.
543
672
  ...(credentialStore ? { credentials: credentialStore } : {}),
544
- ...(opts.toolCredentials?.consent && params.stage !== 'classify' ? { consent: opts.toolCredentials.consent } : {}),
673
+ ...(consentStep && params.stage !== 'classify' ? { consent: consentStep } : {}),
545
674
  ...(compileOpts.log ? { log: compileOpts.log } : {}),
546
675
  });
547
676
  }
@@ -588,15 +717,27 @@ async function runTurnInner(opts, control, _runtime) {
588
717
  };
589
718
  /**
590
719
  * A dispatch route that is a workflow syndicate (ADR 0106): its whole graph
591
- * walked on the child session filed under the route's name (`{ <route>,
592
- * userId, sessionId }`, created from the conversation's state the first
593
- * time, `temp:` keys dropped, and kept), as a delegated nested workflow
594
- * walks (ADR 0098), and drained by the reader a workflow turn uses, so the
595
- * route's answer is what the workflow would answer as its own syndicate.
596
- * The conversation stores the message and the answer, one event authored
597
- * by the route that carries the walk's state writes, so the classifier and
598
- * the next route read the exchange as they read any route's. A node that
599
- * gave up fails the turn NODE_FAILED, as a workflow turn does.
720
+ * walked on the child session filed under the agent path and its kind
721
+ * (`{ <app>/route:<route>, userId, sessionId }`, ADR 0119, ADR 0120; the
722
+ * one filed under an older key is continued when the route ran in the
723
+ * conversation before, entrySession),
724
+ * created from the conversation's state the first time, `temp:` keys
725
+ * dropped, and kept, as a delegated nested workflow walks (ADR 0098), and
726
+ * drained by the reader a workflow turn uses, so the route's answer is what
727
+ * the workflow would answer as its own syndicate. The conversation stores
728
+ * the message and the answer, one event authored by the route that carries
729
+ * the walk's state writes, so the classifier and the next route read the
730
+ * exchange as they read any route's. A node that gave up fails the turn
731
+ * NODE_FAILED, as a workflow turn does.
732
+ *
733
+ * A walk that ends paused (ADR 0119) stores the route's pause record in
734
+ * place of the answer: authored by the route at its own path, no content,
735
+ * the walk's open interrupts in `longRunningToolIds`, its state writes so
736
+ * far. While it is the conversation's last event the route waits: the
737
+ * turn reports the request or question with the path from the route down
738
+ * to the node that asked (interrupts.ts routePause), a decision or a
739
+ * plain-text answer resumes the route without classifying, and the walk
740
+ * reads the answer as its explicit reply (`routeAnswer`).
600
741
  */
601
742
  const runWorkflowRoute = async (route, agent, resolution) => {
602
743
  const store = sessionService;
@@ -605,14 +746,21 @@ async function runTurnInner(opts, control, _runtime) {
605
746
  if (!shared)
606
747
  throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
607
748
  const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
608
- const childKey = { appName: route, userId, sessionId };
609
- const child = (await store.get(childKey)) ?? (await store.create({ ...childKey, state: kept(shared.state) }));
749
+ // 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;
751
+ const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingWalk);
752
+ const childKey = { appName: child.appName, userId, sessionId };
610
753
  const before = new Map(Object.entries(kept(child.state)).map(([k, v]) => [k, JSON.stringify(v)]));
611
754
  const invocationId = `e-${randomUUID()}`;
612
755
  await store.append(shared, createTurnEvent({ invocationId, author: 'user', content: { role: 'user', parts } }));
756
+ /** The walk's state writes this turn, `temp:` keys aside: what the conversation's event for the route carries. */
757
+ const written = async () => {
758
+ const after = kept((await store.get(childKey))?.state);
759
+ return Object.fromEntries(Object.entries(after).filter(([k, v]) => before.get(k) !== JSON.stringify(v)));
760
+ };
613
761
  let walked;
614
762
  try {
615
- walked = await runAgent({ agent, sid: sessionId, appName: route, userParts: parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
763
+ walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
616
764
  }
617
765
  catch (err) {
618
766
  const last = control.stopReason ? undefined : err;
@@ -623,9 +771,22 @@ async function runTurnInner(opts, control, _runtime) {
623
771
  result.error = { code: last instanceof UnsupportedWorkflowResumeError ? 'RESUME_UNSUPPORTED' : last instanceof NodeRunLimitError ? NODE_RUN_LIMIT : 'NODE_FAILED', message: last.message };
624
772
  return { failed: finish() };
625
773
  }
774
+ if (!walked.error && !control.stopReason) {
775
+ // A walk that ended paused (ADR 0119): the conversation stores the route's pause record, which names the walk's
776
+ // open interrupts and carries its state writes so far; the next message finds the walk through it.
777
+ const walkEnd = (await store.get(childKey))?.events.at(-1);
778
+ const paused = walkEnd?.author === route && walkEnd.nodeInfo?.path === route && !walkEnd.content?.parts?.length ? await nestedWorkflowPause(pauseKey, route, 'route') : undefined;
779
+ if (paused) {
780
+ const record = workflowPauseEvent({ name: route, invocationId, input: null, interruptIds: [...(walkEnd.longRunningToolIds ?? [])] });
781
+ const stateDelta = await written();
782
+ if (Object.keys(stateDelta).length)
783
+ record.actions.stateDelta = stateDelta;
784
+ await store.append((await store.get(key)) ?? shared, record);
785
+ return { answer: walked, paused };
786
+ }
787
+ }
626
788
  if (walked.text && !walked.error && !control.stopReason) {
627
- const after = kept((await store.get(childKey))?.state);
628
- const stateDelta = Object.fromEntries(Object.entries(after).filter(([k, v]) => before.get(k) !== JSON.stringify(v)));
789
+ const stateDelta = await written();
629
790
  const answered = createTurnEvent({
630
791
  invocationId,
631
792
  author: route,
@@ -636,6 +797,77 @@ async function runTurnInner(opts, control, _runtime) {
636
797
  }
637
798
  return { answer: walked };
638
799
  };
800
+ /**
801
+ * A dispatch route that is a nested dispatch syndicate (ADR 0120): its own
802
+ * turn (runNestedDispatch: its classifier picks one of its routes, the
803
+ * route answers), on the child session filed under the route's kind
804
+ * (`<app>/route:<route>`, entrySession), created from the conversation's
805
+ * state the first time, `temp:` keys dropped, and kept. The conversation
806
+ * stores the message and, as for a workflow route, one event authored by
807
+ * the route: its answer (the inner route's final text) with the state
808
+ * writes, or, when its turn ends paused, the route's pause record naming
809
+ * the one request it waits on. The turn reports that request with the
810
+ * path from the route down (routePause, which follows the record into the
811
+ * nested conversation), and the next message that answers it resumes the
812
+ * route without classifying: the nested turn reads it as a message at the
813
+ * top reads an answer (an approval decision as it came, a question's
814
+ * answer as the person's text, any message once a grant is stored).
815
+ */
816
+ const runDispatchRoute = async (route, dispatch) => {
817
+ const store = sessionService;
818
+ const key = { appName, userId, sessionId };
819
+ const shared = await store.get(key);
820
+ if (!shared)
821
+ throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
822
+ const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
823
+ const resumingTurn = resumingRoute !== undefined || routeAnswer !== undefined || consentRoute !== undefined;
824
+ const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingTurn);
825
+ const invocationId = `e-${randomUUID()}`;
826
+ await store.append(shared, createTurnEvent({ invocationId, author: 'user', content: { role: 'user', parts } }));
827
+ // A question's answer goes in as the person's words; anything else as it came.
828
+ const nestedParts = routeAnswer ? [{ text: messageText }] : parts;
829
+ const end = await runNestedDispatch(opts, control, _runtime, {
830
+ config: dispatch.config,
831
+ compile: dispatch.compile,
832
+ name: route,
833
+ sessions: store,
834
+ appName: child.appName,
835
+ userId,
836
+ sessionId,
837
+ parts: nestedParts,
838
+ });
839
+ if (control.stopReason)
840
+ return { failed: finish() };
841
+ if (end.status === 'failed') {
842
+ result.status = 'failed';
843
+ result.failedStage = 'dispatch';
844
+ result.error = end.error ?? { code: 'ROUTE_FAILED', message: `${route} failed.` };
845
+ return { failed: finish() };
846
+ }
847
+ const delta = Object.keys(end.stateDelta).length ? { actions: { stateDelta: end.stateDelta } } : {};
848
+ const answer = emptyRun();
849
+ answer.agents.add(route);
850
+ if (end.status === 'input-required' && end.interruptId) {
851
+ const record = workflowPauseEvent({ name: route, invocationId, input: null, interruptIds: [end.interruptId] });
852
+ if (delta.actions)
853
+ record.actions.stateDelta = delta.actions.stateDelta;
854
+ await store.append((await store.get(key)) ?? shared, record);
855
+ const stored = (await store.get(key))?.events ?? [];
856
+ const paused = await routePause(pauseKey, stored, subagentNames);
857
+ if (!paused)
858
+ throw new Error(`${route}: its turn paused on ${end.interruptId}, which its conversation does not hold.`);
859
+ return { answer, paused };
860
+ }
861
+ answer.text = end.text;
862
+ if (end.text) {
863
+ await store.append((await store.get(key)) ?? shared, createTurnEvent({ invocationId, author: route, content: { role: 'model', parts: [{ text: end.text }] }, ...delta }));
864
+ }
865
+ return { answer };
866
+ };
867
+ // While a route's walk waits on a gated call (ADR 0119), a message that is not its decision repeats the request: the
868
+ // walk cannot move past the waiting node, so nothing is stored and nothing runs, as a workflow turn answers it.
869
+ if (!decision && routePaused?.approval)
870
+ return pause(routePaused.approval);
639
871
  let answer;
640
872
  if (isDispatchSyndicate(config)) {
641
873
  // ══ PLAN ═════════════════════════════════════════════════════════════
@@ -651,7 +883,7 @@ async function runTurnInner(opts, control, _runtime) {
651
883
  throw new Error(`Call ${resumeTarget.id} was raised by '${resumeTarget.agent}', which is not a route of this syndicate.`);
652
884
  }
653
885
  resolution = { route: resumeTarget.agent, reason: resumeTarget.why, fellBack: false, fallbackReason: '', viaOverride: false };
654
- decidedBy = resuming ? 'approval' : granting ? 'consent' : 'answer';
886
+ decidedBy = resuming ? 'approval' : granting || consentRoute ? 'consent' : 'answer';
655
887
  }
656
888
  else if (resolution) {
657
889
  ev.log?.(`⇄ Route pinned by override: ${resolution.route}`);
@@ -703,7 +935,8 @@ async function runTurnInner(opts, control, _runtime) {
703
935
  throw new Error(`Dispatch failed: no subagent named '${resolution.route}' is declared.`);
704
936
  result.route = { ...resolution, decidedBy };
705
937
  const routeNote = resolution.reason ? ` — ${resolution.reason}` : '';
706
- ev.log?.(`⇄ Route: ${resolution.route}${routeNote}`);
938
+ // The classifier's reason can paraphrase the person: progress (to the caller) carries it, the log does not.
939
+ ev.log?.(`⇄ Route: ${resolution.route}`);
707
940
  ev.onProgress?.(`Routed to ${resolution.route}${routeNote}`);
708
941
  // ══ DISPATCH ═════════════════════════════════════════════════════════
709
942
  if (routeCfg.a2a_agent_url) {
@@ -716,12 +949,29 @@ async function runTurnInner(opts, control, _runtime) {
716
949
  // projection: other agents' turns would otherwise read as user speech
717
950
  // mixed with their tool payloads (lib/session/transcript.ts).
718
951
  const routeAgent = await compileRoute(routeCfg);
719
- if (routeAgent.workflow) {
952
+ if ('dispatch' in routeAgent) {
953
+ // A nested dispatch syndicate runs as its own turn on its own conversation (ADR 0120).
954
+ const ran = await runDispatchRoute(routeCfg.name, routeAgent.dispatch);
955
+ if ('failed' in ran)
956
+ return ran.failed;
957
+ answer = ran.answer;
958
+ // Its turn paused: the turn waits on what it waits on, with the path from the route down.
959
+ if (ran.paused) {
960
+ result.answer = answer;
961
+ return pausedWalk(ran.paused);
962
+ }
963
+ }
964
+ else if (routeAgent.workflow) {
720
965
  // A workflow route walks its whole graph on its own child session (ADR 0106).
721
966
  const walked = await runWorkflowRoute(routeCfg.name, routeAgent, resolution);
722
967
  if ('failed' in walked)
723
968
  return walked.failed;
724
969
  answer = walked.answer;
970
+ // Its walk paused (ADR 0119): the turn waits on the node that asked, with the path from the route down to it.
971
+ if (walked.paused) {
972
+ result.answer = answer;
973
+ return pausedWalk(walked.paused);
974
+ }
725
975
  }
726
976
  else
727
977
  answer = await runAgent({
@@ -732,13 +982,15 @@ async function runTurnInner(opts, control, _runtime) {
732
982
  // is replayed raw, so the loop finds the call the message answers.
733
983
  sessions: new ProjectedSessionService(sessionService, routeCfg.name, resumingBelow
734
984
  ? { rawFrom: (events) => turnStartOfCall(events, resumingBelow.callIds[0]) }
735
- : resuming
736
- ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
737
- : answering
738
- ? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
739
- : granting
740
- ? { rawFrom: (events) => turnStartOfCall(events, granting.id) }
741
- : {}),
985
+ : grantingBelow
986
+ ? { rawFrom: (events) => turnStartOfCall(events, grantingBelow.callIds[0]) }
987
+ : resuming
988
+ ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
989
+ : answering
990
+ ? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
991
+ : granting
992
+ ? { rawFrom: (events) => turnStartOfCall(events, granting.id) }
993
+ : {}),
742
994
  stage: 'dispatch',
743
995
  route: resolution,
744
996
  publishToolStatus: true,
@@ -788,10 +1040,11 @@ async function runTurnInner(opts, control, _runtime) {
788
1040
  // resumes the graph where it waited.
789
1041
  // While a gated call waits, a message that is not its decision repeats the request: the walk cannot move past
790
1042
  // the waiting node, so nothing is stored and nothing runs, as the A2A server answers it (ADR 0098).
791
- if (!decision && declaresApprovals(config)) {
1043
+ // A node that is a nested workflow raised its walk's request again on the walk's own session (ADR 0119), wherever the gate is declared.
1044
+ if (!decision) {
792
1045
  const open = pendingApproval(existing?.events ?? []);
793
1046
  if (open)
794
- return pause(open);
1047
+ return pause(await deepen(open));
795
1048
  }
796
1049
  const workflowAgent = await compileNativeWorkflowAgent(config, compileOpts);
797
1050
  try {
@@ -828,21 +1081,14 @@ async function runTurnInner(opts, control, _runtime) {
828
1081
  result.error = answer.error;
829
1082
  return finish();
830
1083
  }
831
- // A gated call paused its node, and the walk with it (ADR 0098): the next message answers it.
832
- if (declaresApprovals(config)) {
833
- const after = await sessionService.get({ appName, userId, sessionId });
834
- const pending = pendingApproval(after?.events ?? []);
835
- if (pending)
836
- return pause(pending);
837
- }
838
- if (answer.inputRequests.length) {
839
- const input = answer.inputRequests[answer.inputRequests.length - 1];
840
- result.status = 'input-required';
841
- result.input = input;
842
- result.text = input.message;
843
- ev.log?.(`⏸ Input needed: ${describeInput(input)}`);
844
- return finish();
845
- }
1084
+ // A gated call paused its node, and the walk with it (ADR 0098): the next message answers it. A node that is a
1085
+ // nested workflow pauses on its walk's request, reported with the path down to the node that asked (ADR 0119).
1086
+ const after = await sessionService.get({ appName, userId, sessionId });
1087
+ const pending = pendingApproval(after?.events ?? []);
1088
+ if (pending)
1089
+ return pause(await deepen(pending));
1090
+ if (answer.inputRequests.length)
1091
+ return question(await deepen(answer.inputRequests[answer.inputRequests.length - 1]));
846
1092
  result.text = answer.text;
847
1093
  if (!result.text) {
848
1094
  const failed = answer.nodeErrors[answer.nodeErrors.length - 1];
@@ -907,7 +1153,8 @@ async function runTurnInner(opts, control, _runtime) {
907
1153
  // Named in the syndicate's `guards:` list (and any nested syndicate's). They
908
1154
  // REWRITE rather than retry, on the answering turn's text with every tool
909
1155
  // result it produced. The classifier never reaches here.
910
- const guardNames = collectGuards(config, compileOpts.loadNested);
1156
+ // A nested dispatch syndicate's guards run once, on the turn's answer at the top, which lists them (ADR 0120).
1157
+ const guardNames = nestedTurn ? [] : collectGuards(config, compileOpts.loadNested);
911
1158
  if (result.text && guardNames.length) {
912
1159
  const inputs = answer.toolResultTexts;
913
1160
  for (const guard of resolveGuards(guardNames, (n) => ev.warn?.(`Unknown guard '${n}' — ignored`))) {