melchizedek-agents 1.1.0 → 1.2.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 (118) hide show
  1. package/.env.example +10 -0
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +232 -0
  4. package/DOCUMENTATION.md +91 -16
  5. package/ONBOARDING.md +339 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +5 -2
  8. package/config/agents/examples/council.yaml +1 -1
  9. package/config/agents/examples/critic.yaml +8 -8
  10. package/config/agents/examples/image_production.yaml +4 -3
  11. package/config/agents/syndicate.schema.json +65 -7
  12. package/config/agents/syndicateSchema.yaml +18 -6
  13. package/config/agents/templates/intake_extractor.yaml +3 -2
  14. package/config/agents/templates/systems_operator.yaml +5 -2
  15. package/dist/lib/a2a/app.d.ts +12 -0
  16. package/dist/lib/a2a/app.js +22 -11
  17. package/dist/lib/a2a/executor.js +14 -6
  18. package/dist/lib/a2a/oauthSetup.d.ts +16 -0
  19. package/dist/lib/a2a/oauthSetup.js +57 -3
  20. package/dist/lib/agentDialect.d.ts +18 -8
  21. package/dist/lib/agentDialect.js +36 -19
  22. package/dist/lib/compile.d.ts +38 -7
  23. package/dist/lib/compile.js +57 -37
  24. package/dist/lib/compileNative.d.ts +11 -3
  25. package/dist/lib/compileNative.js +18 -1
  26. package/dist/lib/doctor.d.ts +76 -0
  27. package/dist/lib/doctor.js +159 -7
  28. package/dist/lib/loadSyndicate.d.ts +9 -0
  29. package/dist/lib/models/capabilities.d.ts +9 -0
  30. package/dist/lib/models/capabilities.js +24 -0
  31. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  32. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  33. package/dist/lib/models/claudeAdapter.js +4 -0
  34. package/dist/lib/models/contract.d.ts +5 -2
  35. package/dist/lib/models/gateway.d.ts +2 -1
  36. package/dist/lib/models/gateway.js +4 -2
  37. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  38. package/dist/lib/models/gatewayAdapter.js +8 -0
  39. package/dist/lib/models/geminiAdapter.js +8 -2
  40. package/dist/lib/models/genaiMapping.d.ts +8 -4
  41. package/dist/lib/models/genaiMapping.js +23 -9
  42. package/dist/lib/models/gptAdapter.d.ts +4 -2
  43. package/dist/lib/models/gptAdapter.js +11 -4
  44. package/dist/lib/models/grokAdapter.d.ts +3 -1
  45. package/dist/lib/models/grokAdapter.js +11 -3
  46. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  47. package/dist/lib/models/kimiAdapter.js +3 -3
  48. package/dist/lib/models/reasoning.d.ts +30 -2
  49. package/dist/lib/models/reasoning.js +56 -5
  50. package/dist/lib/onboarding.d.ts +134 -0
  51. package/dist/lib/onboarding.js +539 -0
  52. package/dist/lib/runtime/approvals.d.ts +2 -0
  53. package/dist/lib/runtime/approvals.js +4 -3
  54. package/dist/lib/runtime/credentials.d.ts +6 -0
  55. package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
  56. package/dist/lib/runtime/native/agentLoop.js +2 -0
  57. package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
  58. package/dist/lib/runtime/native/checkpoint.js +13 -19
  59. package/dist/lib/runtime/native/delegate.d.ts +82 -1
  60. package/dist/lib/runtime/native/delegate.js +157 -2
  61. package/dist/lib/runtime/native/interrupts.d.ts +88 -5
  62. package/dist/lib/runtime/native/interrupts.js +220 -30
  63. package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
  64. package/dist/lib/runtime/syndicateTurn.js +295 -62
  65. package/dist/lib/runtime/turnControl.d.ts +41 -0
  66. package/dist/lib/session/transcript.d.ts +9 -0
  67. package/dist/lib/session/transcript.js +20 -1
  68. package/dist/lib/skills.d.ts +18 -0
  69. package/dist/lib/skills.js +73 -1
  70. package/dist/lib/storage/postgres/index.d.ts +6 -1
  71. package/dist/lib/storage/postgres/index.js +1 -1
  72. package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
  73. package/dist/lib/storage/postgres/taskQueue.js +10 -3
  74. package/dist/lib/syndicateSchema.d.ts +38 -1
  75. package/dist/lib/syndicateSchema.js +25 -18
  76. package/dist/lib/tools/auth.d.ts +13 -1
  77. package/dist/lib/tools/auth.js +10 -2
  78. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  79. package/dist/lib/tools/credentialHosts.js +165 -0
  80. package/dist/lib/tools/credentialUses.d.ts +92 -0
  81. package/dist/lib/tools/credentialUses.js +112 -0
  82. package/dist/lib/tools/mcpToolFactory.js +1 -1
  83. package/dist/lib/tools/oauthConsent.js +8 -1
  84. package/dist/lib/tools/oauthHosts.d.ts +2 -0
  85. package/dist/lib/tools/oauthHosts.js +2 -2
  86. package/dist/lib/tools/oauthTools.d.ts +10 -1
  87. package/dist/lib/tools/oauthTools.js +29 -3
  88. package/dist/lib/tools/openapiTools.d.ts +7 -0
  89. package/dist/lib/tools/openapiTools.js +49 -3
  90. package/dist/lib/tools/taskTools.d.ts +32 -2
  91. package/dist/lib/tools/taskTools.js +93 -33
  92. package/dist/lib/workflow/turn.d.ts +22 -3
  93. package/dist/lib/workflow/turn.js +151 -31
  94. package/dist/lib/workflow.d.ts +10 -7
  95. package/dist/lib/workflow.js +10 -7
  96. package/dist/scripts/a2a_server.d.ts +2 -2
  97. package/dist/scripts/a2a_server.js +11 -2
  98. package/dist/scripts/assistant_worker.d.ts +2 -0
  99. package/dist/scripts/assistant_worker.js +13 -4
  100. package/dist/scripts/doctor.d.ts +4 -2
  101. package/dist/scripts/doctor.js +6 -4
  102. package/dist/scripts/setup.d.ts +23 -0
  103. package/dist/scripts/setup.js +154 -0
  104. package/dist/scripts/skills_install.d.ts +3 -1
  105. package/dist/scripts/skills_install.js +17 -2
  106. package/dist/scripts/yaml_codemod.d.ts +12 -2
  107. package/dist/scripts/yaml_codemod.js +115 -33
  108. package/package.json +4 -1
  109. package/skills/README.md +11 -2
  110. package/skills/briefs/_shared.md +1 -1
  111. package/skills/briefs/melchizedek-models.md +4 -2
  112. package/skills/briefs/skills-readme.md +3 -1
  113. package/skills/melchizedek-models/SKILL.md +6 -2
  114. package/skills/melchizedek-onboard/SKILL.md +49 -0
  115. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  116. package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
  117. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  118. 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';
