@ryubyte/dsh-a2a 0.1.3 → 0.1.4
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/README.en.md +54 -11
- package/README.md +23 -6
- package/lib/client.js +110 -12
- package/lib/index.js +454 -26
- package/lib/types/a2a-config.d.ts +2 -0
- package/lib/types/a2a-config.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/dashboard.d.ts +8 -2
- package/lib/types/dashboard.d.ts.map +1 -1
- package/lib/types/index.d.ts +41 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/outbound.d.ts.map +1 -1
- package/lib/types/server.d.ts +225 -1
- package/lib/types/server.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/a2a-config.ts +3 -0
- package/src/client/index.ts +111 -12
- package/src/dashboard.ts +16 -4
- package/src/index.ts +221 -16
- package/src/outbound.ts +31 -4
- package/src/server.ts +474 -9
package/lib/index.js
CHANGED
|
@@ -418,6 +418,17 @@ async function registerAgentTools(tools, options) {
|
|
|
418
418
|
connectionId,
|
|
419
419
|
card
|
|
420
420
|
});
|
|
421
|
+
const contextByAgent = /* @__PURE__ */ new Map();
|
|
422
|
+
const fallbackContextKey = "\0fallback";
|
|
423
|
+
const contextFor = (agentId) => {
|
|
424
|
+
const key = agentId ?? fallbackContextKey;
|
|
425
|
+
let ctxId = contextByAgent.get(key);
|
|
426
|
+
if (ctxId === void 0) {
|
|
427
|
+
ctxId = `a2a-out-${crypto.randomUUID()}`;
|
|
428
|
+
contextByAgent.set(key, ctxId);
|
|
429
|
+
}
|
|
430
|
+
return ctxId;
|
|
431
|
+
};
|
|
421
432
|
let disposers = [];
|
|
422
433
|
if (options.mapSkills === false || skills.length === 0) {
|
|
423
434
|
const name = normalizeToolName(`a2a__${options.name}__agent`);
|
|
@@ -425,7 +436,7 @@ async function registerAgentTools(tools, options) {
|
|
|
425
436
|
id: "agent",
|
|
426
437
|
name: options.name,
|
|
427
438
|
description: `${card.name}: ${card.description} (no skills advertised)`
|
|
428
|
-
}));
|
|
439
|
+
}, contextFor));
|
|
429
440
|
if (dispose) disposers = [{
|
|
430
441
|
name,
|
|
431
442
|
dispose
|
|
@@ -434,7 +445,7 @@ async function registerAgentTools(tools, options) {
|
|
|
434
445
|
const out = [];
|
|
435
446
|
for (const skill of skills) {
|
|
436
447
|
const name = skillToToolName(options.name, skill);
|
|
437
|
-
const dispose = tools.register?.(makeTool(name, client, skill));
|
|
448
|
+
const dispose = tools.register?.(makeTool(name, client, skill, contextFor));
|
|
438
449
|
if (dispose) out.push({
|
|
439
450
|
name,
|
|
440
451
|
dispose
|
|
@@ -451,7 +462,7 @@ async function registerAgentTools(tools, options) {
|
|
|
451
462
|
}
|
|
452
463
|
}));
|
|
453
464
|
}
|
|
454
|
-
function makeTool(name, client, skill) {
|
|
465
|
+
function makeTool(name, client, skill, contextFor) {
|
|
455
466
|
return {
|
|
456
467
|
name,
|
|
457
468
|
description: [skill.description, ...skill.examples?.length ? [`Examples: ${skill.examples.join(" | ")}`] : []].filter(Boolean).join("\n"),
|
|
@@ -474,6 +485,7 @@ function makeTool(name, client, skill) {
|
|
|
474
485
|
const message = {
|
|
475
486
|
messageId: crypto.randomUUID(),
|
|
476
487
|
role: "ROLE_USER",
|
|
488
|
+
contextId: contextFor(exec.agent?.id),
|
|
477
489
|
parts: [{ text: args.prompt }]
|
|
478
490
|
};
|
|
479
491
|
try {
|
|
@@ -567,10 +579,12 @@ var A2AServer = class {
|
|
|
567
579
|
execute;
|
|
568
580
|
listeners = /* @__PURE__ */ new Set();
|
|
569
581
|
baseUrl;
|
|
582
|
+
/** Abort controllers for in-flight tasks, so CancelTask can abort the executor. */
|
|
583
|
+
running = /* @__PURE__ */ new Map();
|
|
570
584
|
constructor(options, store = new TaskStore()) {
|
|
571
585
|
this.options = options;
|
|
572
586
|
this.store = store;
|
|
573
|
-
this.execute = options.execute ??
|
|
587
|
+
this.execute = options.execute ?? notConfiguredExecutor;
|
|
574
588
|
this.endpointPath = options.endpointPath ?? "/a2a";
|
|
575
589
|
this.baseUrl = options.baseUrl;
|
|
576
590
|
this.card = this.buildCard();
|
|
@@ -586,6 +600,25 @@ var A2AServer = class {
|
|
|
586
600
|
url: `${this.baseUrl}${this.endpointPath}`
|
|
587
601
|
}));
|
|
588
602
|
}
|
|
603
|
+
/**
|
|
604
|
+
* Set or clear the shared bearer token that gates the inbound endpoint at
|
|
605
|
+
* runtime. Rebuilds the AgentCard so the `bearerAuth` security scheme is
|
|
606
|
+
* advertised (token set) or withdrawn (token cleared); `authorized()` reads
|
|
607
|
+
* `options.authToken` on each request, so the gate flips immediately. Pass
|
|
608
|
+
* `undefined` or an empty string to disable token gating.
|
|
609
|
+
*/
|
|
610
|
+
setAuthToken(token) {
|
|
611
|
+
this.options.authToken = token && token.length > 0 ? token : void 0;
|
|
612
|
+
if (this.options.authToken) applyAuthScheme(this.card, this.options.authToken);
|
|
613
|
+
else {
|
|
614
|
+
delete this.card.securitySchemes;
|
|
615
|
+
delete this.card.securityRequirements;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/** Whether the inbound endpoint is currently token-gated. */
|
|
619
|
+
get authConfigured() {
|
|
620
|
+
return Boolean(this.options.authToken);
|
|
621
|
+
}
|
|
589
622
|
buildCard() {
|
|
590
623
|
const iface = {
|
|
591
624
|
url: `${this.baseUrl.replace(/\/$/, "")}${this.endpointPath}`,
|
|
@@ -593,7 +626,8 @@ var A2AServer = class {
|
|
|
593
626
|
protocolVersion: "1.0"
|
|
594
627
|
};
|
|
595
628
|
const skills = this.options.skills ?? [];
|
|
596
|
-
|
|
629
|
+
const token = this.options.authToken;
|
|
630
|
+
const card = {
|
|
597
631
|
name: this.options.agentName,
|
|
598
632
|
description: this.options.agentDescription,
|
|
599
633
|
version: this.options.agentVersion,
|
|
@@ -612,6 +646,18 @@ var A2AServer = class {
|
|
|
612
646
|
skills,
|
|
613
647
|
...this.options.iconUrl ? { iconUrl: this.options.iconUrl } : {}
|
|
614
648
|
};
|
|
649
|
+
applyAuthScheme(card, token);
|
|
650
|
+
return card;
|
|
651
|
+
}
|
|
652
|
+
/** True when the request carries the configured bearer token. */
|
|
653
|
+
authorized(req) {
|
|
654
|
+
const token = this.options.authToken;
|
|
655
|
+
if (!token) return true;
|
|
656
|
+
const h = req.headers ?? {};
|
|
657
|
+
const auth = h["authorization"] ?? h["Authorization"];
|
|
658
|
+
if (!auth) return false;
|
|
659
|
+
const m = /^Bearer\s+(.+)$/i.exec(auth.trim());
|
|
660
|
+
return !!m && m[1] === token;
|
|
615
661
|
}
|
|
616
662
|
emit(ev) {
|
|
617
663
|
for (const l of this.listeners) l(ev);
|
|
@@ -625,6 +671,19 @@ var A2AServer = class {
|
|
|
625
671
|
body: JSON.stringify(this.card)
|
|
626
672
|
};
|
|
627
673
|
if (req.method === "POST" && path === this.endpointPath) {
|
|
674
|
+
if (!this.authorized(req)) return {
|
|
675
|
+
status: 401,
|
|
676
|
+
contentType: "application/json",
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
jsonrpc: "2.0",
|
|
679
|
+
id: null,
|
|
680
|
+
error: {
|
|
681
|
+
code: -32001,
|
|
682
|
+
message: "Unauthorized"
|
|
683
|
+
}
|
|
684
|
+
}),
|
|
685
|
+
headers: { "WWW-Authenticate": "Bearer" }
|
|
686
|
+
};
|
|
628
687
|
let payload;
|
|
629
688
|
try {
|
|
630
689
|
payload = JSON.parse(body);
|
|
@@ -671,6 +730,10 @@ var A2AServer = class {
|
|
|
671
730
|
})}\n\n`);
|
|
672
731
|
return { status: 200 };
|
|
673
732
|
}
|
|
733
|
+
if (!this.authorized(req)) return {
|
|
734
|
+
status: 401,
|
|
735
|
+
headers: { "WWW-Authenticate": "Bearer" }
|
|
736
|
+
};
|
|
674
737
|
let payload;
|
|
675
738
|
try {
|
|
676
739
|
payload = JSON.parse(body);
|
|
@@ -748,14 +811,16 @@ var A2AServer = class {
|
|
|
748
811
|
contextId,
|
|
749
812
|
status: this.store.get(taskId).status
|
|
750
813
|
} });
|
|
751
|
-
const
|
|
814
|
+
const controller = new AbortController();
|
|
815
|
+
this.running.set(taskId, controller);
|
|
752
816
|
try {
|
|
753
817
|
const reply = await this.execute({
|
|
754
818
|
message: msg,
|
|
755
819
|
taskId,
|
|
756
820
|
contextId,
|
|
757
|
-
signal
|
|
821
|
+
signal: controller.signal
|
|
758
822
|
});
|
|
823
|
+
if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state)) return;
|
|
759
824
|
this.store.addArtifact(taskId, {
|
|
760
825
|
artifactId: "result",
|
|
761
826
|
parts: reply.parts,
|
|
@@ -772,6 +837,7 @@ var A2AServer = class {
|
|
|
772
837
|
lastChunk: true
|
|
773
838
|
} });
|
|
774
839
|
} catch (err) {
|
|
840
|
+
if (controller.signal.aborted || isTerminal(this.store.get(taskId)?.status.state)) return;
|
|
775
841
|
const message = {
|
|
776
842
|
messageId: crypto.randomUUID(),
|
|
777
843
|
role: "ROLE_AGENT",
|
|
@@ -784,6 +850,8 @@ var A2AServer = class {
|
|
|
784
850
|
contextId,
|
|
785
851
|
status: this.store.get(taskId).status
|
|
786
852
|
} });
|
|
853
|
+
} finally {
|
|
854
|
+
this.running.delete(taskId);
|
|
787
855
|
}
|
|
788
856
|
}
|
|
789
857
|
async dispatch(rpc) {
|
|
@@ -831,6 +899,7 @@ var A2AServer = class {
|
|
|
831
899
|
code: A2A_ERROR_CODES.TASK_CANCEL_NOT_ALLOWED,
|
|
832
900
|
message: `Task ${id} already in terminal state ${task.status.state}`
|
|
833
901
|
};
|
|
902
|
+
this.running.get(id)?.abort();
|
|
834
903
|
this.store.setStatus(id, "TASK_STATE_CANCELED");
|
|
835
904
|
this.options.onTaskSettled?.(id);
|
|
836
905
|
return this.store.get(id);
|
|
@@ -859,6 +928,20 @@ var A2AServer = class {
|
|
|
859
928
|
};
|
|
860
929
|
}
|
|
861
930
|
};
|
|
931
|
+
/**
|
|
932
|
+
* Advertise the `bearerAuth` HTTP security scheme on an AgentCard when a token
|
|
933
|
+
* is configured. Mutates in place; a no-op when `token` is falsy. Shared by
|
|
934
|
+
* {@link A2AServer.buildCard} and {@link A2AServer.setAuthToken}.
|
|
935
|
+
*/
|
|
936
|
+
function applyAuthScheme(card, token) {
|
|
937
|
+
if (!token) return;
|
|
938
|
+
card.securitySchemes = { bearerAuth: {
|
|
939
|
+
type: "http",
|
|
940
|
+
scheme: "bearer",
|
|
941
|
+
bearerFormat: "opaque"
|
|
942
|
+
} };
|
|
943
|
+
card.securityRequirements = [{ schemes: { bearerAuth: [] } }];
|
|
944
|
+
}
|
|
862
945
|
/** Pull task ids out of a dispatch result for inbound peer tracking. */
|
|
863
946
|
function extractTaskIds(result) {
|
|
864
947
|
if (!result || typeof result !== "object") return [];
|
|
@@ -876,11 +959,34 @@ const defaultSkills = [{
|
|
|
876
959
|
outputModes: ["text/plain"]
|
|
877
960
|
}];
|
|
878
961
|
/**
|
|
879
|
-
*
|
|
962
|
+
* Safe default executor: refuses to act, telling the caller to inject a real
|
|
963
|
+
* executor. Used when an `A2AServer` is constructed without an `execute`
|
|
964
|
+
* option, so an inbound message never triggers arbitrary action (in particular
|
|
965
|
+
* never a shell command) unless the operator explicitly configured one.
|
|
966
|
+
*/
|
|
967
|
+
const notConfiguredExecutor = async ({ message }) => {
|
|
968
|
+
const text = message.parts.map((p) => "text" in p ? p.text : "").filter(Boolean).join("\n").slice(0, 120);
|
|
969
|
+
const detail = text ? ` (prompt: ${text}…)` : "";
|
|
970
|
+
return {
|
|
971
|
+
messageId: crypto.randomUUID(),
|
|
972
|
+
role: "ROLE_AGENT",
|
|
973
|
+
parts: [{ text: `no executor configured — inject one via the \`execute\` option${detail}` }]
|
|
974
|
+
};
|
|
975
|
+
};
|
|
976
|
+
/**
|
|
977
|
+
* Shell executor: run the incoming prompt through the system shell and
|
|
880
978
|
* return the captured stdout/stderr as the task artifact. Honors the
|
|
881
979
|
* AbortSignal (task cancellation / shutdown) by killing the child process.
|
|
980
|
+
*
|
|
981
|
+
* This is local-testing-only: arbitrary text from an inbound message becomes
|
|
982
|
+
* a `/bin/sh -c` command, so only use it against a trusted client. It is
|
|
983
|
+
* deliberately NOT the default — pass it explicitly to opt in:
|
|
984
|
+
*
|
|
985
|
+
* ```ts
|
|
986
|
+
* const server = new A2AServer({ ..., execute: shellExecutor }, store);
|
|
987
|
+
* ```
|
|
882
988
|
*/
|
|
883
|
-
const
|
|
989
|
+
const shellExecutor = async ({ message, signal }) => {
|
|
884
990
|
const text = message.parts.map((p) => "text" in p ? p.text : "").filter(Boolean).join("\n");
|
|
885
991
|
if (!text.trim()) return {
|
|
886
992
|
messageId: crypto.randomUUID(),
|
|
@@ -920,6 +1026,179 @@ const defaultExecutor = async ({ message, signal }) => {
|
|
|
920
1026
|
};
|
|
921
1027
|
}
|
|
922
1028
|
};
|
|
1029
|
+
/**
|
|
1030
|
+
* Backwards-compatible alias for {@link shellExecutor}. Kept for code that
|
|
1031
|
+
* explicitly opted into the shell executor before it was renamed; the safe
|
|
1032
|
+
* default for `A2AServer` is now {@link notConfiguredExecutor}, so using
|
|
1033
|
+
* this alias still requires an explicit opt-in.
|
|
1034
|
+
*/
|
|
1035
|
+
const defaultExecutor = shellExecutor;
|
|
1036
|
+
/** Flatten A2A message parts into a single prompt string (text parts only). */
|
|
1037
|
+
function partsToPrompt(parts) {
|
|
1038
|
+
return parts.map((p) => "text" in p && typeof p.text === "string" ? p.text : "").filter(Boolean).join("\n").trim();
|
|
1039
|
+
}
|
|
1040
|
+
/** Read the last assistant message's text off an agent's derived history. */
|
|
1041
|
+
function lastAssistantText(agent) {
|
|
1042
|
+
const history = agent.session.deriveMessages();
|
|
1043
|
+
for (let i = history.length - 1; i >= 0; i--) if (history[i].role === "assistant") return history[i].content.map((b) => b.type === "text" && typeof b.text === "string" ? b.text : "").filter(Boolean).join("\n").trim();
|
|
1044
|
+
return "";
|
|
1045
|
+
}
|
|
1046
|
+
/** Derive a stable session id for an A2A context id. */
|
|
1047
|
+
function sessionIdForContext(contextId) {
|
|
1048
|
+
return `a2a-${contextId}`;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* DSH agent executor: forward each inbound A2A task to this harness's own agent
|
|
1052
|
+
* loop and return the assistant reply as the task artifact.
|
|
1053
|
+
*
|
|
1054
|
+
* ONE A2A CONTEXT ⇒ ONE SESSION. A2A already scopes a conversation by
|
|
1055
|
+
* `contextId`, so this executor keeps one workspace-bound agent session per
|
|
1056
|
+
* context: the first task for a context `create()`s it (session id derived from
|
|
1057
|
+
* the context, `meta.cwd` from `opts.cwd`), and later tasks for the same
|
|
1058
|
+
* context reuse the SAME live agent so history accumulates. Distinct contexts
|
|
1059
|
+
* get distinct sessions. A fresh process that sees a known context first tries
|
|
1060
|
+
* `resume()` (load persisted history), falling back to `create()`.
|
|
1061
|
+
*
|
|
1062
|
+
* Sessions are NOT disposed per task — a disposed session couldn't accumulate
|
|
1063
|
+
* context. The returned executor exposes {@link DshAgentExecutor.disposeAll} so
|
|
1064
|
+
* the plugin disposes every live per-context session on unload/teardown.
|
|
1065
|
+
*
|
|
1066
|
+
* Per-context work is serialized on a promise chain so concurrent tasks for the
|
|
1067
|
+
* same context (and the shared `whenIdle()` — a whole-agent quiescence signal,
|
|
1068
|
+
* not a per-message one) can't interleave. Different contexts run in parallel.
|
|
1069
|
+
*
|
|
1070
|
+
* Enable this via the plugin's `execute` precedence in `index.ts`; it is not
|
|
1071
|
+
* `A2AServer`'s default (the default stays {@link notConfiguredExecutor}).
|
|
1072
|
+
*/
|
|
1073
|
+
function createDshAgentExecutor(agents, opts) {
|
|
1074
|
+
const plugin = opts.plugin ?? "a2a";
|
|
1075
|
+
const handles = /* @__PURE__ */ new Map();
|
|
1076
|
+
const tails = /* @__PURE__ */ new Map();
|
|
1077
|
+
/**
|
|
1078
|
+
* Get or open the live agent for a context. Returns the agent and whether it
|
|
1079
|
+
* was JUST opened (so the caller fires the one-time onSessionOpened hook after
|
|
1080
|
+
* the first prompt lands, keeping "open" and "first message" atomic per tail).
|
|
1081
|
+
* `justOpened` is true only on the create/resume path — a cached handle returns
|
|
1082
|
+
* false — so it alone gates the once-per-context hook; serialization on the
|
|
1083
|
+
* per-context tail guarantees no two openings race.
|
|
1084
|
+
*/
|
|
1085
|
+
const agentFor = async (contextId) => {
|
|
1086
|
+
const sessionId = sessionIdForContext(contextId);
|
|
1087
|
+
const existing = handles.get(contextId);
|
|
1088
|
+
if (existing) return {
|
|
1089
|
+
agent: existing.agent,
|
|
1090
|
+
sessionId,
|
|
1091
|
+
justOpened: false
|
|
1092
|
+
};
|
|
1093
|
+
const agentOptions = opts.resolveAgentOptions?.() ?? opts.agentOptions;
|
|
1094
|
+
const withModel = agentOptions ? { agentOptions } : {};
|
|
1095
|
+
const presets = opts.agentPresets;
|
|
1096
|
+
let presetId;
|
|
1097
|
+
let setup;
|
|
1098
|
+
if (presets) try {
|
|
1099
|
+
presetId = (await presets.resolve()).id;
|
|
1100
|
+
if (!presetId) throw new Error("agent preset roster resolved to no id");
|
|
1101
|
+
setup = async (agentCtx) => {
|
|
1102
|
+
await presets.mount(agentCtx, presetId);
|
|
1103
|
+
};
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
throw new Error(`a2a: failed to resolve agent preset: ${err.message}`);
|
|
1106
|
+
}
|
|
1107
|
+
const createOptions = {
|
|
1108
|
+
sessionId,
|
|
1109
|
+
...presetId ? { meta: {
|
|
1110
|
+
cwd: opts.cwd,
|
|
1111
|
+
agentPreset: presetId
|
|
1112
|
+
} } : { meta: { cwd: opts.cwd } },
|
|
1113
|
+
...withModel,
|
|
1114
|
+
...setup ? { setup } : {}
|
|
1115
|
+
};
|
|
1116
|
+
let handle;
|
|
1117
|
+
try {
|
|
1118
|
+
handle = await agents.create(createOptions);
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
if (/already (has|owns)|persisted/i.test(err.message)) handle = await agents.resume({
|
|
1121
|
+
resumeSessionId: sessionId,
|
|
1122
|
+
...withModel,
|
|
1123
|
+
...setup ? { setup } : {}
|
|
1124
|
+
});
|
|
1125
|
+
else throw err;
|
|
1126
|
+
}
|
|
1127
|
+
handles.set(contextId, handle);
|
|
1128
|
+
return {
|
|
1129
|
+
agent: handle.agent,
|
|
1130
|
+
sessionId,
|
|
1131
|
+
justOpened: true
|
|
1132
|
+
};
|
|
1133
|
+
};
|
|
1134
|
+
/** Run one turn on a context's session, serialized behind its tail. */
|
|
1135
|
+
const runTurn = async (contextId, prompt, signal) => {
|
|
1136
|
+
const { agent, sessionId, justOpened } = await agentFor(contextId);
|
|
1137
|
+
const onAbort = () => agent.cancel("a2a-canceled");
|
|
1138
|
+
if (signal?.aborted) onAbort();
|
|
1139
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
1140
|
+
const userMessage = Object.freeze({
|
|
1141
|
+
id: crypto.randomUUID(),
|
|
1142
|
+
role: "user",
|
|
1143
|
+
content: [{
|
|
1144
|
+
type: "text",
|
|
1145
|
+
text: prompt
|
|
1146
|
+
}],
|
|
1147
|
+
source: {
|
|
1148
|
+
kind: "plugin",
|
|
1149
|
+
plugin
|
|
1150
|
+
}
|
|
1151
|
+
});
|
|
1152
|
+
agent.send(userMessage, "next-turn", true);
|
|
1153
|
+
if (justOpened) try {
|
|
1154
|
+
await opts.onSessionOpened?.({
|
|
1155
|
+
sessionId,
|
|
1156
|
+
contextId,
|
|
1157
|
+
session: agent.session,
|
|
1158
|
+
firstPrompt: prompt
|
|
1159
|
+
});
|
|
1160
|
+
} catch {}
|
|
1161
|
+
await agent.whenIdle();
|
|
1162
|
+
return lastAssistantText(agent);
|
|
1163
|
+
};
|
|
1164
|
+
const executor = (async ({ message, contextId, signal }) => {
|
|
1165
|
+
const prompt = partsToPrompt(message.parts);
|
|
1166
|
+
if (!prompt) return {
|
|
1167
|
+
messageId: crypto.randomUUID(),
|
|
1168
|
+
role: "ROLE_AGENT",
|
|
1169
|
+
parts: [{ text: "No prompt provided." }]
|
|
1170
|
+
};
|
|
1171
|
+
const ctxKey = contextId;
|
|
1172
|
+
const run = (tails.get(ctxKey) ?? Promise.resolve()).catch(() => {}).then(() => runTurn(ctxKey, prompt, signal));
|
|
1173
|
+
tails.set(ctxKey, run);
|
|
1174
|
+
try {
|
|
1175
|
+
const replyText = await run;
|
|
1176
|
+
const text = signal?.aborted ? `(canceled) ${replyText}`.trim() : replyText || "(no reply)";
|
|
1177
|
+
return {
|
|
1178
|
+
messageId: crypto.randomUUID(),
|
|
1179
|
+
role: "ROLE_AGENT",
|
|
1180
|
+
parts: [{ text }]
|
|
1181
|
+
};
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
return {
|
|
1184
|
+
messageId: crypto.randomUUID(),
|
|
1185
|
+
role: "ROLE_AGENT",
|
|
1186
|
+
parts: [{ text: `DSH agent executor error: ${err.message}` }]
|
|
1187
|
+
};
|
|
1188
|
+
} finally {
|
|
1189
|
+
if (tails.get(ctxKey) === run) tails.delete(ctxKey);
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
executor.disposeAll = async () => {
|
|
1193
|
+
const live = [...handles.values()];
|
|
1194
|
+
const pending = [...tails.values()];
|
|
1195
|
+
handles.clear();
|
|
1196
|
+
tails.clear();
|
|
1197
|
+
await Promise.allSettled(pending);
|
|
1198
|
+
await Promise.allSettled(live.map((h) => h.dispose()));
|
|
1199
|
+
};
|
|
1200
|
+
return executor;
|
|
1201
|
+
}
|
|
923
1202
|
//#endregion
|
|
924
1203
|
//#region src/dashboard.ts
|
|
925
1204
|
/** Registry of live A2A connections, both directions. */
|
|
@@ -1077,7 +1356,8 @@ var DashboardRegistry = class {
|
|
|
1077
1356
|
ok: false,
|
|
1078
1357
|
message: "discover-agent requires agentCardUrl"
|
|
1079
1358
|
};
|
|
1080
|
-
|
|
1359
|
+
const bearerToken = payload?.bearerToken || void 0;
|
|
1360
|
+
return this.hooks.discoverAgent ? this.hooks.discoverAgent(url, { bearerToken }) : {
|
|
1081
1361
|
ok: false,
|
|
1082
1362
|
message: "discover-agent is not wired"
|
|
1083
1363
|
};
|
|
@@ -1089,7 +1369,8 @@ var DashboardRegistry = class {
|
|
|
1089
1369
|
ok: false,
|
|
1090
1370
|
message: "add-agent requires agentCardUrl"
|
|
1091
1371
|
};
|
|
1092
|
-
|
|
1372
|
+
const bearerToken = payload?.bearerToken || void 0;
|
|
1373
|
+
return this.hooks.addAgent ? this.hooks.addAgent(name, url, { bearerToken }) : {
|
|
1093
1374
|
ok: false,
|
|
1094
1375
|
message: "add-agent is not wired (client mode not mounted)"
|
|
1095
1376
|
};
|
|
@@ -1106,6 +1387,13 @@ var DashboardRegistry = class {
|
|
|
1106
1387
|
ok: false,
|
|
1107
1388
|
message: "server control is not wired (server mode not mounted)"
|
|
1108
1389
|
};
|
|
1390
|
+
case "set-server-auth": {
|
|
1391
|
+
const token = payload?.authToken || void 0;
|
|
1392
|
+
return this.hooks.setServerAuthToken ? this.hooks.setServerAuthToken(token) : {
|
|
1393
|
+
ok: false,
|
|
1394
|
+
message: "set-server-auth is not wired (server mode not mounted)"
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1109
1397
|
case "server-status": return this.hooks.serverStatus ? Promise.resolve(this.hooks.serverStatus()) : {
|
|
1110
1398
|
ok: false,
|
|
1111
1399
|
message: "server-status is not wired (server mode not mounted)"
|
|
@@ -1291,6 +1579,7 @@ function mergePersisted(base, persisted) {
|
|
|
1291
1579
|
if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
|
|
1292
1580
|
if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
|
|
1293
1581
|
if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
|
|
1582
|
+
if (pServer.authToken !== void 0) merged.authToken = pServer.authToken;
|
|
1294
1583
|
if (pServer.skills) merged.skills = pServer.skills;
|
|
1295
1584
|
}
|
|
1296
1585
|
return merged;
|
|
@@ -1599,8 +1888,9 @@ function apply(ctx, config = {}) {
|
|
|
1599
1888
|
message: `connection ${connectionId} reconnected as ${newId}`
|
|
1600
1889
|
};
|
|
1601
1890
|
},
|
|
1602
|
-
addAgent: async (name, agentCardUrl) => {
|
|
1603
|
-
const
|
|
1891
|
+
addAgent: async (name, agentCardUrl, opts) => {
|
|
1892
|
+
const bearerToken = opts?.bearerToken;
|
|
1893
|
+
const cid = await connect(name, agentCardUrl, true, { bearerToken });
|
|
1604
1894
|
if (!cid) return {
|
|
1605
1895
|
ok: false,
|
|
1606
1896
|
message: `failed to connect to ${agentCardUrl}`
|
|
@@ -1609,7 +1899,8 @@ function apply(ctx, config = {}) {
|
|
|
1609
1899
|
name,
|
|
1610
1900
|
agentCardUrl,
|
|
1611
1901
|
configured: true,
|
|
1612
|
-
enabled: true
|
|
1902
|
+
enabled: true,
|
|
1903
|
+
bearerToken
|
|
1613
1904
|
});
|
|
1614
1905
|
persistAgents();
|
|
1615
1906
|
return {
|
|
@@ -1617,11 +1908,11 @@ function apply(ctx, config = {}) {
|
|
|
1617
1908
|
message: `agent "${name}" connected as ${cid} (saved to ${configPath})`
|
|
1618
1909
|
};
|
|
1619
1910
|
},
|
|
1620
|
-
discoverAgent: async (agentCardUrl) => {
|
|
1911
|
+
discoverAgent: async (agentCardUrl, opts) => {
|
|
1621
1912
|
try {
|
|
1622
1913
|
const card = await fetchAgentCard(agentCardUrl, {
|
|
1623
1914
|
timeoutMs: mergedConfig.timeoutMs ?? 8e3,
|
|
1624
|
-
bearerToken: mergedConfig.bearerToken
|
|
1915
|
+
bearerToken: opts?.bearerToken ?? mergedConfig.bearerToken
|
|
1625
1916
|
});
|
|
1626
1917
|
const iface = pickInterface(card);
|
|
1627
1918
|
return {
|
|
@@ -1726,6 +2017,35 @@ function apply(ctx, config = {}) {
|
|
|
1726
2017
|
const webServer = ctx.webServer;
|
|
1727
2018
|
const baseUrl = mergedConfig.baseUrl ?? `http://${webServer.host === "0.0.0.0" ? "127.0.0.1" : webServer.host}:${webServer.port}`;
|
|
1728
2019
|
const endpointPath = mergedConfig.endpointPath ?? DEFAULT_ENDPOINT;
|
|
2020
|
+
let execute = mergedConfig.execute;
|
|
2021
|
+
let executorKind = execute ? "custom" : "none";
|
|
2022
|
+
let disposeExecutor;
|
|
2023
|
+
if (!execute) {
|
|
2024
|
+
const agents = probeAgentRegistry(ctx);
|
|
2025
|
+
if (agents) {
|
|
2026
|
+
const inboundCwd = inboundSessionCwd(configPath);
|
|
2027
|
+
const dshExec = createDshAgentExecutor(agents, {
|
|
2028
|
+
cwd: inboundCwd,
|
|
2029
|
+
plugin: "a2a",
|
|
2030
|
+
agentPresets: probeAgentPresets(ctx),
|
|
2031
|
+
resolveAgentOptions: () => {
|
|
2032
|
+
const sel = probeAgentDefaultModel(ctx)?.currentSelection();
|
|
2033
|
+
if (!sel?.model && !sel?.provider) return void 0;
|
|
2034
|
+
return {
|
|
2035
|
+
provider: sel.provider,
|
|
2036
|
+
model: sel.model
|
|
2037
|
+
};
|
|
2038
|
+
},
|
|
2039
|
+
onSessionOpened: ({ session, firstPrompt }) => {
|
|
2040
|
+
probeSessionTitle(ctx)?.rename(session, `A2A: ${summarize(firstPrompt)}`);
|
|
2041
|
+
}
|
|
2042
|
+
});
|
|
2043
|
+
execute = dshExec;
|
|
2044
|
+
disposeExecutor = () => dshExec.disposeAll();
|
|
2045
|
+
executorKind = "dsh-agent";
|
|
2046
|
+
logger?.info?.(`[a2a] server executor: DSH agent session per A2A context (cwd ${inboundCwd})`);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
1729
2049
|
const server = new A2AServer({
|
|
1730
2050
|
baseUrl,
|
|
1731
2051
|
agentName: mergedConfig.agentName ?? "DSH Agent",
|
|
@@ -1733,7 +2053,8 @@ function apply(ctx, config = {}) {
|
|
|
1733
2053
|
agentVersion: mergedConfig.agentVersion ?? "0.1.0",
|
|
1734
2054
|
skills: mergedConfig.skills,
|
|
1735
2055
|
endpointPath,
|
|
1736
|
-
execute
|
|
2056
|
+
execute,
|
|
2057
|
+
authToken: mergedConfig.authToken,
|
|
1737
2058
|
onInbound: (facts) => {
|
|
1738
2059
|
const peerId = dashboard.touchInbound({
|
|
1739
2060
|
headers: facts.headers,
|
|
@@ -1780,17 +2101,23 @@ function apply(ctx, config = {}) {
|
|
|
1780
2101
|
}
|
|
1781
2102
|
const body = await readBody(req);
|
|
1782
2103
|
if (isStream) {
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
2104
|
+
const stream = await server.handleStream(toServerReq(req), body, (frame) => res.write(frame));
|
|
2105
|
+
if (stream.status !== 200) {
|
|
2106
|
+
res.writeHead(stream.status, {
|
|
2107
|
+
"content-type": "text/plain",
|
|
2108
|
+
...stream.headers ?? {}
|
|
2109
|
+
});
|
|
2110
|
+
res.end("Unauthorized");
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
1789
2113
|
res.end();
|
|
1790
2114
|
return;
|
|
1791
2115
|
}
|
|
1792
2116
|
const out = await server.handle(toServerReq(req), body);
|
|
1793
|
-
res.writeHead(out.status, {
|
|
2117
|
+
res.writeHead(out.status, {
|
|
2118
|
+
"content-type": out.contentType,
|
|
2119
|
+
...out.headers ?? {}
|
|
2120
|
+
});
|
|
1794
2121
|
res.end(out.body);
|
|
1795
2122
|
}
|
|
1796
2123
|
}));
|
|
@@ -1813,7 +2140,9 @@ function apply(ctx, config = {}) {
|
|
|
1813
2140
|
name: s.name,
|
|
1814
2141
|
description: s.description
|
|
1815
2142
|
})),
|
|
1816
|
-
customExecutor: Boolean(mergedConfig.execute)
|
|
2143
|
+
customExecutor: Boolean(mergedConfig.execute),
|
|
2144
|
+
executor: executorKind,
|
|
2145
|
+
authConfigured: server.authConfigured
|
|
1817
2146
|
});
|
|
1818
2147
|
dashboard.setHooks({
|
|
1819
2148
|
setServerEnabled: async (enable) => {
|
|
@@ -1838,6 +2167,27 @@ function apply(ctx, config = {}) {
|
|
|
1838
2167
|
message: "A2A server disabled"
|
|
1839
2168
|
};
|
|
1840
2169
|
},
|
|
2170
|
+
setServerAuthToken: async (token) => {
|
|
2171
|
+
server.setAuthToken(token);
|
|
2172
|
+
const next = loadConfig();
|
|
2173
|
+
next.server = {
|
|
2174
|
+
...next.server ?? {},
|
|
2175
|
+
authToken: token && token.length > 0 ? token : void 0
|
|
2176
|
+
};
|
|
2177
|
+
const res = saveConfig(next);
|
|
2178
|
+
if (!res.ok) {
|
|
2179
|
+
logger?.error?.(`[a2a] failed to persist authToken to ${res.path}: ${res.message}`);
|
|
2180
|
+
return {
|
|
2181
|
+
ok: false,
|
|
2182
|
+
message: `applied in-memory but failed to save: ${res.message}`
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
logger?.info?.(`[a2a] inbound authToken ${token ? "set" : "cleared"} (saved to ${res.path})`);
|
|
2186
|
+
return {
|
|
2187
|
+
ok: true,
|
|
2188
|
+
message: token ? "已设置入站鉴权 token" : "已清除入站鉴权 token"
|
|
2189
|
+
};
|
|
2190
|
+
},
|
|
1841
2191
|
serverStatus: () => ({
|
|
1842
2192
|
ok: true,
|
|
1843
2193
|
message: JSON.stringify({
|
|
@@ -1848,11 +2198,89 @@ function apply(ctx, config = {}) {
|
|
|
1848
2198
|
});
|
|
1849
2199
|
return () => {
|
|
1850
2200
|
unregisterRoutes();
|
|
2201
|
+
disposeExecutor?.().catch((err) => logger?.error?.(`[a2a] executor dispose failed: ${err.message}`));
|
|
1851
2202
|
};
|
|
1852
2203
|
}, "a2a: server routes");
|
|
1853
2204
|
});
|
|
1854
2205
|
}
|
|
1855
2206
|
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Probe for a usable agent registry WITHOUT taking an inject dependency on it.
|
|
2209
|
+
*
|
|
2210
|
+
* `ctx.agents` (the registry) can be present while `create()` still rejects —
|
|
2211
|
+
* that happens on a bare dsh-base profile that has the agent layer but no
|
|
2212
|
+
* agent-loop plugin to register the creation factory. We can't cheaply tell
|
|
2213
|
+
* "factory registered" apart from "registry present" without attempting a
|
|
2214
|
+
* create, so this returns the registry when it's structurally usable and lets
|
|
2215
|
+
* {@link createDshAgentExecutor} degrade to a readable error if a later
|
|
2216
|
+
* `create()` rejects.
|
|
2217
|
+
*/
|
|
2218
|
+
function probeAgentRegistry(ctx) {
|
|
2219
|
+
return probeService(ctx, "agents", "create");
|
|
2220
|
+
}
|
|
2221
|
+
/**
|
|
2222
|
+
* Probe for the agent-preset roster service (`ctx.agentPresets`, owned by
|
|
2223
|
+
* `@deepseek-ai/dsh-agent-presets`) without an inject dependency. When the
|
|
2224
|
+
* deployment composes presets (`dsh-web-app` mounts the roster with a
|
|
2225
|
+
* `default: standard`), inbound sessions must be composed from the same
|
|
2226
|
+
* preset or they publish against the empty global layer — tools, prompt
|
|
2227
|
+
* sections, and skills all absent. Absent rosters keep legacy behavior.
|
|
2228
|
+
*/
|
|
2229
|
+
function probeAgentPresets(ctx) {
|
|
2230
|
+
return probeService(ctx, "agentPresets", "resolve");
|
|
2231
|
+
}
|
|
2232
|
+
/**
|
|
2233
|
+
* Probe for the deployment default-model service without an inject dependency.
|
|
2234
|
+
* Returns undefined when the service is absent, so the executor simply creates
|
|
2235
|
+
* the agent without a seeded selection — fine for compositions that seed the
|
|
2236
|
+
* model another way.
|
|
2237
|
+
*/
|
|
2238
|
+
function probeAgentDefaultModel(ctx) {
|
|
2239
|
+
return probeService(ctx, "agentDefaultModel", "currentSelection");
|
|
2240
|
+
}
|
|
2241
|
+
/** Probe `ctx.sessionTitle` (optional). Used to name inbound sessions. */
|
|
2242
|
+
function probeSessionTitle(ctx) {
|
|
2243
|
+
return probeService(ctx, "sessionTitle", "rename");
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Read a strict service off the reflection layer and confirm it exposes the
|
|
2247
|
+
* named method, without taking an inject dependency. Returns undefined when the
|
|
2248
|
+
* service is absent, its fiber inactive, or the method missing.
|
|
2249
|
+
*/
|
|
2250
|
+
function probeService(ctx, name, method) {
|
|
2251
|
+
try {
|
|
2252
|
+
const svc = ctx.reflect?.get(name, true);
|
|
2253
|
+
if (svc && typeof svc[method] === "function") return svc;
|
|
2254
|
+
} catch {}
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* One-line title summary from a prompt: first line, whitespace-collapsed, cut
|
|
2258
|
+
* to a display-friendly length on a word boundary when possible.
|
|
2259
|
+
*/
|
|
2260
|
+
function summarize(prompt, maxChars = 40) {
|
|
2261
|
+
const text = prompt.replace(/\s+/g, " ").trim();
|
|
2262
|
+
if (text.length <= maxChars) return text || "(empty)";
|
|
2263
|
+
const cut = text.slice(0, maxChars);
|
|
2264
|
+
const sp = cut.lastIndexOf(" ");
|
|
2265
|
+
return `${(sp > maxChars * .6 ? cut.slice(0, sp) : cut).trimEnd()}…`;
|
|
2266
|
+
}
|
|
2267
|
+
/**
|
|
2268
|
+
* Absolute cwd for inbound per-context sessions: a `.a2a-sessions` subdir of
|
|
2269
|
+
* the profile directory (the dir holding `a2a.json`), kept separate from the
|
|
2270
|
+
* profile root so inbound sessions don't crowd the user's interactive
|
|
2271
|
+
* conversations there. They surface in the session list's "Ungrouped" bucket,
|
|
2272
|
+
* named `A2A: <summary>`. Created if missing (persistence writes under it);
|
|
2273
|
+
* falls back to cwd on any failure.
|
|
2274
|
+
*/
|
|
2275
|
+
function inboundSessionCwd(configPath) {
|
|
2276
|
+
try {
|
|
2277
|
+
const dir = join(dirname(configPath), ".a2a-sessions");
|
|
2278
|
+
mkdirSync(dir, { recursive: true });
|
|
2279
|
+
return dir;
|
|
2280
|
+
} catch {
|
|
2281
|
+
return process.cwd();
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
1856
2284
|
/** Drain the request body from a Node IncomingMessage. */
|
|
1857
2285
|
async function readBody(req) {
|
|
1858
2286
|
return new Promise((resolve, reject) => {
|
|
@@ -1893,4 +2321,4 @@ function toServerReq(req) {
|
|
|
1893
2321
|
};
|
|
1894
2322
|
}
|
|
1895
2323
|
//#endregion
|
|
1896
|
-
export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, registerAgentTools };
|
|
2324
|
+
export { A2AClient, A2AError, A2AServer, A2A_ERROR_CODES, A2A_METHODS, DashboardRegistry, Role, TaskState, TaskStore, apply, createDshAgentExecutor, defaultExecutor, defaultSkills, fetchAgentCard, getSharedRegistry, inject, isTerminal, name, notConfiguredExecutor, registerAgentTools, shellExecutor };
|