melchizedek-agents 1.3.0 → 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.
- package/CHANGELOG.md +90 -0
- package/DOCUMENTATION.md +18 -3
- package/ONBOARDING.md +3 -3
- package/README.md +1 -0
- package/config/agents/examples/connectors.yaml +4 -1
- package/config/agents/examples/event_assistant.yaml +82 -0
- package/dist/lib/chatgpt/oauth.d.ts +4 -3
- package/dist/lib/chatgpt/oauth.js +36 -12
- package/dist/lib/compileNative.js +1 -0
- package/dist/lib/models/gptAdapter.js +11 -1
- package/dist/lib/runtime/credentials.d.ts +21 -0
- package/dist/lib/runtime/credentials.js +32 -0
- package/dist/lib/runtime/native/delegate.d.ts +2 -0
- package/dist/lib/runtime/native/delegate.js +9 -2
- package/dist/lib/runtime/native/interrupts.d.ts +33 -3
- package/dist/lib/runtime/native/interrupts.js +99 -9
- package/dist/lib/runtime/syndicateTurn.js +41 -11
- package/dist/lib/syndicateSchema.js +18 -0
- package/dist/lib/tools/mcpToolFactory.js +83 -10
- package/dist/lib/tools/mcpTransports.d.ts +4 -2
- package/dist/lib/tools/mcpTransports.js +4 -2
- package/dist/lib/workflow/agentNode.d.ts +19 -3
- package/dist/lib/workflow/agentNode.js +106 -13
- package/dist/lib/workflow/resume.d.ts +8 -7
- package/dist/lib/workflow/resume.js +17 -10
- package/dist/lib/workflow/turn.d.ts +12 -2
- package/dist/lib/workflow/turn.js +22 -12
- package/package.json +16 -12
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
* grantedCalls is that preprocessor, check for check, with one difference
|
|
59
59
|
* in what the answer carries:
|
|
60
60
|
* 1. The answers: the function responses named `adk_request_credential`
|
|
61
|
-
* in the
|
|
61
|
+
* in the latest user event, provided this agent has stored nothing
|
|
62
|
+
* with content since (at the top that is the last event with content;
|
|
63
|
+
* in a walk another node's events may come between, ADR 0128). None:
|
|
62
64
|
* nothing happens.
|
|
63
65
|
* 2. The requests they answer: `adk_request_credential` calls this agent
|
|
64
66
|
* made, by id. An answer naming no such request is ignored, as ADK
|
|
@@ -102,7 +104,8 @@
|
|
|
102
104
|
* workflow under the call's name) makes an approval request an agent node
|
|
103
105
|
* raised, or an ask_user node's question, the pause, with the node that
|
|
104
106
|
* asked at the end of the path. The walk stops there: a pause inside a
|
|
105
|
-
* workflow node's own delegation does not run (lib/workflow/agentNode.ts)
|
|
107
|
+
* workflow node's own delegation does not run (lib/workflow/agentNode.ts),
|
|
108
|
+
* except an OAuth consent request, which does (ADR 0128, below).
|
|
106
109
|
*
|
|
107
110
|
* NESTED WORKFLOWS AS A ROUTE OR A NODE (ADR 0119). A workflow node that is
|
|
108
111
|
* a nested workflow raises its walk's open requests again on its caller's
|
|
@@ -126,6 +129,19 @@
|
|
|
126
129
|
* call, the route's pause record or the node's raised request, and the
|
|
127
130
|
* nested turn reads it as a message at the top.
|
|
128
131
|
*
|
|
132
|
+
* CONSENT INSIDE A WALK (ADR 0128). An agent node's own consent request
|
|
133
|
+
* is an interrupt of the walk like its approval request: the walk's pause
|
|
134
|
+
* record names it, and walkConsents finds it there for the turn. A node
|
|
135
|
+
* whose request waits below it (in a call its agent left open, in a nested
|
|
136
|
+
* walk, in a nested dispatch syndicate's conversation) raises a copy of it
|
|
137
|
+
* on the walk under the same id (lib/workflow/agentNode.ts,
|
|
138
|
+
* lib/workflow/turn.ts), and consentBelow follows the copy down to the
|
|
139
|
+
* agent that asked; workflowPause reads a credential request as it reads
|
|
140
|
+
* an approval or a question, so a walk nested below a route, a node or a
|
|
141
|
+
* delegated call is followed the same way. grantedCalls reads the latest
|
|
142
|
+
* user event unless the agent has written since, so another node's events
|
|
143
|
+
* between the grant's answer and the node's resume do not hide it.
|
|
144
|
+
*
|
|
129
145
|
* CONSENT AND SKILL SCRIPTS INSIDE DELEGATED SUBAGENTS (ADR 0118). A skill
|
|
130
146
|
* script run asks for approval through the same `adk_request_confirmation`
|
|
131
147
|
* call a gated tool stores, so the walk and the resume above carry it with
|
|
@@ -144,7 +160,7 @@ import { nativeToolOf } from '../../models/schemaNormalize.js';
|
|
|
144
160
|
import { instructionToolOf, isTool, toolOf } from '../../tools/tool.js';
|
|
145
161
|
import { inputRequestFrom } from '../../workflowConfig.js';
|
|
146
162
|
import { approvalRequestOf, pendingApproval } from '../approvals.js';
|
|
147
|
-
import { pendingConsent } from '../credentials.js';
|
|
163
|
+
import { consentFrom, pendingConsent } from '../credentials.js';
|
|
148
164
|
import { getFunctionCalls, getFunctionResponses } from '../events.js';
|
|
149
165
|
import { ASK_USER, pendingQuestion } from '../questions.js';
|
|
150
166
|
import { childAppName, entryAppName, legacyChild, legacyEntry } from './delegate.js';
|
|
@@ -308,11 +324,17 @@ export async function grantedCalls(agent, scope) {
|
|
|
308
324
|
const events = scope.session.events;
|
|
309
325
|
if (events.length === 0)
|
|
310
326
|
return undefined;
|
|
327
|
+
// The latest user event, unless this agent has written since (ADR 0128): in a walk, another node's events may
|
|
328
|
+
// follow the person's answer before this node reads it; at the top only this agent writes after the message.
|
|
311
329
|
let last;
|
|
312
|
-
for (let i = events.length - 1; i >= 0 && !last; i--)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
330
|
+
for (let i = events.length - 1; i >= 0 && !last; i--) {
|
|
331
|
+
const event = events[i];
|
|
332
|
+
if (event.author === agent.name && event.content !== undefined)
|
|
333
|
+
return undefined;
|
|
334
|
+
if (event.author === 'user' && event.content !== undefined)
|
|
335
|
+
last = event;
|
|
336
|
+
}
|
|
337
|
+
if (!last)
|
|
316
338
|
return undefined;
|
|
317
339
|
const answers = new Map();
|
|
318
340
|
for (const r of getFunctionResponses(last))
|
|
@@ -427,6 +449,7 @@ async function pauseBelow(key, caller, call, depth, seen) {
|
|
|
427
449
|
callIds: [id],
|
|
428
450
|
...(walked.approval ? { approval: { ...walked.approval, path } } : {}),
|
|
429
451
|
...(walked.question ? { question: { ...walked.question, path } } : {}),
|
|
452
|
+
...(walked.consent ? { consent: { ...walked.consent, path } } : {}),
|
|
430
453
|
};
|
|
431
454
|
}
|
|
432
455
|
const approval = pendingApproval(child.events);
|
|
@@ -540,8 +563,9 @@ function workflowPause(events, name, id) {
|
|
|
540
563
|
const open = new Set((record.longRunningToolIds ?? []).filter((x) => id === undefined || x === id));
|
|
541
564
|
if (open.size === 0)
|
|
542
565
|
return undefined;
|
|
543
|
-
// The latest open request the record names, an approval first
|
|
544
|
-
|
|
566
|
+
// The latest open request the record names, an approval first, then a question, then a consent request (ADR 0128):
|
|
567
|
+
// the walk stores node inputs as user turns, so a later one may follow it.
|
|
568
|
+
for (const kind of [REQUEST_CONFIRMATION_CALL, REQUEST_INPUT_CALL, REQUEST_CREDENTIAL_CALL]) {
|
|
545
569
|
for (let i = events.length - 2; i >= 0; i--) {
|
|
546
570
|
const event = events[i];
|
|
547
571
|
if (!event.author || event.author === 'user')
|
|
@@ -551,6 +575,12 @@ function workflowPause(events, name, id) {
|
|
|
551
575
|
continue;
|
|
552
576
|
if (kind === REQUEST_CONFIRMATION_CALL)
|
|
553
577
|
return { asker: event.author, approval: approvalRequestOf(event, call) };
|
|
578
|
+
if (kind === REQUEST_CREDENTIAL_CALL) {
|
|
579
|
+
const consent = consentFrom(event.author, call);
|
|
580
|
+
if (consent)
|
|
581
|
+
return { asker: event.author, consent };
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
554
584
|
const question = inputRequestFrom(event.author, call);
|
|
555
585
|
if (question && question.id === call.id)
|
|
556
586
|
return { asker: event.author, question };
|
|
@@ -559,6 +589,58 @@ function workflowPause(events, name, id) {
|
|
|
559
589
|
}
|
|
560
590
|
return undefined;
|
|
561
591
|
}
|
|
592
|
+
/** Whether `asker` made the call `functionCallId` itself in `events`: its consent request is its own, not one it raised again. */
|
|
593
|
+
function madeCall(events, asker, functionCallId) {
|
|
594
|
+
return events.some((event) => event.author === asker && getFunctionCalls(event).some((call) => call.id === functionCallId && call.name !== REQUEST_CREDENTIAL_CALL));
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The consent request `id` waiting below the node `asker` of a walk whose
|
|
598
|
+
* session holds `events` (filed under `key.appName`), when the node raised
|
|
599
|
+
* it again on the walk (ADR 0128): inside the node's own child session (a
|
|
600
|
+
* nested workflow, or a nested dispatch syndicate's conversation,
|
|
601
|
+
* nestedWorkflowPause), else inside a call the node left open (a delegated
|
|
602
|
+
* subagent, delegatedPauses). Its `path` runs from `asker` down to the
|
|
603
|
+
* agent that asked. Undefined when nothing below holds it.
|
|
604
|
+
*/
|
|
605
|
+
async function consentBelow(key, events, asker, id, depth) {
|
|
606
|
+
if (depth >= MAX_DELEGATION_DEPTH)
|
|
607
|
+
return undefined;
|
|
608
|
+
const nested = await nestedWorkflowPause(key, asker, 'node', id, depth + 1);
|
|
609
|
+
if (nested?.consent?.id === id)
|
|
610
|
+
return nested.consent;
|
|
611
|
+
const below = await delegatedPauses({ ...key, delegated: false }, events, asker, depth + 1);
|
|
612
|
+
return below.find((pause) => pause.consent?.id === id)?.consent;
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* The OAuth consent requests a walk waits on (ADR 0128): the credential
|
|
616
|
+
* requests among the open interrupts its pause record names (the session's
|
|
617
|
+
* last event, authored by the workflow at its own path, no content), each
|
|
618
|
+
* followed down to the agent that asked. A node's own request comes back as
|
|
619
|
+
* it is, `agent` the node and no `path`, as a node's own approval request
|
|
620
|
+
* does; one a node raised again on the walk (a delegated subagent's, a
|
|
621
|
+
* nested walk's, a nested dispatch syndicate's) carries the `path` from the
|
|
622
|
+
* node down. `key.appName` is the walk session's app. Empty when the walk is
|
|
623
|
+
* not paused, or waits on no grant.
|
|
624
|
+
*/
|
|
625
|
+
export async function walkConsents(key, events, depth = 0) {
|
|
626
|
+
const record = events[events.length - 1];
|
|
627
|
+
const name = record?.author;
|
|
628
|
+
if (!record || !name || name === 'user' || (record.content?.parts ?? []).length > 0 || record.nodeInfo?.path !== name)
|
|
629
|
+
return [];
|
|
630
|
+
const out = [];
|
|
631
|
+
for (const id of record.longRunningToolIds ?? []) {
|
|
632
|
+
const walked = workflowPause(events, name, id);
|
|
633
|
+
const consent = walked?.consent;
|
|
634
|
+
if (!walked || !consent)
|
|
635
|
+
continue;
|
|
636
|
+
if (madeCall(events, walked.asker, consent.functionCallId)) {
|
|
637
|
+
out.push(consent);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
out.push((await consentBelow(key, events, walked.asker, id, depth)) ?? consent);
|
|
641
|
+
}
|
|
642
|
+
return out;
|
|
643
|
+
}
|
|
562
644
|
/**
|
|
563
645
|
* The pause the walk of `name` holds in `child` (its child session), with
|
|
564
646
|
* `id` the only interrupt that counts when given: the node that asked, or,
|
|
@@ -569,6 +651,14 @@ async function workflowPauseIn(key, child, name, id, depth = 0) {
|
|
|
569
651
|
const walked = workflowPause(child.events, name, id);
|
|
570
652
|
if (!walked)
|
|
571
653
|
return undefined;
|
|
654
|
+
if (walked.consent) {
|
|
655
|
+
// A node's own consent request, else one it raised again: followed down to the agent that asked (ADR 0128).
|
|
656
|
+
const below = madeCall(child.events, walked.asker, walked.consent.functionCallId)
|
|
657
|
+
? undefined
|
|
658
|
+
: await consentBelow({ ...key, appName: child.appName }, child.events, walked.asker, walked.consent.id, depth + 1);
|
|
659
|
+
const path = [name, ...(below?.path ?? [walked.asker])];
|
|
660
|
+
return { path, consent: { ...(below ?? walked.consent), path } };
|
|
661
|
+
}
|
|
572
662
|
const found = (walked.approval?.id ?? walked.question?.id);
|
|
573
663
|
const deeper = await nestedWorkflowPause({ ...key, appName: child.appName }, walked.asker, 'node', found, depth + 1);
|
|
574
664
|
const path = deeper ? [name, ...deeper.path] : [name, walked.asker];
|
|
@@ -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
|
-
|
|
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
|
|
9
|
-
*
|
|
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
|
|
142
|
-
*
|
|
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
|
-
|
|
221
|
+
conn = await attempt('streamable_http');
|
|
165
222
|
}
|
|
166
223
|
catch (error) {
|
|
167
|
-
if (
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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,
|
|
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'
|
|
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>;
|