@loomwork/plugin-a2a 0.0.0-stage → 0.1.1
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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +80 -2
- package/cards.d.ts +1 -0
- package/dist/A2AClientEffect.d.ts +11 -0
- package/dist/A2AClientEffect.d.ts.map +1 -0
- package/dist/A2AClientEffect.js +89 -0
- package/dist/A2AClientEffect.js.map +1 -0
- package/dist/A2AEffect.d.ts +13 -0
- package/dist/A2AEffect.d.ts.map +1 -0
- package/dist/A2AEffect.js +20 -0
- package/dist/A2AEffect.js.map +1 -0
- package/dist/A2APeerClient.d.ts +126 -0
- package/dist/A2APeerClient.d.ts.map +1 -0
- package/dist/A2APeerClient.js +167 -0
- package/dist/A2APeerClient.js.map +1 -0
- package/dist/A2AServer.d.ts +78 -0
- package/dist/A2AServer.d.ts.map +1 -0
- package/dist/A2AServer.js +239 -0
- package/dist/A2AServer.js.map +1 -0
- package/dist/AgentExecutor.d.ts +89 -0
- package/dist/AgentExecutor.d.ts.map +1 -0
- package/dist/AgentExecutor.js +126 -0
- package/dist/AgentExecutor.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/toAgentCard.d.ts +11 -0
- package/dist/toAgentCard.d.ts.map +1 -0
- package/dist/toAgentCard.js +19 -0
- package/dist/toAgentCard.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +65 -3
- package/src/A2AClientEffect.ts +109 -0
- package/src/A2AEffect.ts +16 -0
- package/src/A2APeerClient.ts +298 -0
- package/src/A2AServer.ts +309 -0
- package/src/AgentExecutor.ts +192 -0
- package/src/index.ts +35 -0
- package/src/toAgentCard.ts +31 -0
- package/src/types.ts +41 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { TaintedValue } from "@loomwork/classify";
|
|
2
|
+
import { tainted } from "@loomwork/classify";
|
|
3
|
+
import { tool$ } from "@loomwork/core";
|
|
4
|
+
import type { AgentDefinition, ToolDefinition } from "@loomwork/core";
|
|
5
|
+
import { agent$ } from "@loomwork/core";
|
|
6
|
+
import type { Effect, EffectContext, EffectRuntime } from "@loomwork/effect-runtime";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createA2AClientEffect } from "./A2AClientEffect";
|
|
9
|
+
import type { A2AClientOptions } from "./A2AClientEffect";
|
|
10
|
+
import { A2AEffect } from "./A2AEffect";
|
|
11
|
+
import type { A2ATask, A2ATaskResult, AgentCard } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Turns a remote A2A agent (Google ADK, a LangGraph/CrewAI A2A shim, or another
|
|
15
|
+
* Loom `createA2AServer`) into a callable Loom tool. This is the outbound half
|
|
16
|
+
* of the bidirectional mesh — the A2A analogue of `McpClient.registerInto`.
|
|
17
|
+
*
|
|
18
|
+
* The remote agent is discovered via its AgentCard (`/.well-known/agent.json`)
|
|
19
|
+
* and registered into an `EffectRuntime` so Loom agents, steps and the planner
|
|
20
|
+
* can invoke it like any other tool.
|
|
21
|
+
*/
|
|
22
|
+
export interface A2APeerConfig<TTaint extends boolean = false> {
|
|
23
|
+
/** Base URL of the remote A2A agent (no trailing path). */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Extra headers (auth etc.) sent on discovery and every task call. */
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
/** Overall per-call timeout, forwarded to the underlying client effect. */
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
/** Poll interval while waiting for the remote task to reach a terminal state. */
|
|
30
|
+
pollIntervalMs?: number;
|
|
31
|
+
/** Override the registered tool id. Defaults to a slug of the card name. */
|
|
32
|
+
toolId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Mark the peer's `content` as untrusted (ADR 0085), stamped `a2a:<toolId>`.
|
|
35
|
+
* The prompt boundary in `@loomwork/llm` then REFUSES it in any instruction
|
|
36
|
+
* position — system prompt, assistant turn, tool description — and fences it
|
|
37
|
+
* in a `user` message.
|
|
38
|
+
*
|
|
39
|
+
* A peer agent writes its own artifact text, and a peer is exactly as
|
|
40
|
+
* trustworthy as whoever operates it. This is the switch that makes that fact
|
|
41
|
+
* travel with the value.
|
|
42
|
+
*
|
|
43
|
+
* Everything the peer wrote as text is tainted: `content`, and `raw.artifacts` and `raw.error`,
|
|
44
|
+
* which hold the same words. Not the whole output, unlike `mcpInvoke$`: `raw.id` and `raw.status`
|
|
45
|
+
* feed control flow, and forcing an `unwrapTainted()` on code that never touches a prompt would be
|
|
46
|
+
* friction in the wrong place. `content` is the flattened text, so `peer.content` drops straight
|
|
47
|
+
* into an `LlmMessage` slot where the guard sees it.
|
|
48
|
+
*
|
|
49
|
+
* Typed, not just documented: the flag's literal type selects the output
|
|
50
|
+
* type, so a caller that does not opt in sees `A2APeerOutput` exactly as
|
|
51
|
+
* before and compiles unchanged.
|
|
52
|
+
*/
|
|
53
|
+
taintResults?: TTaint;
|
|
54
|
+
/**
|
|
55
|
+
* Vouch for the peer's AgentCard text (ADR 0125 D14; the equivalent of MCP's `trustDescriptions`).
|
|
56
|
+
* Default false: an agent loop shows the model only a neutral placeholder for this peer's
|
|
57
|
+
* description, because the card is written by whoever operates the peer. Set it only for a peer you
|
|
58
|
+
* operate or trust; its card text then reaches the model as instructions, unguarded (a one-time
|
|
59
|
+
* `LOOM_AGENT_TRUSTED_TOOL_TEXT` warning says so).
|
|
60
|
+
*/
|
|
61
|
+
trustDescriptions?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Friendly input accepted by a peer tool — a plain prompt plus optional context. */
|
|
65
|
+
export interface A2APeerInput {
|
|
66
|
+
prompt: string;
|
|
67
|
+
context?: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Peer tool output — flattened artifact text plus the raw A2A result. */
|
|
71
|
+
export interface A2APeerOutput {
|
|
72
|
+
content: string;
|
|
73
|
+
raw: A2ATaskResult;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** {@link A2APeerOutput} under `taintResults` — the text carries its provenance. */
|
|
77
|
+
export interface TaintedA2APeerOutput {
|
|
78
|
+
content: TaintedValue<string>;
|
|
79
|
+
raw: Omit<A2ATaskResult, "artifacts" | "error"> & {
|
|
80
|
+
artifacts?: TaintedValue<NonNullable<A2ATaskResult["artifacts"]>>;
|
|
81
|
+
error?: TaintedValue<string>;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The output shape implied by the `taintResults` flag. A `boolean` (rather than
|
|
87
|
+
* a literal) distributes to the union, which is the honest answer when the flag
|
|
88
|
+
* is only known at runtime.
|
|
89
|
+
*/
|
|
90
|
+
export type A2APeerOutputFor<TTaint extends boolean> = TTaint extends true
|
|
91
|
+
? TaintedA2APeerOutput
|
|
92
|
+
: A2APeerOutput;
|
|
93
|
+
|
|
94
|
+
/** The trio produced when mapping a remote agent: card, tool definition, effect. */
|
|
95
|
+
export interface MappedA2APeer<TTaint extends boolean = false> {
|
|
96
|
+
card: AgentCard;
|
|
97
|
+
definition: ToolDefinition<A2APeerInput, A2APeerOutputFor<TTaint>>;
|
|
98
|
+
effect: Effect<A2APeerInput, A2APeerOutputFor<TTaint>>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Structural view of `@loomwork/registry`'s `AgentRegistry.register`. Declared here
|
|
103
|
+
* so `@loomwork/plugin-a2a` stays dependency-light and does not import the registry package
|
|
104
|
+
* (which itself avoids depending on a2a for the same reason).
|
|
105
|
+
*/
|
|
106
|
+
export interface PeerAgentRegistrar {
|
|
107
|
+
register(definition: AgentDefinition, card?: RegisteredPeerCard): unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface RegisteredPeerCard {
|
|
111
|
+
name: string;
|
|
112
|
+
description?: string;
|
|
113
|
+
url?: string;
|
|
114
|
+
version: string;
|
|
115
|
+
capabilities?: { streaming?: boolean };
|
|
116
|
+
skills?: Array<{ id: string; name: string; description?: string }>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const peerInputSchema = z.object({
|
|
120
|
+
prompt: z.string(),
|
|
121
|
+
context: z.unknown().optional(),
|
|
122
|
+
}) as z.ZodType<A2APeerInput>;
|
|
123
|
+
|
|
124
|
+
// `content` validates BOTH shapes because one schema backs both flag settings.
|
|
125
|
+
// It is the runtime contract; the conditional type is what narrows it per caller.
|
|
126
|
+
const peerOutputSchema = z.object({
|
|
127
|
+
content: z.union([
|
|
128
|
+
z.string(),
|
|
129
|
+
z.object({ __loomTainted: z.object({ origin: z.string() }), value: z.string() }),
|
|
130
|
+
]),
|
|
131
|
+
raw: z.unknown(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
/** Fetch a remote agent's AgentCard from its well-known discovery endpoint. */
|
|
135
|
+
export async function fetchAgentCard(
|
|
136
|
+
url: string,
|
|
137
|
+
headers: Record<string, string> = {}
|
|
138
|
+
): Promise<AgentCard> {
|
|
139
|
+
const resp = await fetch(`${url}/.well-known/agent.json`, {
|
|
140
|
+
headers: { accept: "application/json", ...headers },
|
|
141
|
+
});
|
|
142
|
+
if (!resp.ok) {
|
|
143
|
+
throw new Error(`A2A discovery failed ${resp.status}: ${await resp.text()}`);
|
|
144
|
+
}
|
|
145
|
+
return (await resp.json()) as AgentCard;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export class A2APeerClient<TTaint extends boolean = false> {
|
|
149
|
+
private card: AgentCard | undefined;
|
|
150
|
+
|
|
151
|
+
constructor(private readonly config: A2APeerConfig<TTaint>) {}
|
|
152
|
+
|
|
153
|
+
/** Fetch and cache the remote AgentCard. */
|
|
154
|
+
async discover(): Promise<AgentCard> {
|
|
155
|
+
if (this.card === undefined) {
|
|
156
|
+
this.card = await fetchAgentCard(this.config.url, this.config.headers);
|
|
157
|
+
}
|
|
158
|
+
return this.card;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Discover the peer and build its Loom tool definition + effect (no registration). */
|
|
162
|
+
async map(): Promise<MappedA2APeer<TTaint>> {
|
|
163
|
+
const card = await this.discover();
|
|
164
|
+
const toolId = this.config.toolId ?? slug(card.name);
|
|
165
|
+
const effect = this.buildEffect(toolId);
|
|
166
|
+
|
|
167
|
+
const definition = tool$<A2APeerInput, A2APeerOutputFor<TTaint>>({
|
|
168
|
+
id: toolId,
|
|
169
|
+
name: card.name,
|
|
170
|
+
description: describe(card),
|
|
171
|
+
kind: "a2a",
|
|
172
|
+
// R22 (ADR 0125 D7) — built from a REMOTE agent card: tool search must not rank or show its text.
|
|
173
|
+
origin: "remote",
|
|
174
|
+
...(this.config.trustDescriptions === true && { trustJsonSchemaText: true }),
|
|
175
|
+
// loom's own static schema (not the card's): its `.describe()` text is loom-authored, so it is kept.
|
|
176
|
+
schemaOrigin: "local",
|
|
177
|
+
inputSchema: peerInputSchema,
|
|
178
|
+
outputSchema: peerOutputSchema as unknown as z.ZodType<A2APeerOutputFor<TTaint>>,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return { card, definition, effect };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Discover the peer, register it as an effect, and return the tool definition.
|
|
186
|
+
* Pass a registry to also expose the peer to the planner/intent layer.
|
|
187
|
+
*/
|
|
188
|
+
async registerInto(
|
|
189
|
+
effectRuntime: EffectRuntime,
|
|
190
|
+
registry?: PeerAgentRegistrar
|
|
191
|
+
): Promise<ToolDefinition<A2APeerInput, A2APeerOutputFor<TTaint>>> {
|
|
192
|
+
const { card, definition, effect } = await this.map();
|
|
193
|
+
effectRuntime.register(definition.id, effect);
|
|
194
|
+
|
|
195
|
+
if (registry !== undefined) {
|
|
196
|
+
const remoteAgent = agent$({
|
|
197
|
+
id: definition.id,
|
|
198
|
+
name: card.name,
|
|
199
|
+
...(card.description !== undefined && { description: card.description }),
|
|
200
|
+
tools: [definition],
|
|
201
|
+
version: card.version,
|
|
202
|
+
capabilities: card.capabilities,
|
|
203
|
+
skills: card.skills,
|
|
204
|
+
});
|
|
205
|
+
registry.register(remoteAgent, toRegisteredCard(card));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return definition;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Adapter effect: friendly input -> A2ATask -> underlying client -> flattened output. */
|
|
212
|
+
private buildEffect(toolId: string): Effect<A2APeerInput, A2APeerOutputFor<TTaint>> {
|
|
213
|
+
const clientOptions: A2AClientOptions = {
|
|
214
|
+
...(this.config.headers !== undefined && { headers: this.config.headers }),
|
|
215
|
+
...(this.config.timeoutMs !== undefined && { timeoutMs: this.config.timeoutMs }),
|
|
216
|
+
...(this.config.pollIntervalMs !== undefined && {
|
|
217
|
+
pollIntervalMs: this.config.pollIntervalMs,
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
const client = createA2AClientEffect(this.config.url, clientOptions);
|
|
221
|
+
|
|
222
|
+
return new A2AEffect<A2APeerInput, A2APeerOutputFor<TTaint>>(
|
|
223
|
+
async (input: A2APeerInput, ctx: EffectContext): Promise<A2APeerOutputFor<TTaint>> => {
|
|
224
|
+
const task: A2ATask = {
|
|
225
|
+
// Deterministic id derived from the execution so replays stay stable.
|
|
226
|
+
// A transient call stamped as an execution (ctx.decide's scope) shares
|
|
227
|
+
// (executionId, stepId) with its siblings; callKey keeps their ids distinct.
|
|
228
|
+
id: `${ctx.executionId}:${ctx.stepId}${ctx.callKey !== undefined ? `:${ctx.callKey}` : ""}`,
|
|
229
|
+
message: { role: "user", parts: [{ type: "text", text: input.prompt }] },
|
|
230
|
+
...(input.context !== undefined && { metadata: { context: input.context } }),
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const result = await client.execute(task, ctx);
|
|
234
|
+
const content = artifactText(result);
|
|
235
|
+
// Taint at INGRESS — the wrapper IS the step output, so it is what
|
|
236
|
+
// STEP_COMPLETED records and what a replay hands back. The cast is the
|
|
237
|
+
// one place the conditional return type cannot be proved structurally.
|
|
238
|
+
if (this.config.taintResults !== true) {
|
|
239
|
+
return { content, raw: result } as A2APeerOutputFor<TTaint>;
|
|
240
|
+
}
|
|
241
|
+
const origin = `a2a:${toolId}`;
|
|
242
|
+
const { artifacts, error, ...state } = result;
|
|
243
|
+
return {
|
|
244
|
+
content: tainted(content, origin),
|
|
245
|
+
raw: {
|
|
246
|
+
...state,
|
|
247
|
+
...(artifacts !== undefined && { artifacts: tainted(artifacts, origin) }),
|
|
248
|
+
...(error !== undefined && { error: tainted(error, origin) }),
|
|
249
|
+
},
|
|
250
|
+
} as A2APeerOutputFor<TTaint>;
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Flatten all text parts across all artifacts of a completed/failed A2A result. */
|
|
257
|
+
function artifactText(result: A2ATaskResult): string {
|
|
258
|
+
if (result.error !== undefined) {
|
|
259
|
+
return result.error;
|
|
260
|
+
}
|
|
261
|
+
return (result.artifacts ?? [])
|
|
262
|
+
.flatMap((artifact) => artifact.parts.map((part) => part.text))
|
|
263
|
+
.join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Build an LLM-facing tool description from the card's summary and skills. */
|
|
267
|
+
function describe(card: AgentCard): string {
|
|
268
|
+
const base = card.description ?? card.name;
|
|
269
|
+
if (card.skills.length === 0) {
|
|
270
|
+
return base;
|
|
271
|
+
}
|
|
272
|
+
const skills = card.skills.map((s) => s.name).join(", ");
|
|
273
|
+
return `${base} (skills: ${skills})`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toRegisteredCard(card: AgentCard): RegisteredPeerCard {
|
|
277
|
+
return {
|
|
278
|
+
name: card.name,
|
|
279
|
+
...(card.description !== undefined && { description: card.description }),
|
|
280
|
+
url: card.url,
|
|
281
|
+
version: card.version,
|
|
282
|
+
capabilities: card.capabilities,
|
|
283
|
+
skills: card.skills.map((s) => ({
|
|
284
|
+
id: s.id,
|
|
285
|
+
name: s.name,
|
|
286
|
+
...(s.description !== undefined && { description: s.description }),
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function slug(name: string): string {
|
|
292
|
+
return (
|
|
293
|
+
name
|
|
294
|
+
.toLowerCase()
|
|
295
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
296
|
+
.replace(/^-+|-+$/g, "") || "a2a-peer"
|
|
297
|
+
);
|
|
298
|
+
}
|
package/src/A2AServer.ts
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as http from "node:http";
|
|
3
|
+
import { BadRequestError, exposeError } from "@loomwork/core";
|
|
4
|
+
import type { PrincipalChain } from "@loomwork/effect-runtime";
|
|
5
|
+
import type { RegisteredFlow, WorkflowRuntime } from "@loomwork/workflow-runtime";
|
|
6
|
+
import { LoomAgentExecutor, hasActor } from "./AgentExecutor";
|
|
7
|
+
import type { A2ATask, AgentCard } from "./types";
|
|
8
|
+
|
|
9
|
+
export interface A2AServerOptions {
|
|
10
|
+
port?: number;
|
|
11
|
+
/**
|
|
12
|
+
* R12 — the interface to bind. Default `"127.0.0.1"` (loopback only): a server is reachable
|
|
13
|
+
* from this machine and nothing else until you widen it deliberately (`"0.0.0.0"`, `"::"`, or
|
|
14
|
+
* a specific interface address). Binding all interfaces was the old default.
|
|
15
|
+
*/
|
|
16
|
+
host?: string;
|
|
17
|
+
/**
|
|
18
|
+
* R12 — the URL the agent card advertises, when it differs from the bind address (a server bound
|
|
19
|
+
* to `"0.0.0.0"` would otherwise advertise `localhost`, which no remote peer can reach). Set it
|
|
20
|
+
* to the externally reachable base URL (reverse proxy / load balancer). Default: derived from
|
|
21
|
+
* `host` and the bound port.
|
|
22
|
+
*/
|
|
23
|
+
publicUrl?: string;
|
|
24
|
+
/**
|
|
25
|
+
* R12 — authenticate AND identify the caller in one step: return the caller's
|
|
26
|
+
* {@link PrincipalChain}, or `undefined` to refuse the request (401). Derive it from connection
|
|
27
|
+
* facts only (verified bearer token, mTLS peer) — never from the task body. This is the
|
|
28
|
+
* recommended gate: it also scopes task ownership to the principal (see below).
|
|
29
|
+
*
|
|
30
|
+
* The principal MUST carry a non-empty string `actor` (task ownership is keyed on it); any other
|
|
31
|
+
* principal is refused with 401 and a one-time warning. When both this and
|
|
32
|
+
* {@link resolvePrincipal} are set, THIS wins and `resolvePrincipal` is ignored.
|
|
33
|
+
*/
|
|
34
|
+
authenticate?: (
|
|
35
|
+
req: http.IncomingMessage
|
|
36
|
+
) => PrincipalChain | undefined | Promise<PrincipalChain | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* Gate the task routes (`POST /tasks/send`, `GET /tasks/:id`). Return `true` to allow. The
|
|
39
|
+
* agent-card route stays open. A boolean carries no identity, so every caller that passes it
|
|
40
|
+
* shares ONE task namespace unless `resolvePrincipal`/`authenticate` says who they are.
|
|
41
|
+
*
|
|
42
|
+
* R12 — the server REFUSES TO BE CREATED with neither `authenticate` nor `authorize` unless
|
|
43
|
+
* {@link insecure} is `true`.
|
|
44
|
+
*/
|
|
45
|
+
authorize?: (req: http.IncomingMessage) => boolean | Promise<boolean>;
|
|
46
|
+
/**
|
|
47
|
+
* R12 — explicit opt-out of authentication: any caller that can reach the socket can submit
|
|
48
|
+
* tasks. Logs a loud warning at creation. Intended for local demos and tests; combine with the
|
|
49
|
+
* loopback default. Refused when {@link strict} is set.
|
|
50
|
+
*/
|
|
51
|
+
insecure?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* R12 — `defineConfig`'s `security.strict`. Under strict the server is a config error unless it
|
|
54
|
+
* authenticates AND resolves a principal; `insecure: true` is refused; and a caller that
|
|
55
|
+
* resolves to no principal is refused (401). DEFAULTS from the runtime you pass
|
|
56
|
+
* (`workflowRuntime.requiresPrincipal`), so a strict runtime makes the server strict without
|
|
57
|
+
* this flag; set it explicitly to override.
|
|
58
|
+
*/
|
|
59
|
+
strict?: boolean;
|
|
60
|
+
/** Where the insecure-mode warning goes. Default `console.warn`. */
|
|
61
|
+
warn?: (message: string) => void;
|
|
62
|
+
/**
|
|
63
|
+
* R3 — the AUTHENTICATED principal behind a request, derived from the same connection facts
|
|
64
|
+
* `authorize` checked (mTLS peer, verified bearer token, signed header) — NEVER from the task
|
|
65
|
+
* body. Applied to the runtime call for every task route (`/tasks/send`, `GET /tasks/:id`).
|
|
66
|
+
* Without it every task is anonymous, which a runtime that fails closed (`security.strict`)
|
|
67
|
+
* refuses (`ExecutionDeniedError`): an A2A server is an edge, and under strict an edge must
|
|
68
|
+
* say who is calling. Returning `undefined` for a request means "anonymous".
|
|
69
|
+
*/
|
|
70
|
+
resolvePrincipal?: (
|
|
71
|
+
req: http.IncomingMessage
|
|
72
|
+
) => PrincipalChain | undefined | Promise<PrincipalChain | undefined>;
|
|
73
|
+
/**
|
|
74
|
+
* Called with the real error behind a 500, alongside the `errorId` the caller was given.
|
|
75
|
+
* The response itself carries no internal detail, so this is the only place it surfaces —
|
|
76
|
+
* wire it to your logger or the detail is simply lost.
|
|
77
|
+
*/
|
|
78
|
+
onError?: (errorId: string, err: unknown) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface A2AServer {
|
|
82
|
+
start(): Promise<void>;
|
|
83
|
+
stop(): Promise<void>;
|
|
84
|
+
readonly url: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cap request bodies so an unauthenticated caller can't OOM the process by streaming a
|
|
88
|
+
// huge (or endless) body into memory before parse. Matches the isolate broker's 10 MB cap.
|
|
89
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
90
|
+
// `task.id` becomes the executionId, which is embedded in event ids and carried in the
|
|
91
|
+
// Postgres NOTIFY payload (itself capped at ~8 KB). Generous for a ULID or a UUID.
|
|
92
|
+
const MAX_TASK_ID_LENGTH = 200;
|
|
93
|
+
|
|
94
|
+
function parseBody(req: http.IncomingMessage): Promise<unknown> {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const chunks: Uint8Array[] = [];
|
|
97
|
+
let size = 0;
|
|
98
|
+
req.on("data", (chunk: Uint8Array) => {
|
|
99
|
+
size += chunk.length;
|
|
100
|
+
if (size > MAX_BODY_BYTES) {
|
|
101
|
+
req.destroy();
|
|
102
|
+
reject(new BadRequestError(`request body exceeds ${MAX_BODY_BYTES} bytes`));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
chunks.push(chunk);
|
|
106
|
+
});
|
|
107
|
+
req.on("end", () => {
|
|
108
|
+
try {
|
|
109
|
+
const raw = Buffer.concat(chunks);
|
|
110
|
+
resolve(JSON.parse(raw.toString("utf-8")) as unknown);
|
|
111
|
+
} catch {
|
|
112
|
+
// The caller's own malformed JSON — safe to name, and useless to hide.
|
|
113
|
+
reject(new BadRequestError("request body is not valid JSON"));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
req.on("error", (err: Error) => reject(err));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function json(res: http.ServerResponse, status: number, body: unknown): void {
|
|
121
|
+
const payload = JSON.stringify(body);
|
|
122
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
123
|
+
res.end(payload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createA2AServer(
|
|
127
|
+
workflowRuntime: WorkflowRuntime,
|
|
128
|
+
flow: RegisteredFlow,
|
|
129
|
+
agentCard: Omit<AgentCard, "url">,
|
|
130
|
+
options: A2AServerOptions = {}
|
|
131
|
+
): A2AServer {
|
|
132
|
+
const port = options.port ?? 3001;
|
|
133
|
+
const host = options.host ?? "127.0.0.1";
|
|
134
|
+
const authenticated = options.authenticate !== undefined || options.authorize !== undefined;
|
|
135
|
+
const strict = options.strict ?? workflowRuntime.requiresPrincipal === true;
|
|
136
|
+
if (strict) {
|
|
137
|
+
if (options.insecure === true) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"createA2AServer: `insecure: true` is refused under security.strict — authenticate callers."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (options.authenticate === undefined && options.resolvePrincipal === undefined) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
"createA2AServer: security.strict requires `authenticate` (or `resolvePrincipal`) — every task must carry the caller's principal."
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!authenticated && options.insecure !== true) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"createA2AServer: refusing to create an unauthenticated server. Supply `authenticate` (recommended) or `authorize`, or pass `insecure: true` to opt out explicitly."
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (!authenticated) {
|
|
154
|
+
(options.warn ?? ((m: string) => console.warn(m)))(
|
|
155
|
+
`[loom] WARNING: A2A server is running with insecure: true — NO authentication; any caller that can reach ${host}:${port} can submit and read tasks.`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const urlHost =
|
|
159
|
+
host === "0.0.0.0" || host === "::" ? "localhost" : host.includes(":") ? `[${host}]` : host;
|
|
160
|
+
let resolvedUrl = `http://${urlHost}:${port}`;
|
|
161
|
+
const warn = options.warn ?? ((m: string) => console.warn(m));
|
|
162
|
+
let warnedNoActor = false;
|
|
163
|
+
|
|
164
|
+
// The whole A2A layer is this one adapter; the server just moves bytes to it. Task ids are
|
|
165
|
+
// namespaced by principal (R12): the client's id is only a label within its own namespace.
|
|
166
|
+
const executor = new LoomAgentExecutor(workflowRuntime, flow, { namespaceByPrincipal: true });
|
|
167
|
+
|
|
168
|
+
const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse): void => {
|
|
169
|
+
void (async () => {
|
|
170
|
+
const url = req.url ?? "/";
|
|
171
|
+
const method = req.method ?? "GET";
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
if (method === "GET" && url === "/.well-known/agent.json") {
|
|
175
|
+
json(res, 200, { ...agentCard, url: options.publicUrl ?? resolvedUrl });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Gate the task routes when an authorizer is wired; the agent card stays open.
|
|
180
|
+
if (options.authorize !== undefined && url !== "/.well-known/agent.json") {
|
|
181
|
+
if (!(await options.authorize(req))) {
|
|
182
|
+
json(res, 401, { error: "unauthorized" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// R12 — authenticate: identity or refusal.
|
|
188
|
+
let principal: PrincipalChain | undefined;
|
|
189
|
+
if (options.authenticate !== undefined) {
|
|
190
|
+
principal = await options.authenticate(req);
|
|
191
|
+
if (principal === undefined) {
|
|
192
|
+
json(res, 401, { error: "unauthorized" });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// R3 — who is calling, resolved once per request, after `authorize`.
|
|
197
|
+
principal ??= await options.resolvePrincipal?.(req);
|
|
198
|
+
// R12 — fail closed: a principal that cannot be given its own namespace (no actor) must
|
|
199
|
+
// not be pooled with other such callers; under strict an unidentified caller is refused.
|
|
200
|
+
if (principal !== undefined && !hasActor(principal)) {
|
|
201
|
+
if (!warnedNoActor) {
|
|
202
|
+
warnedNoActor = true;
|
|
203
|
+
warn(
|
|
204
|
+
"[loom] A2A server: refusing a caller whose principal has no non-empty string `actor` — `authenticate`/`resolvePrincipal` must return an `actor` (task ownership is keyed on it)."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
json(res, 401, { error: "unauthorized" });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (principal === undefined && strict) {
|
|
211
|
+
json(res, 401, { error: "unauthorized" });
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// The task namespace: the principal when known; one shared bucket for callers that were
|
|
215
|
+
// authorized but not identified; "anonymous" under `insecure`.
|
|
216
|
+
const call = {
|
|
217
|
+
...(principal !== undefined && { principal }),
|
|
218
|
+
...(principal === undefined && authenticated && { namespace: "authorized" }),
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (method === "POST" && url === "/tasks/send") {
|
|
222
|
+
const task = (await parseBody(req)) as A2ATask;
|
|
223
|
+
// `task.id` becomes the executionId VERBATIM, so it is screened here rather than
|
|
224
|
+
// only deep in the kernel — a rejected id should read as a 400 from the adapter,
|
|
225
|
+
// not surface as a 500 from the runtime.
|
|
226
|
+
if (typeof task?.id !== "string" || task.id.length === 0) {
|
|
227
|
+
json(res, 400, { error: "task.id must be a non-empty string" });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
// The kernel reserves `__loom_*` for its own control streams (retention lifecycle,
|
|
231
|
+
// erasure). An execution created there both corrupts that audit trail and becomes
|
|
232
|
+
// invisible to every enumerator, so retention never reclaims it.
|
|
233
|
+
if (task.id.startsWith("__loom_")) {
|
|
234
|
+
json(res, 400, { error: 'task.id must not use the reserved "__loom_" prefix' });
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// Bound it: the id is embedded in every event id and travels in the Postgres
|
|
238
|
+
// NOTIFY payload, which is itself capped — an unbounded id turns into failing
|
|
239
|
+
// appends rather than a rejected request.
|
|
240
|
+
if (task.id.length > MAX_TASK_ID_LENGTH) {
|
|
241
|
+
json(res, 400, { error: `task.id exceeds ${MAX_TASK_ID_LENGTH} characters` });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const { id } = await executor.submit(task, call);
|
|
245
|
+
json(res, 202, { id });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const taskMatch = /^\/tasks\/([^/]+)$/.exec(url);
|
|
250
|
+
if (method === "GET" && taskMatch !== null) {
|
|
251
|
+
const rawTaskId = taskMatch[1];
|
|
252
|
+
if (rawTaskId === undefined) {
|
|
253
|
+
json(res, 400, { error: "missing task id" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// The path segment is percent-encoded on the wire (a client MUST encode an id
|
|
257
|
+
// containing `/` or other reserved characters, or URL normalization upstream of this
|
|
258
|
+
// server — e.g. `fetch`'s own dot-segment collapsing — could otherwise turn an id like
|
|
259
|
+
// `../admin` into a request for a different route entirely). Decode it back to the
|
|
260
|
+
// real task id before it reaches the executor.
|
|
261
|
+
let taskId: string;
|
|
262
|
+
try {
|
|
263
|
+
taskId = decodeURIComponent(rawTaskId);
|
|
264
|
+
} catch {
|
|
265
|
+
json(res, 400, { error: "task id is not validly percent-encoded" });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const result = await executor.get(taskId, call);
|
|
269
|
+
if (result === null) {
|
|
270
|
+
json(res, 404, { error: `task ${taskId} not found` });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
json(res, 200, result);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
json(res, 404, { error: "not found" });
|
|
278
|
+
} catch (err) {
|
|
279
|
+
const exposed = exposeError(err, randomUUID);
|
|
280
|
+
if (exposed.internal !== undefined) {
|
|
281
|
+
options.onError?.(exposed.body.errorId as string, exposed.internal);
|
|
282
|
+
}
|
|
283
|
+
json(res, exposed.status, exposed.body);
|
|
284
|
+
}
|
|
285
|
+
})();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
get url() {
|
|
290
|
+
return resolvedUrl;
|
|
291
|
+
},
|
|
292
|
+
start(): Promise<void> {
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
server.listen(port, host, () => {
|
|
295
|
+
const addr = server.address();
|
|
296
|
+
if (addr !== null && typeof addr === "object") {
|
|
297
|
+
resolvedUrl = `http://${urlHost}:${addr.port}`;
|
|
298
|
+
}
|
|
299
|
+
resolve();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
stop(): Promise<void> {
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
server.close((err?: Error) => (err !== undefined ? reject(err) : resolve()));
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|