@@ -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';
@@ -281,6 +284,8 @@ export async function runSyndicateTurn(opts) {
281
284
  // `trace: false`: no span from anything the turn runs, to any sink.
282
285
  untraced: opts.trace === false,
283
286
  });
287
+ // A nested dispatch syndicate, wherever the turn reaches one, runs as a turn of its own under these controls (ADR 0120).
288
+ control.nestedDispatch = (run) => runNestedDispatch(opts, control, runtime, run);
284
289
  try {
285
290
  const run = () => runWithTurnControl(control, () => runTurnInner(opts, control, runtime));
286
291
  // A span a library opens through OpenTelemetry's own API (not the
@@ -307,7 +312,50 @@ function nativeWorkflowAgent(spec, opts) {
307
312
  const { graph, agents, resolveTool, workflows } = compileNativeWorkflow(spec);
308
313
  return { runtime: 'native-workflow', graph, agents, adapterFor: nativeAdapterFor(opts, workflowAgentSpecs(spec)), resolveTool, workflows };
309
314
  }
310
- async function runTurnInner(opts, control, _runtime) {
315
+ /**
316
+ * A nested dispatch syndicate's own turn (ADR 0120): runTurnInner on its
317
+ * conversation (`run`, opened by the caller), under the turn's controls,
318
+ * with the turn's store, memory, credentials and log, never streamed, its
319
+ * guards left to the turn at the top (collectGuards already lists them
320
+ * there). Its result is read as a walk's end: the route's final text, the
321
+ * conversation's state writes, and what it waits on, by id.
322
+ */
323
+ async function runNestedDispatch(opts, control, runtime, run) {
324
+ const sessions = run.sessions;
325
+ const key = { appName: run.appName, userId: run.userId, sessionId: run.sessionId };
326
+ const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
327
+ const before = new Map(Object.entries(kept((await sessions.get(key))?.state)).map(([k, v]) => [k, JSON.stringify(v)]));
328
+ const config = run.config;
329
+ const trace = opts.trace === false ? false : { ...(opts.trace ?? {}), syndicateName: config.syndicate_name ?? run.name };
330
+ const nested = await runTurnInner({
331
+ config,
332
+ parts: run.parts,
333
+ appName: run.appName,
334
+ userId: run.userId,
335
+ sessionId: run.sessionId,
336
+ sessionService: sessions,
337
+ ...(opts.memoryService ? { memoryService: opts.memoryService } : {}),
338
+ compile: run.compile,
339
+ ...(opts.toolCredentials ? { toolCredentials: opts.toolCredentials } : {}),
340
+ streaming: false,
341
+ trace,
342
+ events: { ...(opts.events?.log ? { log: opts.events.log } : {}), ...(opts.events?.warn ? { warn: opts.events.warn } : {}) },
343
+ }, control, runtime, { credentialApp: opts.appName });
344
+ const after = kept((await sessions.get(key))?.state);
345
+ const stateDelta = Object.fromEntries(Object.entries(after).filter(([k, v]) => before.get(k) !== JSON.stringify(v)));
346
+ const interruptId = nested.approval?.id ?? nested.input?.id ?? nested.consent?.id;
347
+ return {
348
+ status: nested.status,
349
+ text: nested.text,
350
+ stateDelta,
351
+ ...(interruptId ? { interruptId } : {}),
352
+ ...(nested.approval ? { approval: nested.approval } : {}),
353
+ ...(nested.input ? { input: nested.input } : {}),
354
+ ...(nested.consent ? { consent: nested.consent } : {}),
355
+ ...(nested.error ? { error: nested.error } : {}),
356
+ };
357
+ }
358
+ async function runTurnInner(opts, control, _runtime, nestedTurn) {
311
359
  const { config, appName, userId, sessionId, sessionService } = opts;
312
360
  const ev = opts.events ?? {};
313
361
  // Nested references load from the definition's bundle when it has one
@@ -322,11 +370,13 @@ async function runTurnInner(opts, control, _runtime) {
322
370
  const nativeOf = (spec) => ({ runtime: 'native', agent: compileNative(spec), adapterFor: nativeAdapterFor(compileOpts, spec) });
323
371
  /** The orchestrator (a DELEGATE root, or a dispatch classifier). */
324
372
  const compileRoot = async () => nativeOf(await compileSpec(config, compileOpts));
325
- /** A dispatch route: one agent, or a nested workflow's whole graph (ADR 0106). */
373
+ /** A dispatch route: one agent, a nested workflow's whole graph (ADR 0106), or a nested dispatch syndicate's own turn (ADR 0120). */
326
374
  const compileRoute = async (routeCfg) => {
327
375
  const entry = await compileEntrySpec(routeCfg, compileOpts);
328
376
  if (entry.kind === 'workflow')
329
377
  return { ...nativeWorkflowAgent(entry.workflow, compileOpts), workflow: entry.workflow };
378
+ if (entry.kind === 'dispatch')
379
+ return { dispatch: entry.dispatch };
330
380
  return nativeOf(entry.spec);
331
381
  };
332
382
  // Self-correction (ADR 0075): one per turn, from the YAML's retries:.
@@ -374,14 +424,27 @@ async function runTurnInner(opts, control, _runtime) {
374
424
  const delegationKey = { sessions: sessionService, userId, sessionId, appName };
375
425
  /** The first pause waiting below an open delegated call in this conversation, `author`'s calls only when given. */
376
426
  const pauseBelow = async (events, author) => isWorkflowSyndicate(config) ? undefined : (await delegatedPauses(delegationKey, events, author))[0];
427
+ // Pauses inside nested workflows run as a dispatch route or a workflow node (ADR 0119).
428
+ const pauseKey = { ...delegationKey, appName };
429
+ /** 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. */
430
+ const deepen = (pending) => deepestPause(pauseKey, pending);
431
+ /** A dispatch route that is a nested workflow, its walk paused: the conversation's last event is its pause record. */
432
+ const routePaused = isDispatchSyndicate(config) ? await routePause(pauseKey, existing?.events ?? [], subagentNames) : undefined;
377
433
  let resumingBelow;
434
+ let resumingRoute;
378
435
  let resuming;
379
436
  if (decision) {
380
437
  resuming = pendingApproval(existing?.events ?? []);
438
+ if (resuming && isWorkflowSyndicate(config))
439
+ resuming = await deepen(resuming);
381
440
  if (!resuming) {
382
441
  resumingBelow = await pauseBelow(existing?.events ?? []);
383
442
  resuming = resumingBelow?.approval;
384
443
  }
444
+ if (!resuming && routePaused?.approval) {
445
+ resumingRoute = routePaused;
446
+ resuming = routePaused.approval;
447
+ }
385
448
  if (!resuming || resuming.id !== decision.id) {
386
449
  result.status = 'failed';
387
450
  result.error = { code: 'NO_PENDING_APPROVAL', message: `No approval ${decision.id} is waiting in this conversation.` };
@@ -394,17 +457,38 @@ async function runTurnInner(opts, control, _runtime) {
394
457
  // While a call waits for an OAuth grant, the next message resumes it once
395
458
  // the callback has stored the grant; until then it repeats the request and
396
459
  // runs nothing.
397
- const credentialStore = opts.toolCredentials ? pinnedCredentialStore(opts.toolCredentials.store, appName) : undefined;
460
+ // A nested dispatch syndicate's turn reads and stores grants under the root's app, as a delegated subagent does (ADR 0118, ADR 0120).
461
+ const credentialApp = nestedTurn?.credentialApp ?? appName;
462
+ const credentialStore = opts.toolCredentials ? pinnedCredentialStore(opts.toolCredentials.store, credentialApp) : undefined;
463
+ const consentStep = opts.toolCredentials?.consent
464
+ ? nestedTurn
465
+ ? consentPinnedTo(opts.toolCredentials.consent, credentialApp)
466
+ : opts.toolCredentials.consent
467
+ : undefined;
398
468
  const consentPause = (pending) => {
399
469
  result.status = 'input-required';
400
470
  result.consent = pending;
401
471
  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}`);
472
+ ev.log?.(`⏸ Authorization needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.provider}`);
403
473
  return finish();
404
474
  };
405
475
  let granting;
476
+ // 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.
477
+ let grantingBelow;
478
+ // 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.
479
+ let consentRoute;
406
480
  if (!decision && !isWorkflowSyndicate(config)) {
407
- const open = pendingConsent(existing?.events ?? []);
481
+ let open = pendingConsent(existing?.events ?? []);
482
+ if (!open && config.subagents?.length) {
483
+ const below = await pauseBelow(existing?.events ?? []);
484
+ if (below?.consent) {
485
+ grantingBelow = below;
486
+ open = below.consent;
487
+ }
488
+ }
489
+ const routeWaits = !open && !!routePaused?.consent;
490
+ if (routeWaits)
491
+ open = routePaused.consent;
408
492
  if (open) {
409
493
  if (!credentialStore) {
410
494
  result.status = 'failed';
@@ -413,16 +497,21 @@ async function runTurnInner(opts, control, _runtime) {
413
497
  }
414
498
  let granted = false;
415
499
  try {
416
- granted = !!open.provider && !!(await credentialStore.get({ appName, userId, provider: open.provider }));
500
+ granted = !!open.provider && !!(await credentialStore.get({ appName: credentialApp, userId, provider: open.provider }));
417
501
  }
418
502
  catch {
419
503
  // Expired or unreadable: not granted. The request stands.
420
504
  }
421
505
  if (!granted)
422
506
  return consentPause(open);
423
- granting = open;
424
- parts = [credentialResponsePart(open.id, open.provider)];
425
- ev.log?.(`✓ Authorization granted: ${open.agent} → ${open.provider}`);
507
+ // The route's own turn carries the grant's answer down to its call; the message goes to it as it came.
508
+ if (routeWaits)
509
+ consentRoute = routePaused;
510
+ else {
511
+ granting = open;
512
+ parts = [credentialResponsePart(open.id, open.provider)];
513
+ }
514
+ ev.log?.(`✓ Authorization granted: ${(open.path ?? [open.agent]).join(' → ')} → ${open.provider}`);
426
515
  }
427
516
  }
428
517
  // ── Questions (lib/runtime/questions.ts) ───────────────────────────────────
@@ -433,12 +522,21 @@ async function runTurnInner(opts, control, _runtime) {
433
522
  // A question asked inside a delegated subagent (ADR 0110) is answered the same way: the answer is stored here, and the
434
523
  // open call that leads to the asker carries it down; `answering` names that call and the agent that made it.
435
524
  let answering;
436
- if (!decision && !granting && !isWorkflowSyndicate(config)) {
525
+ /** 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). */
526
+ let routeAnswer;
527
+ if (!decision && !granting && !consentRoute && !isWorkflowSyndicate(config)) {
437
528
  const plainText = parts.length > 0 && parts.every((p) => typeof p.text === 'string');
438
529
  const own = plainText ? pendingQuestion(existing?.events ?? []) : undefined;
439
530
  const below = plainText && !own ? await pauseBelow(existing?.events ?? []) : undefined;
440
531
  const question = own ?? below?.question;
441
- if (question) {
532
+ if (!question && plainText && routePaused?.question) {
533
+ // The conversation stores the person's words; the route's walk reads them as the reply to the node that asked.
534
+ const waiting = routePaused.question;
535
+ answering = { agent: routePaused.route, id: waiting.id };
536
+ routeAnswer = [{ functionResponse: { id: waiting.id, name: INPUT_REQUEST, response: { result: messageText } } }];
537
+ ev.log?.(`✓ Answer to ${(waiting.path ?? [waiting.node]).join(' → ')}'s question`);
538
+ }
539
+ else if (question) {
442
540
  answering = below ? { agent: below.path[0], id: below.callIds[0] } : { agent: question.node, id: question.id };
443
541
  parts = [questionAnswerPart(question.id, messageText)];
444
542
  ev.log?.(`✓ Answer to ${(question.path ?? [question.node]).join(' → ')}'s question`);
@@ -447,13 +545,19 @@ async function runTurnInner(opts, control, _runtime) {
447
545
  /** The agent a dispatch turn must resume, and the call its interrupted turn holds. */
448
546
  const resumeTarget = resumingBelow
449
547
  ? { 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;
548
+ : resumingRoute
549
+ ? { agent: resumingRoute.route, id: resuming.id, why: 'resuming an approval' }
550
+ : consentRoute
551
+ ? { agent: consentRoute.route, id: consentRoute.consent.id, why: 'resuming after an authorization' }
552
+ : grantingBelow
553
+ ? { agent: grantingBelow.path[0], id: grantingBelow.callIds[0], why: 'resuming after an authorization' }
554
+ : resuming
555
+ ? { agent: resuming.agent, id: resuming.id, why: 'resuming an approval' }
556
+ : granting
557
+ ? { agent: granting.agent, id: granting.id, why: 'resuming after an authorization' }
558
+ : answering
559
+ ? { ...answering, why: 'answering its question' }
560
+ : undefined;
457
561
  /** After the answering run: is a call now waiting for a grant? */
458
562
  const awaitingConsent = async (agentName) => {
459
563
  if (!opts.toolCredentials?.consent)
@@ -485,6 +589,8 @@ async function runTurnInner(opts, control, _runtime) {
485
589
  const below = await pauseBelow(after?.events ?? [], agentName);
486
590
  if (below?.approval)
487
591
  return pause(below.approval);
592
+ if (below?.consent)
593
+ return consentPause(below.consent);
488
594
  if (below?.question) {
489
595
  result.status = 'input-required';
490
596
  result.input = below.question;
@@ -501,6 +607,16 @@ async function runTurnInner(opts, control, _runtime) {
501
607
  ev.log?.(`⏸ Approval needed: ${(pending.path ?? [pending.agent]).join(' → ')} → ${pending.tool}`);
502
608
  return finish();
503
609
  };
610
+ /** The turn waits on what a question asks. */
611
+ const question = (input) => {
612
+ result.status = 'input-required';
613
+ result.input = input;
614
+ result.text = input.message;
615
+ ev.log?.(`⏸ Input needed: ${describeInput(input)}${input.path ? ` (${input.path.join(' → ')})` : ''}`);
616
+ return finish();
617
+ };
618
+ /** 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. */
619
+ const pausedWalk = (paused) => paused.approval ? pause(paused.approval) : paused.consent ? consentPause(paused.consent) : question(paused.question);
504
620
  /** Run ONE agent against ONE session, under the turn's controls. */
505
621
  const runAgent = async (params) => {
506
622
  let stream;
@@ -541,7 +657,7 @@ async function runTurnInner(opts, control, _runtime) {
541
657
  ...(selfCorrection ? { selfCorrection } : {}),
542
658
  // Tool credentials and the consent step (ADR 0072, ADR 0085); the classifier's lane lists no tools.
543
659
  ...(credentialStore ? { credentials: credentialStore } : {}),
544
- ...(opts.toolCredentials?.consent && params.stage !== 'classify' ? { consent: opts.toolCredentials.consent } : {}),
660
+ ...(consentStep && params.stage !== 'classify' ? { consent: consentStep } : {}),
545
661
  ...(compileOpts.log ? { log: compileOpts.log } : {}),
546
662
  });
547
663
  }
@@ -588,15 +704,27 @@ async function runTurnInner(opts, control, _runtime) {
588
704
  };
589
705
  /**
590
706
  * 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.
707
+ * walked on the child session filed under the agent path and its kind
708
+ * (`{ <app>/route:<route>, userId, sessionId }`, ADR 0119, ADR 0120; the
709
+ * one filed under an older key is continued when the route ran in the
710
+ * conversation before, entrySession),
711
+ * created from the conversation's state the first time, `temp:` keys
712
+ * dropped, and kept, as a delegated nested workflow walks (ADR 0098), and
713
+ * drained by the reader a workflow turn uses, so the route's answer is what
714
+ * the workflow would answer as its own syndicate. The conversation stores
715
+ * the message and the answer, one event authored by the route that carries
716
+ * the walk's state writes, so the classifier and the next route read the
717
+ * exchange as they read any route's. A node that gave up fails the turn
718
+ * NODE_FAILED, as a workflow turn does.
719
+ *
720
+ * A walk that ends paused (ADR 0119) stores the route's pause record in
721
+ * place of the answer: authored by the route at its own path, no content,
722
+ * the walk's open interrupts in `longRunningToolIds`, its state writes so
723
+ * far. While it is the conversation's last event the route waits: the
724
+ * turn reports the request or question with the path from the route down
725
+ * to the node that asked (interrupts.ts routePause), a decision or a
726
+ * plain-text answer resumes the route without classifying, and the walk
727
+ * reads the answer as its explicit reply (`routeAnswer`).
600
728
  */
601
729
  const runWorkflowRoute = async (route, agent, resolution) => {
602
730
  const store = sessionService;
@@ -605,14 +733,21 @@ async function runTurnInner(opts, control, _runtime) {
605
733
  if (!shared)
606
734
  throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
607
735
  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) }));
736
+ // Filed under the agent path (ADR 0119), else continued under the route's name alone where ADR 0106 filed it.
737
+ const resumingWalk = resumingRoute !== undefined || routeAnswer !== undefined;
738
+ const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingWalk);
739
+ const childKey = { appName: child.appName, userId, sessionId };
610
740
  const before = new Map(Object.entries(kept(child.state)).map(([k, v]) => [k, JSON.stringify(v)]));
611
741
  const invocationId = `e-${randomUUID()}`;
612
742
  await store.append(shared, createTurnEvent({ invocationId, author: 'user', content: { role: 'user', parts } }));
743
+ /** The walk's state writes this turn, `temp:` keys aside: what the conversation's event for the route carries. */
744
+ const written = async () => {
745
+ const after = kept((await store.get(childKey))?.state);
746
+ return Object.fromEntries(Object.entries(after).filter(([k, v]) => before.get(k) !== JSON.stringify(v)));
747
+ };
613
748
  let walked;
614
749
  try {
615
- walked = await runAgent({ agent, sid: sessionId, appName: route, userParts: parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
750
+ walked = await runAgent({ agent, sid: sessionId, appName: child.appName, userParts: routeAnswer ?? parts, sessions: sessionService, stage: 'dispatch', route: resolution, publishToolStatus: true, errorPolicy: 'collect' });
616
751
  }
617
752
  catch (err) {
618
753
  const last = control.stopReason ? undefined : err;
@@ -623,9 +758,22 @@ async function runTurnInner(opts, control, _runtime) {
623
758
  result.error = { code: last instanceof UnsupportedWorkflowResumeError ? 'RESUME_UNSUPPORTED' : last instanceof NodeRunLimitError ? NODE_RUN_LIMIT : 'NODE_FAILED', message: last.message };
624
759
  return { failed: finish() };
625
760
  }
761
+ if (!walked.error && !control.stopReason) {
762
+ // A walk that ended paused (ADR 0119): the conversation stores the route's pause record, which names the walk's
763
+ // open interrupts and carries its state writes so far; the next message finds the walk through it.
764
+ const walkEnd = (await store.get(childKey))?.events.at(-1);
765
+ const paused = walkEnd?.author === route && walkEnd.nodeInfo?.path === route && !walkEnd.content?.parts?.length ? await nestedWorkflowPause(pauseKey, route, 'route') : undefined;
766
+ if (paused) {
767
+ const record = workflowPauseEvent({ name: route, invocationId, input: null, interruptIds: [...(walkEnd.longRunningToolIds ?? [])] });
768
+ const stateDelta = await written();
769
+ if (Object.keys(stateDelta).length)
770
+ record.actions.stateDelta = stateDelta;
771
+ await store.append((await store.get(key)) ?? shared, record);
772
+ return { answer: walked, paused };
773
+ }
774
+ }
626
775
  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)));
776
+ const stateDelta = await written();
629
777
  const answered = createTurnEvent({
630
778
  invocationId,
631
779
  author: route,
@@ -636,6 +784,77 @@ async function runTurnInner(opts, control, _runtime) {
636
784
  }
637
785
  return { answer: walked };
638
786
  };
787
+ /**
788
+ * A dispatch route that is a nested dispatch syndicate (ADR 0120): its own
789
+ * turn (runNestedDispatch: its classifier picks one of its routes, the
790
+ * route answers), on the child session filed under the route's kind
791
+ * (`<app>/route:<route>`, entrySession), created from the conversation's
792
+ * state the first time, `temp:` keys dropped, and kept. The conversation
793
+ * stores the message and, as for a workflow route, one event authored by
794
+ * the route: its answer (the inner route's final text) with the state
795
+ * writes, or, when its turn ends paused, the route's pause record naming
796
+ * the one request it waits on. The turn reports that request with the
797
+ * path from the route down (routePause, which follows the record into the
798
+ * nested conversation), and the next message that answers it resumes the
799
+ * route without classifying: the nested turn reads it as a message at the
800
+ * top reads an answer (an approval decision as it came, a question's
801
+ * answer as the person's text, any message once a grant is stored).
802
+ */
803
+ const runDispatchRoute = async (route, dispatch) => {
804
+ const store = sessionService;
805
+ const key = { appName, userId, sessionId };
806
+ const shared = await store.get(key);
807
+ if (!shared)
808
+ throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
809
+ const kept = (state) => Object.fromEntries(Object.entries(state ?? {}).filter(([k]) => !k.startsWith(TEMP_STATE_PREFIX)));
810
+ const resumingTurn = resumingRoute !== undefined || routeAnswer !== undefined || consentRoute !== undefined;
811
+ const child = await entrySession(store, { appName, userId, sessionId, events: shared.events }, route, 'route', kept(shared.state), resumingTurn);
812
+ const invocationId = `e-${randomUUID()}`;
813
+ await store.append(shared, createTurnEvent({ invocationId, author: 'user', content: { role: 'user', parts } }));
814
+ // A question's answer goes in as the person's words; anything else as it came.
815
+ const nestedParts = routeAnswer ? [{ text: messageText }] : parts;
816
+ const end = await runNestedDispatch(opts, control, _runtime, {
817
+ config: dispatch.config,
818
+ compile: dispatch.compile,
819
+ name: route,
820
+ sessions: store,
821
+ appName: child.appName,
822
+ userId,
823
+ sessionId,
824
+ parts: nestedParts,
825
+ });
826
+ if (control.stopReason)
827
+ return { failed: finish() };
828
+ if (end.status === 'failed') {
829
+ result.status = 'failed';
830
+ result.failedStage = 'dispatch';
831
+ result.error = end.error ?? { code: 'ROUTE_FAILED', message: `${route} failed.` };
832
+ return { failed: finish() };
833
+ }
834
+ const delta = Object.keys(end.stateDelta).length ? { actions: { stateDelta: end.stateDelta } } : {};
835
+ const answer = emptyRun();
836
+ answer.agents.add(route);
837
+ if (end.status === 'input-required' && end.interruptId) {
838
+ const record = workflowPauseEvent({ name: route, invocationId, input: null, interruptIds: [end.interruptId] });
839
+ if (delta.actions)
840
+ record.actions.stateDelta = delta.actions.stateDelta;
841
+ await store.append((await store.get(key)) ?? shared, record);
842
+ const stored = (await store.get(key))?.events ?? [];
843
+ const paused = await routePause(pauseKey, stored, subagentNames);
844
+ if (!paused)
845
+ throw new Error(`${route}: its turn paused on ${end.interruptId}, which its conversation does not hold.`);
846
+ return { answer, paused };
847
+ }
848
+ answer.text = end.text;
849
+ if (end.text) {
850
+ await store.append((await store.get(key)) ?? shared, createTurnEvent({ invocationId, author: route, content: { role: 'model', parts: [{ text: end.text }] }, ...delta }));
851
+ }
852
+ return { answer };
853
+ };
854
+ // While a route's walk waits on a gated call (ADR 0119), a message that is not its decision repeats the request: the
855
+ // walk cannot move past the waiting node, so nothing is stored and nothing runs, as a workflow turn answers it.
856
+ if (!decision && routePaused?.approval)
857
+ return pause(routePaused.approval);
639
858
  let answer;
640
859
  if (isDispatchSyndicate(config)) {
641
860
  // ══ PLAN ═════════════════════════════════════════════════════════════
@@ -651,7 +870,7 @@ async function runTurnInner(opts, control, _runtime) {
651
870
  throw new Error(`Call ${resumeTarget.id} was raised by '${resumeTarget.agent}', which is not a route of this syndicate.`);
652
871
  }
653
872
  resolution = { route: resumeTarget.agent, reason: resumeTarget.why, fellBack: false, fallbackReason: '', viaOverride: false };
654
- decidedBy = resuming ? 'approval' : granting ? 'consent' : 'answer';
873
+ decidedBy = resuming ? 'approval' : granting || consentRoute ? 'consent' : 'answer';
655
874
  }
656
875
  else if (resolution) {
657
876
  ev.log?.(`⇄ Route pinned by override: ${resolution.route}`);
@@ -716,12 +935,29 @@ async function runTurnInner(opts, control, _runtime) {
716
935
  // projection: other agents' turns would otherwise read as user speech
717
936
  // mixed with their tool payloads (lib/session/transcript.ts).
718
937
  const routeAgent = await compileRoute(routeCfg);
719
- if (routeAgent.workflow) {
938
+ if ('dispatch' in routeAgent) {
939
+ // A nested dispatch syndicate runs as its own turn on its own conversation (ADR 0120).
940
+ const ran = await runDispatchRoute(routeCfg.name, routeAgent.dispatch);
941
+ if ('failed' in ran)
942
+ return ran.failed;
943
+ answer = ran.answer;
944
+ // Its turn paused: the turn waits on what it waits on, with the path from the route down.
945
+ if (ran.paused) {
946
+ result.answer = answer;
947
+ return pausedWalk(ran.paused);
948
+ }
949
+ }
950
+ else if (routeAgent.workflow) {
720
951
  // A workflow route walks its whole graph on its own child session (ADR 0106).
721
952
  const walked = await runWorkflowRoute(routeCfg.name, routeAgent, resolution);
722
953
  if ('failed' in walked)
723
954
  return walked.failed;
724
955
  answer = walked.answer;
956
+ // Its walk paused (ADR 0119): the turn waits on the node that asked, with the path from the route down to it.
957
+ if (walked.paused) {
958
+ result.answer = answer;
959
+ return pausedWalk(walked.paused);
960
+ }
725
961
  }
726
962
  else
727
963
  answer = await runAgent({
@@ -732,13 +968,15 @@ async function runTurnInner(opts, control, _runtime) {
732
968
  // is replayed raw, so the loop finds the call the message answers.
733
969
  sessions: new ProjectedSessionService(sessionService, routeCfg.name, resumingBelow
734
970
  ? { 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
- : {}),
971
+ : grantingBelow
972
+ ? { rawFrom: (events) => turnStartOfCall(events, grantingBelow.callIds[0]) }
973
+ : resuming
974
+ ? { rawFrom: (events) => interruptedTurnStart(events, resuming.id) }
975
+ : answering
976
+ ? { rawFrom: (events) => turnStartOfCall(events, answering.id) }
977
+ : granting
978
+ ? { rawFrom: (events) => turnStartOfCall(events, granting.id) }
979
+ : {}),
742
980
  stage: 'dispatch',
743
981
  route: resolution,
744
982
  publishToolStatus: true,
@@ -788,10 +1026,11 @@ async function runTurnInner(opts, control, _runtime) {
788
1026
  // resumes the graph where it waited.
789
1027
  // While a gated call waits, a message that is not its decision repeats the request: the walk cannot move past
790
1028
  // the waiting node, so nothing is stored and nothing runs, as the A2A server answers it (ADR 0098).
791
- if (!decision && declaresApprovals(config)) {
1029
+ // 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.
1030
+ if (!decision) {
792
1031
  const open = pendingApproval(existing?.events ?? []);
793
1032
  if (open)
794
- return pause(open);
1033
+ return pause(await deepen(open));
795
1034
  }
796
1035
  const workflowAgent = await compileNativeWorkflowAgent(config, compileOpts);
797
1036
  try {
@@ -828,21 +1067,14 @@ async function runTurnInner(opts, control, _runtime) {
828
1067
  result.error = answer.error;
829
1068
  return finish();
830
1069
  }
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
- }
1070
+ // A gated call paused its node, and the walk with it (ADR 0098): the next message answers it. A node that is a
1071
+ // nested workflow pauses on its walk's request, reported with the path down to the node that asked (ADR 0119).
1072
+ const after = await sessionService.get({ appName, userId, sessionId });
1073
+ const pending = pendingApproval(after?.events ?? []);
1074
+ if (pending)
1075
+ return pause(await deepen(pending));
1076
+ if (answer.inputRequests.length)
1077
+ return question(await deepen(answer.inputRequests[answer.inputRequests.length - 1]));
846
1078
  result.text = answer.text;
847
1079
  if (!result.text) {
848
1080
  const failed = answer.nodeErrors[answer.nodeErrors.length - 1];
@@ -907,7 +1139,8 @@ async function runTurnInner(opts, control, _runtime) {
907
1139
  // Named in the syndicate's `guards:` list (and any nested syndicate's). They
908
1140
  // REWRITE rather than retry, on the answering turn's text with every tool
909
1141
  // result it produced. The classifier never reaches here.
910
- const guardNames = collectGuards(config, compileOpts.loadNested);
1142
+ // A nested dispatch syndicate's guards run once, on the turn's answer at the top, which lists them (ADR 0120).
1143
+ const guardNames = nestedTurn ? [] : collectGuards(config, compileOpts.loadNested);
911
1144
  if (result.text && guardNames.length) {
912
1145
  const inputs = answer.toolResultTexts;
913
1146
  for (const guard of resolveGuards(guardNames, (n) => ev.warn?.(`Unknown guard '${n}' — ignored`))) {
@@ -45,6 +45,47 @@ export interface TurnControl {
45
45
  stopReason?: TurnStopReason;
46
46
  /** Stop the turn. The first reason wins. */
47
47
  stop(reason: TurnStopReason): void;
48
+ /**
49
+ * Runs a nested dispatch syndicate as its own turn on its own
50
+ * conversation, under this turn's controls (ADR 0120): set by the turn
51
+ * runner (lib/runtime/syndicateTurn.ts), read where a nested dispatch
52
+ * syndicate runs (a delegated call, a dispatch route, a workflow node),
53
+ * which sit below the turn runner and cannot import it.
54
+ */
55
+ nestedDispatch?: (run: NestedDispatchRun) => Promise<NestedDispatchEnd>;
56
+ }
57
+ /** One run of a nested dispatch syndicate (ADR 0120): what it is, where its conversation is filed, and the message. */
58
+ export interface NestedDispatchRun {
59
+ /** The nested syndicate's config (a SyndicateYamlConfig with a `dispatch:` block). */
60
+ config: unknown;
61
+ /** The compile options it loads its routes with (CompileOptions, its nesting chain included). */
62
+ compile: unknown;
63
+ /** The entry's name: the nested syndicate as its caller lists it. */
64
+ name: string;
65
+ /** The store, and the key its conversation is filed under (already opened by the caller). */
66
+ sessions: unknown;
67
+ appName: string;
68
+ userId: string;
69
+ sessionId: string;
70
+ /** The message's parts: the request, or the answer to what waits in it. */
71
+ parts: unknown[];
72
+ }
73
+ /** How a nested dispatch syndicate's turn ended (ADR 0120). */
74
+ export interface NestedDispatchEnd {
75
+ status: 'completed' | 'input-required' | 'failed' | 'canceled';
76
+ /** The route's final text: the nested syndicate's answer. */
77
+ text: string;
78
+ /** Its conversation's state writes this run, `temp:` keys aside. */
79
+ stateDelta: Record<string, unknown>;
80
+ /** What it waits on, when paused: the request's id, and the request as the turn reports it (a PendingApproval, a PendingInput, a PendingConsent). */
81
+ interruptId?: string;
82
+ approval?: unknown;
83
+ input?: unknown;
84
+ consent?: unknown;
85
+ error?: {
86
+ code: string;
87
+ message: string;
88
+ };
48
89
  }
49
90
  export interface TurnControlOptions {
50
91
  maxLlmCalls?: number;
@@ -232,6 +232,15 @@ export declare class ProjectedSessionService implements SessionService {
232
232
  * The event lands in the projected session the runtime holds and, through
233
233
  * the store, in the real one `get` read. A session `get` did not hand out
234
234
  * is the real one, and the store alone applies the event to it.
235
+ *
236
+ * A store may answer an append with events of its own: a durable run's
237
+ * store restoring a checkpoint (lib/runtime/native/checkpoint.ts, ADR 0113)
238
+ * answers the opening message with the run's stored events, the stored
239
+ * opening returned in place of the one given. The projected session then
240
+ * takes, in place of the event given, every event the real session gained,
241
+ * unprojected: they are the current turn, which is never projected. The
242
+ * agent loop thereby reads the restored steps, as it would on the store's
243
+ * own session.
235
244
  */
236
245
  append(session: EngineSession, event: TurnEvent): Promise<TurnEvent>;
237
246
  }