Skip to content

Commit e7fdfd1

Browse files
committed
feat(sdk): add cloud agent A2A helpers
Build provider-neutral A2A message/send payloads from returned cloudAgent metadata and support injected transports for signed AgentCore follow-up calls.
1 parent 1f37ef6 commit e7fdfd1

5 files changed

Lines changed: 253 additions & 4 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,26 @@ await client.close();
4444

4545
`context` is caller-defined metadata. AgentDispatch stores and forwards it; your runtime decides what to do with it. It is the right place for repo names, issue IDs, branch names, priority, user preferences, or other task-specific hints.
4646

47+
## Continue with A2A
48+
49+
When the runtime returns `cloudAgent.protocol === "a2a"`, the SDK can turn that handoff metadata into a JSON-RPC `message/send` follow-up request. For AWS AgentCore, pass the generated request through a SigV4/AWS SDK transport in your app or agent framework:
50+
51+
```ts
52+
import { sendCloudAgentA2AMessage } from "@agent-dispatch/sdk";
53+
54+
const response = await sendCloudAgentA2AMessage(
55+
task.cloudAgent!,
56+
{ text: "Continue the investigation and focus on IAM findings." },
57+
async (request) => {
58+
// Sign and send `request.url`, `request.headers`, and `request.body`
59+
// with your provider-specific client.
60+
return signedAgentCoreFetch(request);
61+
}
62+
);
63+
64+
console.log(response.text);
65+
```
66+
4767
If you already have an MCP transport in your agent framework, use `AgentDispatchMcpClient` directly:
4868

