@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.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +80 -2
  4. package/cards.d.ts +1 -0
  5. package/dist/A2AClientEffect.d.ts +11 -0
  6. package/dist/A2AClientEffect.d.ts.map +1 -0
  7. package/dist/A2AClientEffect.js +89 -0
  8. package/dist/A2AClientEffect.js.map +1 -0
  9. package/dist/A2AEffect.d.ts +13 -0
  10. package/dist/A2AEffect.d.ts.map +1 -0
  11. package/dist/A2AEffect.js +20 -0
  12. package/dist/A2AEffect.js.map +1 -0
  13. package/dist/A2APeerClient.d.ts +126 -0
  14. package/dist/A2APeerClient.d.ts.map +1 -0
  15. package/dist/A2APeerClient.js +167 -0
  16. package/dist/A2APeerClient.js.map +1 -0
  17. package/dist/A2AServer.d.ts +78 -0
  18. package/dist/A2AServer.d.ts.map +1 -0
  19. package/dist/A2AServer.js +239 -0
  20. package/dist/A2AServer.js.map +1 -0
  21. package/dist/AgentExecutor.d.ts +89 -0
  22. package/dist/AgentExecutor.d.ts.map +1 -0
  23. package/dist/AgentExecutor.js +126 -0
  24. package/dist/AgentExecutor.js.map +1 -0
  25. package/dist/index.d.ts +12 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +20 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/toAgentCard.d.ts +11 -0
  30. package/dist/toAgentCard.d.ts.map +1 -0
  31. package/dist/toAgentCard.js +19 -0
  32. package/dist/toAgentCard.js.map +1 -0
  33. package/dist/types.d.ts +41 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +3 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +65 -3
  38. package/src/A2AClientEffect.ts +109 -0
  39. package/src/A2AEffect.ts +16 -0
  40. package/src/A2APeerClient.ts +298 -0
  41. package/src/A2AServer.ts +309 -0
  42. package/src/AgentExecutor.ts +192 -0
  43. package/src/index.ts +35 -0
  44. package/src/toAgentCard.ts +31 -0
  45. 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
+ }
@@ -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
+ }