@loomwork/plugin-a2a 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,192 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import type { ExecutionState } from "@loomwork/core";
3
+ import type { PrincipalChain } from "@loomwork/effect-runtime";
4
+ import type { RegisteredFlow, ResumeEvent, WorkflowRuntime } from "@loomwork/workflow-runtime";
5
+ import type { A2ATask, A2ATaskResult, A2ATaskStatus } from "./types";
6
+
7
+ /**
8
+ * The A2A task lifecycle, expressed as a small executor interface. Its states map
9
+ * one-to-one onto Loom's execution states, so the entire A2A layer reduces to a
10
+ * single adapter over the runtime:
11
+ *
12
+ * submit / working → runtime.start → running
13
+ * input-required → SuspendError / status "suspended" (approval / agent-surface form)
14
+ * provideInput → runtime.resume(executionId, resumeEvent)
15
+ * completed / failed → terminal ExecutionState
16
+ *
17
+ * A transport (the in-house HTTP server below, or an external @a2a-js/sdk server)
18
+ * only has to move bytes to/from this interface.
19
+ */
20
+ export interface AgentExecutor {
21
+ submit(task: A2ATask, call?: ExecutorCall): Promise<{ id: string }>;
22
+ /** Poll a task's status/result. `null` ⇒ unknown task (→ 404). */
23
+ get(taskId: string, call?: ExecutorCall): Promise<A2ATaskResult | null>;
24
+ /** Deliver the input an `input-required` (suspended) task is waiting on. */
25
+ provideInput(taskId: string, event: ResumeEvent, call?: ExecutorCall): Promise<{ id: string }>;
26
+ }
27
+
28
+ /**
29
+ * R3 — per-call context from the transport. `principal` is the AUTHENTICATED identity of whoever
30
+ * made the request (resolved by the transport from its connection, never from the task body);
31
+ * the executor applies it to the runtime call so a runtime that fails closed (`security.strict`)
32
+ * authorizes the task as that caller. Absent ⇒ anonymous, which such a runtime refuses.
33
+ */
34
+ export interface ExecutorCall {
35
+ principal?: PrincipalChain;
36
+ /**
37
+ * R12 — the task namespace when no principal identifies the caller (e.g. a boolean `authorize`
38
+ * gate). Ignored when `principal` is present (the principal's identity wins). Only meaningful
39
+ * on an executor created with `namespaceByPrincipal`.
40
+ */
41
+ namespace?: string;
42
+ }
43
+
44
+ export interface LoomAgentExecutorOptions {
45
+ /**
46
+ * R12 — scope task ids to the caller. The execution id the runtime sees is
47
+ * `a2a.<namespace of caller>.<client task id>`: a caller can only ever name executions in its
48
+ * OWN namespace, so it cannot read, resume or collide with another caller's task by guessing
49
+ * or choosing an id (the lookup simply lands on a different, nonexistent execution). The
50
+ * client-visible id stays the one it chose. Default `false` for direct (in-process) use of the
51
+ * executor, where the id is the embedder's own; the HTTP server turns it on.
52
+ */
53
+ namespaceByPrincipal?: boolean;
54
+ }
55
+
56
+ /** R12 — does this principal carry the minimum identity a per-caller namespace needs? */
57
+ export function hasActor(principal: PrincipalChain): boolean {
58
+ return typeof principal.actor === "string" && principal.actor.trim().length > 0;
59
+ }
60
+
61
+ /**
62
+ * R12 — a stable, opaque key for a principal; the namespace its tasks live in. Two callers with
63
+ * different identity can never address each other's execution ids, because the id the runtime
64
+ * sees embeds this key. Tenant + actor + onBehalfOf are the identity; a delegation token is not
65
+ * (it is a credential, and rotating it must not orphan the caller's tasks).
66
+ */
67
+ export function principalNamespace(principal: PrincipalChain | undefined): string {
68
+ if (principal === undefined) return "anonymous";
69
+ // Fail closed: `PrincipalChain` makes every field optional and allows extras, so a principal
70
+ // identifying callers by another field (`{ sub }`) would hash to ONE shared namespace and hand
71
+ // every such caller each other's tasks. An actor is the minimum identity.
72
+ if (!hasActor(principal)) {
73
+ throw new Error(
74
+ "principalNamespace: the principal has no non-empty string `actor` — it cannot be given a per-caller task namespace"
75
+ );
76
+ }
77
+ const identity = JSON.stringify([
78
+ principal.tenant ?? null,
79
+ principal.actor ?? null,
80
+ principal.onBehalfOf ?? null,
81
+ ]);
82
+ return createHash("sha256").update(identity).digest("hex").slice(0, 24);
83
+ }
84
+
85
+ function scopedId(
86
+ call: ExecutorCall,
87
+ clientId: string,
88
+ namespaceOf: (p: PrincipalChain | undefined) => string
89
+ ): string {
90
+ const ns =
91
+ call.principal !== undefined ? namespaceOf(call.principal) : (call.namespace ?? "anonymous");
92
+ return `a2a.${ns}.${clientId}`;
93
+ }
94
+
95
+ /** Strip the `a2a.<namespace>.` prefix back off an execution id. */
96
+ function clientIdOf(executionId: string): string {
97
+ const m = /^a2a\.[^.]*\.(.*)$/s.exec(executionId);
98
+ return m?.[1] ?? executionId;
99
+ }
100
+
101
+ /** Map a Loom execution status onto an A2A task status. */
102
+ export function executionStatusToA2A(status: string): A2ATaskStatus {
103
+ switch (status) {
104
+ case "completed":
105
+ return "completed";
106
+ case "failed":
107
+ return "failed";
108
+ case "running":
109
+ case "suspended":
110
+ return "working";
111
+ default:
112
+ return "submitted";
113
+ }
114
+ }
115
+
116
+ /** Fold a terminal/interim ExecutionState into the A2A task result shape. */
117
+ export function toTaskResult(taskId: string, state: ExecutionState): A2ATaskResult {
118
+ const a2aStatus = executionStatusToA2A(state.status);
119
+ const result: A2ATaskResult = { id: taskId, status: a2aStatus };
120
+ if (a2aStatus === "completed") {
121
+ const output = state.variables._output;
122
+ result.artifacts = [
123
+ {
124
+ parts: [
125
+ { type: "text", text: typeof output === "string" ? output : JSON.stringify(output) },
126
+ ],
127
+ },
128
+ ];
129
+ } else if (a2aStatus === "failed") {
130
+ result.error = String(state.variables._error ?? "unknown error");
131
+ }
132
+ return result;
133
+ }
134
+
135
+ /**
136
+ * The single adapter: drives one Loom flow as an A2A agent. It is STATELESS —
137
+ * `get` rebuilds status from the durable log via `runtime.loadState` rather than
138
+ * holding a live handle, so the server can be horizontally scaled or restarted
139
+ * without losing in-flight tasks (the same statelessness the remote-worker model
140
+ * relies on).
141
+ */
142
+ export class LoomAgentExecutor implements AgentExecutor {
143
+ constructor(
144
+ private readonly runtime: WorkflowRuntime,
145
+ private readonly flow: RegisteredFlow,
146
+ private readonly options: LoomAgentExecutorOptions = {}
147
+ ) {}
148
+
149
+ private idFor(call: ExecutorCall, clientId: string): string {
150
+ return this.options.namespaceByPrincipal === true
151
+ ? scopedId(call, clientId, principalNamespace)
152
+ : clientId;
153
+ }
154
+
155
+ async submit(task: A2ATask, call: ExecutorCall = {}): Promise<{ id: string }> {
156
+ const prompt = task.message.parts.map((p) => p.text).join("\n");
157
+ const identity = call.principal !== undefined ? { principal: call.principal } : {};
158
+ const handle = await this.runtime.start(
159
+ this.flow,
160
+ { prompt },
161
+ {
162
+ ...((task.id || this.options.namespaceByPrincipal === true) && {
163
+ executionId: this.idFor(call, task.id || randomUUID()),
164
+ }),
165
+ ...identity,
166
+ }
167
+ );
168
+ // Under namespacing the caller only ever sees the id it chose (or was minted).
169
+ return {
170
+ id:
171
+ this.options.namespaceByPrincipal === true
172
+ ? clientIdOf(handle.executionId)
173
+ : handle.executionId,
174
+ };
175
+ }
176
+
177
+ async get(taskId: string, call: ExecutorCall = {}): Promise<A2ATaskResult | null> {
178
+ const identity = call.principal !== undefined ? { principal: call.principal } : {};
179
+ const state = await this.runtime.loadState(this.idFor(call, taskId), identity);
180
+ return state ? toTaskResult(taskId, state) : null;
181
+ }
182
+
183
+ async provideInput(
184
+ taskId: string,
185
+ event: ResumeEvent,
186
+ call: ExecutorCall = {}
187
+ ): Promise<{ id: string }> {
188
+ const identity = call.principal !== undefined ? { principal: call.principal } : {};
189
+ const handle = await this.runtime.resume(this.idFor(call, taskId), event, identity);
190
+ return { id: taskId };
191
+ }
192
+ }
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export { A2AEffect } from "./A2AEffect";
2
+ export { createA2AClientEffect } from "./A2AClientEffect";
3
+ export type { A2AClientOptions } from "./A2AClientEffect";
4
+ export { A2APeerClient, fetchAgentCard } from "./A2APeerClient";
5
+ export type {
6
+ A2APeerConfig,
7
+ A2APeerInput,
8
+ A2APeerOutput,
9
+ A2APeerOutputFor,
10
+ MappedA2APeer,
11
+ PeerAgentRegistrar,
12
+ TaintedA2APeerOutput,
13
+ } from "./A2APeerClient";
14
+ export { createA2AServer } from "./A2AServer";
15
+ export type { A2AServerOptions, A2AServer } from "./A2AServer";
16
+ export {
17
+ LoomAgentExecutor,
18
+ principalNamespace,
19
+ executionStatusToA2A,
20
+ toTaskResult,
21
+ type AgentExecutor,
22
+ type ExecutorCall,
23
+ type LoomAgentExecutorOptions,
24
+ } from "./AgentExecutor";
25
+ export { toAgentCard } from "./toAgentCard";
26
+ export type { ToAgentCardOptions } from "./toAgentCard";
27
+ export type {
28
+ AgentCard,
29
+ AgentSkill,
30
+ A2AMessage,
31
+ A2AMessagePart,
32
+ A2ATask,
33
+ A2ATaskStatus,
34
+ A2ATaskResult,
35
+ } from "./types";
@@ -0,0 +1,31 @@
1
+ import type { AgentDefinition } from "@loomwork/core";
2
+ import type { AgentCard } from "./types";
3
+
4
+ // Options for deriving an AgentCard from an agent$ definition. Everything here is
5
+ // deployment/runtime context that does not belong on the agent definition itself.
6
+ export interface ToAgentCardOptions {
7
+ // Public URL for the agent. Defaults to the in-process scheme derived from the
8
+ // agent id; an A2AServer overrides this with the resolved address at request
9
+ // time (see createA2AServer, which takes Omit<AgentCard, "url">).
10
+ url?: string;
11
+ // Fallback version if the definition does not carry one.
12
+ version?: string;
13
+ // Fallback capabilities if the definition does not carry them.
14
+ capabilities?: { streaming?: boolean };
15
+ }
16
+
17
+ // Derive the public A2A AgentCard from an agent definition. name/description come
18
+ // straight from the definition; skills/capabilities/version are read from the
19
+ // (optional) card metadata authored on agent$, falling back to opts then to sane
20
+ // defaults. This is the single source of truth that removes the hand-written,
21
+ // drift-prone card objects.
22
+ export function toAgentCard(agent: AgentDefinition, opts: ToAgentCardOptions = {}): AgentCard {
23
+ return {
24
+ name: agent.name,
25
+ description: agent.description ?? "",
26
+ url: opts.url ?? `loom+inproc://${agent.id}`,
27
+ version: agent.version ?? opts.version ?? "1.0.0",
28
+ capabilities: agent.capabilities ?? opts.capabilities ?? { streaming: false },
29
+ skills: agent.skills ?? [],
30
+ };
31
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export interface AgentSkill {
2
+ id: string;
3
+ name: string;
4
+ description?: string;
5
+ inputModes?: string[];
6
+ outputModes?: string[];
7
+ }
8
+
9
+ export interface AgentCard {
10
+ name: string;
11
+ description?: string;
12
+ url: string;
13
+ version: string;
14
+ capabilities: { streaming?: boolean };
15
+ skills: AgentSkill[];
16
+ }
17
+
18
+ export interface A2AMessagePart {
19
+ type: "text";
20
+ text: string;
21
+ }
22
+
23
+ export interface A2AMessage {
24
+ role: "user" | "agent";
25
+ parts: A2AMessagePart[];
26
+ }
27
+
28
+ export interface A2ATask {
29
+ id: string;
30
+ message: A2AMessage;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export type A2ATaskStatus = "submitted" | "working" | "completed" | "failed";
35
+
36
+ export interface A2ATaskResult {
37
+ id: string;
38
+ status: A2ATaskStatus;
39
+ artifacts?: Array<{ name?: string; parts: A2AMessagePart[] }>;
40
+ error?: string;
41
+ }