4969
```ts

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@agent-dispatch/sdk",
3-
"version": "0.1.1",
3+
"version": "0.1.2",
44
"description": "TypeScript SDK for AgentDispatch.",
55
"type": "module",
66
"license": "Apache-2.0",

src/index.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AccountProfile,
33
AdapterCapability,
44
CancelResult,
5+
CloudAgentInteraction,
56
DispatchRequest,
67
LogChunk,
78
TaskHandle,
@@ -80,6 +81,39 @@ export interface AgentDispatchStdioClientOptions extends StdioServerParameters {
8081
clientVersion?: string;
8182
}
8283

84+
export interface A2AMessagePart {
85+
kind: "text" | (string & {});
86+
text?: string;
87+
[key: string]: unknown;
88+
}
89+
90+
export interface CloudAgentA2AMessage {
91+
id?: string;
92+
role?: "user" | "agent" | (string & {});
93+
text?: string;
94+
parts?: A2AMessagePart[];
95+
messageId?: string;
96+
metadata?: Record<string, unknown>;
97+
}
98+
99+
export interface CloudAgentA2AHttpRequest {
100+
url: string;
101+
method: "POST";
102+
headers: Record<string, string>;
103+
body: string;
104+
cloudAgent: CloudAgentInteraction;
105+
}
106+
107+
export interface CloudAgentA2AResult {
108+
raw: unknown;
109+
text?: string;
110+
metadata?: Record<string, unknown>;
111+
}
112+
113+
export type CloudAgentA2ATransport =
114+
| ((request: CloudAgentA2AHttpRequest) => Promise<unknown>)
115+
| { send(request: CloudAgentA2AHttpRequest): Promise<unknown> };
116+
83117
export interface SpawnCloudAgentRequest {
84118
instruction: string;
85119
runtime?: string;
@@ -194,6 +228,68 @@ export function connectAgentDispatchStdioClient(options: AgentDispatchStdioClien
194228
return AgentDispatchStdioClient.connect(options);
195229
}
196230

231+
export function createA2AMessageSendPayload(message: CloudAgentA2AMessage): Record<string, unknown> {
232+
const parts = message.parts ?? (message.text !== undefined ? [{ kind: "text", text: message.text }] : undefined);
233+
if (!parts?.length) {
234+
throw new Error("A2A follow-up requires message.text or message.parts.");
235+
}
236+
237+
return {
238+
jsonrpc: "2.0",
239+
id: message.id ?? createClientId("a2a"),
240+
method: "message/send",
241+
params: {
242+
message: {
243+
role: message.role ?? "user",
244+
parts,
245+
messageId: message.messageId ?? createClientId("msg")
246+
},
247+
...(message.metadata ? { metadata: message.metadata } : {})
248+
}
249+
};
250+
}
251+
252+
export function createCloudAgentA2AHttpRequest(cloudAgent: CloudAgentInteraction, message: CloudAgentA2AMessage): CloudAgentA2AHttpRequest {
253+
if (cloudAgent.protocol !== "a2a") {
254+
throw new Error(`Cloud agent protocol is ${cloudAgent.protocol}, not a2a.`);
255+
}
256+
const url = stringValue(cloudAgent.a2a?.endpointUrl) ?? stringValue(cloudAgent.invocation?.runtimeUrl);
257+
if (!url) {
258+
throw new Error("Cloud agent A2A endpoint URL is missing.");
259+
}
260+
const sessionHeaderName = stringValue(cloudAgent.a2a?.sessionHeaderName) ?? stringValue(cloudAgent.invocation?.sessionHeaderName);
261+
const sessionHeaderValue = stringValue(cloudAgent.a2a?.sessionHeaderValue) ?? stringValue(cloudAgent.invocation?.sessionHeaderValue);
262+
const payload = createA2AMessageSendPayload(message);
263+
const method = stringValue(cloudAgent.a2a?.messageMethod) ?? "message/send";
264+
payload.method = method;
265+
266+
const headers: Record<string, string> = {
267+
"content-type": stringValue(cloudAgent.invocation?.contentType) ?? "application/json",
268+
accept: stringValue(cloudAgent.invocation?.accept) ?? "application/json"
269+
};
270+
if (sessionHeaderName && sessionHeaderValue) {
271+
headers[sessionHeaderName] = sessionHeaderValue;
272+
}
273+
274+
return {
275+
url,
276+
method: "POST",
277+
headers,
278+
body: JSON.stringify(payload),
279+
cloudAgent
280+
};
281+
}
282+
283+
export async function sendCloudAgentA2AMessage(
284+
cloudAgent: CloudAgentInteraction,
285+
message: CloudAgentA2AMessage,
286+
transport: CloudAgentA2ATransport
287+
): Promise<CloudAgentA2AResult> {
288+
const request = createCloudAgentA2AHttpRequest(cloudAgent, message);
289+
const raw = typeof transport === "function" ? await transport(request) : await transport.send(request);
290+
return decodeA2AResult(raw);
291+
}
292+
197293
function removeUndefinedValues(input: Record<string, unknown>): Record<string, unknown> {
198294
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
199295
}
@@ -228,3 +324,32 @@ function isTextContentResult(result: unknown): result is { content: Array<{ type
228324
Array.isArray((result as { content?: unknown }).content)
229325
);
230326
}
327+
328+
function decodeA2AResult(raw: unknown): CloudAgentA2AResult {
329+
const candidate = isRecord(raw) && isRecord(raw.result) ? raw.result : raw;
330+
const message = isRecord(candidate) && isRecord(candidate.message) ? candidate.message : candidate;
331+
const parts = isRecord(message) && Array.isArray(message.parts) ? message.parts : [];
332+
const text = parts
333+
.filter(isRecord)
334+
.map((part) => typeof part.text === "string" ? part.text : "")
335+
.filter(Boolean)
336+
.join("\n") || undefined;
337+
const metadata = isRecord(message) && isRecord(message.metadata)
338+
? message.metadata
339+
: isRecord(candidate) && isRecord(candidate.metadata)
340+
? candidate.metadata
341+
: undefined;
342+
return { raw, text, metadata };
343+
}
344+
345+
function stringValue(value: unknown): string | undefined {
346+
return typeof value === "string" && value.length > 0 ? value : undefined;
347+
}
348+
349+
function isRecord(value: unknown): value is Record<string, unknown> {
350+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
351+
}
352+
353+
function createClientId(prefix: string): string {
354+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
355+
}

test/client.test.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { describe, expect, it } from "vitest";
22
import type { DispatchRequest, TaskRecord } from "@agent-dispatch/core";
3-
import { AgentDispatchClient, AgentDispatchMcpClient, AgentDispatchStdioClient, connectAgentDispatchStdioClient, type AgentDispatchRuntime, type McpToolTransport } from "../src/index.js";
3+
import {
4+
AgentDispatchClient,
5+
AgentDispatchMcpClient,
6+
AgentDispatchStdioClient,
7+
connectAgentDispatchStdioClient,
8+
createA2AMessageSendPayload,
9+
createCloudAgentA2AHttpRequest,
10+
sendCloudAgentA2AMessage,
11+
type AgentDispatchRuntime,
12+
type McpToolTransport
13+
} from "../src/index.js";
414

515
const request: DispatchRequest = {
616
provider: "aws",
@@ -214,3 +224,97 @@ describe("AgentDispatchMcpClient", () => {
214224
expect(typeof connectAgentDispatchStdioClient).toBe("function");
215225
});
216226
});
227+
228+
describe("cloud agent A2A helpers", () => {
229+
const cloudAgent = {
230+
protocol: "a2a",
231+
provider: "aws",
232+
backend: "aws-agentcore",
233+
accountProfile: "dev-aws",
234+
sessionId: "session_123",
235+
invocation: {
236+
type: "aws.agentcore.invoke_agent_runtime",
237+
provider: "aws",
238+
accountProfile: "dev-aws",
239+
runtimeUrl: "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/runtime/invocations/",
240+
sessionHeaderName: "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id",
241+
sessionHeaderValue: "session_123",
242+
contentType: "application/json",
243+
accept: "application/json"
244+
},
245+
a2a: {
246+
transport: "json-rpc-2.0-http",
247+
messageMethod: "message/send",
248+
endpointUrl: "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/runtime/invocations/",
249+
sessionHeaderName: "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id",
250+
sessionHeaderValue: "session_123"
251+
}
252+
} as const;
253+
254+
it("builds A2A message/send payloads", () => {
255+
expect(createA2AMessageSendPayload({ id: "req-1", messageId: "msg-1", text: "continue" })).toEqual({
256+
jsonrpc: "2.0",
257+
id: "req-1",
258+
method: "message/send",
259+
params: {
260+
message: {
261+
role: "user",
262+
parts: [{ kind: "text", text: "continue" }],
263+
messageId: "msg-1"
264+
}
265+
}
266+
});
267+
});
268+
269+
it("builds cloud-agent A2A HTTP requests with AgentCore session headers", () => {
270+
const request = createCloudAgentA2AHttpRequest(cloudAgent, {
271+
id: "req-1",
272+
messageId: "msg-1",
273+
text: "continue",
274+
metadata: { priority: "background" }
275+
});
276+
277+
expect(request).toMatchObject({
278+
url: "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/runtime/invocations/",
279+
method: "POST",
280+
headers: {
281+
"content-type": "application/json",
282+
accept: "application/json",
283+
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": "session_123"
284+
}
285+
});
286+
expect(JSON.parse(request.body)).toMatchObject({
287+
jsonrpc: "2.0",
288+
method: "message/send",
289+
params: {
290+
metadata: { priority: "background" },
291+
message: { parts: [{ kind: "text", text: "continue" }] }
292+
}
293+
});
294+
});
295+
296+
it("sends A2A follow-up messages through an injected transport", async () => {
297+
const calls: unknown[] = [];
298+
const result = await sendCloudAgentA2AMessage(cloudAgent, { text: "next step" }, async (request) => {
299+
calls.push(request);
300+
return {
301+
jsonrpc: "2.0",
302+
id: "req-1",
303+
result: {
304+
kind: "message",
305+
role: "agent",
306+
parts: [{ kind: "text", text: "done" }],
307+
metadata: { ok: true }
308+
}
309+
};
310+
});
311+
312+
expect(calls).toHaveLength(1);
313+
expect(result).toMatchObject({ text: "done", metadata: { ok: true } });
314+
});
315+
316+
it("rejects non-A2A cloud-agent metadata", () => {
317+
expect(() => createCloudAgentA2AHttpRequest({ ...cloudAgent, protocol: "http" }, { text: "continue" }))
318+
.toThrow("not a2a");
319+
});
320+
});

0 commit comments

Comments
 (0)