aixyz 0.20.0 → 0.22.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.
- package/accepts.ts +1 -0
- package/app/adapters/express.ts +129 -0
- package/app/index.ts +138 -0
- package/app/payment/payment.ts +157 -0
- package/app/plugin.ts +7 -0
- package/{server/adapters → app/plugins}/a2a.ts +66 -54
- package/app/plugins/erc-8004.ts +66 -0
- package/app/plugins/index-page.ts +46 -0
- package/app/plugins/mcp.ts +107 -0
- package/app/types.ts +14 -0
- package/config.ts +1 -1
- package/package.json +24 -10
- package/model.test.ts +0 -135
- package/server/adapters/a2a.test.ts +0 -161
- package/server/adapters/erc-8004.ts +0 -71
- package/server/adapters/mcp.ts +0 -103
- package/server/index.ts +0 -96
package/accepts.ts
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { IncomingMessage } from "node:http";
|
|
2
|
+
import type { AixyzApp } from "../index";
|
|
3
|
+
|
|
4
|
+
/** Minimal request shape compatible with both Node's IncomingMessage and Express's Request. */
|
|
5
|
+
interface NodeRequest {
|
|
6
|
+
headers: IncomingMessage["headers"];
|
|
7
|
+
url?: string;
|
|
8
|
+
method?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Minimal response shape compatible with both Node's ServerResponse and Express's Response. */
|
|
12
|
+
interface NodeResponse {
|
|
13
|
+
writeHead(statusCode: number, headers?: Record<string, string>): this;
|
|
14
|
+
write(chunk: unknown): boolean;
|
|
15
|
+
end(): this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Converts an {@link AixyzApp} into Express-compatible middleware.
|
|
20
|
+
*
|
|
21
|
+
* This adapter bridges the web-standard `Request`/`Response` API used by
|
|
22
|
+
* `AixyzApp` with Express's Node-style `(req, res, next)` middleware signature.
|
|
23
|
+
* Incoming Express requests are converted to web-standard `Request` objects,
|
|
24
|
+
* routed through `app.fetch()`, and the resulting `Response` is streamed back
|
|
25
|
+
* to the Express response. If the `AixyzApp` does not match the request
|
|
26
|
+
* (404), control is passed to the next Express middleware via `next()`.
|
|
27
|
+
*
|
|
28
|
+
* The returned middleware handles all AixyzApp concerns — A2A, MCP, x402
|
|
29
|
+
* payment verification, and any registered plugins — so you can mount it
|
|
30
|
+
* alongside your own Express routes without conflict.
|
|
31
|
+
*
|
|
32
|
+
* **Important:** Do not apply `express.json()` or other body-parsing middleware
|
|
33
|
+
* before this middleware, as it needs access to the raw request body stream.
|
|
34
|
+
*
|
|
35
|
+
* @param app - A fully initialized {@link AixyzApp} instance (call
|
|
36
|
+
* `app.initialize()` before passing it here).
|
|
37
|
+
* @returns An async Express middleware function `(req, res, next) => void`.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import express from "express";
|
|
42
|
+
* import { AixyzApp } from "aixyz/app";
|
|
43
|
+
* import { toExpressMiddleware } from "aixyz/app/adapters/express";
|
|
44
|
+
* import { A2APlugin } from "aixyz/app/plugins/a2a";
|
|
45
|
+
* import { MCPPlugin } from "aixyz/app/plugins/mcp";
|
|
46
|
+
* import * as agent from "./agent";
|
|
47
|
+
* import * as myTool from "./tools/my-tool";
|
|
48
|
+
*
|
|
49
|
+
* const app = new AixyzApp();
|
|
50
|
+
* await app.withPlugin(new A2APlugin(agent));
|
|
51
|
+
* await app.withPlugin(new MCPPlugin([{ name: "myTool", exports: myTool }]));
|
|
52
|
+
* await app.initialize();
|
|
53
|
+
*
|
|
54
|
+
* const server = express();
|
|
55
|
+
*
|
|
56
|
+
* // Your own Express routes
|
|
57
|
+
* server.get("/health", (_req, res) => res.json({ status: "ok" }));
|
|
58
|
+
*
|
|
59
|
+
* // Mount AixyzApp (handles /agent, /mcp, /.well-known/agent-card.json, etc.)
|
|
60
|
+
* server.use(toExpressMiddleware(app));
|
|
61
|
+
*
|
|
62
|
+
* server.listen(3000);
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function toExpressMiddleware(app: AixyzApp) {
|
|
66
|
+
return async (req: NodeRequest, res: NodeResponse, next: (err?: unknown) => void) => {
|
|
67
|
+
try {
|
|
68
|
+
const request = toWebRequest(req);
|
|
69
|
+
const response = await app.fetch(request);
|
|
70
|
+
|
|
71
|
+
// Let Express handle unmatched routes
|
|
72
|
+
if (response.status === 404) {
|
|
73
|
+
return next();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await writeResponse(response, res);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
next(err);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toWebRequest(req: NodeRequest): Request {
|
|
84
|
+
const protocol = (req.headers["x-forwarded-proto"] as string) || "http";
|
|
85
|
+
const host = req.headers.host || "localhost";
|
|
86
|
+
const url = `${protocol}://${host}${req.url || "/"}`;
|
|
87
|
+
|
|
88
|
+
const headers = new Headers();
|
|
89
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
90
|
+
if (value === undefined) continue;
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
for (const v of value) headers.append(key, v);
|
|
93
|
+
} else {
|
|
94
|
+
headers.set(key, value);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const method = (req.method || "GET").toUpperCase();
|
|
99
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
100
|
+
|
|
101
|
+
return new Request(url, {
|
|
102
|
+
method,
|
|
103
|
+
headers,
|
|
104
|
+
body: hasBody ? (req as unknown as ReadableStream) : undefined,
|
|
105
|
+
// @ts-expect-error -- Node 18+ supports duplex on RequestInit
|
|
106
|
+
duplex: hasBody ? "half" : undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeResponse(response: Response, res: NodeResponse): Promise<void> {
|
|
111
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
112
|
+
|
|
113
|
+
if (!response.body) {
|
|
114
|
+
res.end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const reader = response.body.getReader();
|
|
119
|
+
try {
|
|
120
|
+
while (true) {
|
|
121
|
+
const { done, value } = await reader.read();
|
|
122
|
+
if (done) break;
|
|
123
|
+
res.write(value);
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
reader.releaseLock();
|
|
127
|
+
res.end();
|
|
128
|
+
}
|
|
129
|
+
}
|
package/app/index.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { AcceptsX402 } from "../accepts";
|
|
2
|
+
import type { FacilitatorClient } from "@x402/core/server";
|
|
3
|
+
import { type HttpMethod, type RouteHandler, type Middleware, type RouteEntry } from "./types";
|
|
4
|
+
import { PaymentGateway } from "./payment/payment";
|
|
5
|
+
import { Network } from "@x402/core/types";
|
|
6
|
+
import { getAixyzConfig } from "@aixyz/config";
|
|
7
|
+
import { BasePlugin } from "./plugin";
|
|
8
|
+
|
|
9
|
+
export { BasePlugin };
|
|
10
|
+
export type { HttpMethod, RouteHandler, Middleware, RouteEntry };
|
|
11
|
+
|
|
12
|
+
export interface AixyzAppOptions {
|
|
13
|
+
facilitators?: FacilitatorClient | FacilitatorClient[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Framework-agnostic route and middleware registry with optional x402 payment gating.
|
|
18
|
+
* Call `fetch()` to dispatch a web-standard Request through payment verification, middleware, and route handler.
|
|
19
|
+
*/
|
|
20
|
+
export class AixyzApp {
|
|
21
|
+
readonly routes = new Map<string, RouteEntry>();
|
|
22
|
+
readonly payment?: PaymentGateway;
|
|
23
|
+
private middlewares: Middleware[] = [];
|
|
24
|
+
private plugins: BasePlugin[] = [];
|
|
25
|
+
private readonly poweredByHeader: boolean;
|
|
26
|
+
|
|
27
|
+
constructor(options?: AixyzAppOptions) {
|
|
28
|
+
// TODO(future): getAiXyzConfig will be materialized.
|
|
29
|
+
// this is internal, we control it so it's fine for us to use—but we changing it in the future.
|
|
30
|
+
const config = getAixyzConfig();
|
|
31
|
+
this.poweredByHeader = config.build.poweredByHeader;
|
|
32
|
+
|
|
33
|
+
if (options?.facilitators) {
|
|
34
|
+
this.payment = new PaymentGateway(options.facilitators, config);
|
|
35
|
+
this.payment.register((config.x402.network as Network) ?? "eip155:8453");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Initialize payment gateway and plugins. Must be called after all routes are registered. */
|
|
40
|
+
async initialize(): Promise<void> {
|
|
41
|
+
if (this.payment) {
|
|
42
|
+
// Register payment routes with the gateway before initializing
|
|
43
|
+
for (const [, entry] of this.routes) {
|
|
44
|
+
if (entry.payment) {
|
|
45
|
+
this.payment.addRoute(entry.method, entry.path, entry.payment);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await this.payment.initialize();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const plugin of this.plugins) {
|
|
52
|
+
await plugin.initialize?.(this);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Register a plugin. Calls plugin.register(this) and returns this for chaining. */
|
|
57
|
+
async withPlugin<B extends BasePlugin>(plugin: B): Promise<this> {
|
|
58
|
+
this.plugins.push(plugin);
|
|
59
|
+
await plugin.register(this);
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns the canonical lookup key for a route (e.g. "POST /agent"). */
|
|
64
|
+
getRouteKey(method: HttpMethod, path: string) {
|
|
65
|
+
return `${method} ${path}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Register a route with an optional x402 payment requirement. */
|
|
69
|
+
route(method: HttpMethod, path: string, handler: RouteHandler, options?: { payment?: AcceptsX402 }): void {
|
|
70
|
+
const key = this.getRouteKey(method, path);
|
|
71
|
+
this.routes.set(key, {
|
|
72
|
+
method,
|
|
73
|
+
path,
|
|
74
|
+
handler,
|
|
75
|
+
payment: options?.payment,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Append a middleware to the chain. Middlewares run in registration order before the route handler. */
|
|
80
|
+
use(middleware: Middleware): void {
|
|
81
|
+
this.middlewares.push(middleware);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Returns the registered middlewares (read-only access for adapters). */
|
|
85
|
+
getMiddlewares(): Middleware[] {
|
|
86
|
+
return this.middlewares;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Dispatch a web-standard Request through payment verification, middleware, and route handler. */
|
|
90
|
+
fetch = async (request: Request): Promise<Response> => {
|
|
91
|
+
const response = await this.dispatch(request);
|
|
92
|
+
if (this.poweredByHeader) {
|
|
93
|
+
response.headers.set("X-Powered-By", "aixyz");
|
|
94
|
+
}
|
|
95
|
+
return response;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
private dispatch = async (request: Request): Promise<Response> => {
|
|
99
|
+
const url = new URL(request.url);
|
|
100
|
+
const key = this.getRouteKey(request.method as HttpMethod, url.pathname);
|
|
101
|
+
const entry = this.routes.get(key);
|
|
102
|
+
|
|
103
|
+
if (!entry) {
|
|
104
|
+
return new Response("Not Found", { status: 404 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.payment && this.payment) {
|
|
108
|
+
const rejection = await this.payment.verify(request);
|
|
109
|
+
if (rejection) return rejection;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let index = 0;
|
|
113
|
+
const middlewares = this.middlewares;
|
|
114
|
+
const handler = entry.handler;
|
|
115
|
+
|
|
116
|
+
const next = async (): Promise<Response> => {
|
|
117
|
+
if (index < middlewares.length) {
|
|
118
|
+
const mw = middlewares[index++];
|
|
119
|
+
return mw(request, next);
|
|
120
|
+
}
|
|
121
|
+
return handler(request);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const response = await next();
|
|
125
|
+
|
|
126
|
+
if (entry.payment && this.payment) {
|
|
127
|
+
const settlementResult = await this.payment.settle(request);
|
|
128
|
+
if (settlementResult?.success) {
|
|
129
|
+
const paymentResultHeader = settlementResult.headers["PAYMENT-RESPONSE"];
|
|
130
|
+
const cloned = new Response(response.body, response);
|
|
131
|
+
cloned.headers.set("PAYMENT-RESPONSE", paymentResultHeader);
|
|
132
|
+
return cloned;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return response;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { FacilitatorClient, ProcessSettleResultResponse, x402ResourceServer } from "@x402/core/server";
|
|
2
|
+
import {
|
|
3
|
+
x402HTTPResourceServer,
|
|
4
|
+
type HTTPAdapter,
|
|
5
|
+
type HTTPRequestContext,
|
|
6
|
+
type RoutesConfig,
|
|
7
|
+
type RouteConfig,
|
|
8
|
+
type HTTPResponseInstructions,
|
|
9
|
+
} from "@x402/core/http";
|
|
10
|
+
import { ExactEvmScheme } from "@x402/evm/exact/server";
|
|
11
|
+
import type { AcceptsX402 } from "../../accepts";
|
|
12
|
+
import { Network, PaymentPayload, PaymentRequirements } from "@x402/core/types";
|
|
13
|
+
import { AixyzConfig } from "@aixyz/config";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Converts a web-standard Request into the x402 HTTPAdapter interface.
|
|
17
|
+
*/
|
|
18
|
+
function toHTTPAdapter(request: Request): HTTPAdapter {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
return {
|
|
21
|
+
getHeader: (name) => request.headers.get(name) ?? undefined,
|
|
22
|
+
getMethod: () => request.method,
|
|
23
|
+
getPath: () => url.pathname,
|
|
24
|
+
getUrl: () => request.url,
|
|
25
|
+
getAcceptHeader: () => request.headers.get("accept") ?? "",
|
|
26
|
+
getUserAgent: () => request.headers.get("user-agent") ?? "",
|
|
27
|
+
getQueryParams: () => Object.fromEntries(url.searchParams),
|
|
28
|
+
getQueryParam: (name) => url.searchParams.get(name) ?? undefined,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Converts x402 HTTPResponseInstructions into a web-standard Response.
|
|
34
|
+
*/
|
|
35
|
+
function toResponse(instructions: HTTPResponseInstructions): Response {
|
|
36
|
+
const headers = new Headers(instructions.headers);
|
|
37
|
+
let body: string | undefined;
|
|
38
|
+
if (instructions.body != null) {
|
|
39
|
+
body = instructions.isHtml ? String(instructions.body) : JSON.stringify(instructions.body);
|
|
40
|
+
}
|
|
41
|
+
if (body && !headers.has("content-type")) {
|
|
42
|
+
headers.set("content-type", instructions.isHtml ? "text/html" : "application/json");
|
|
43
|
+
}
|
|
44
|
+
return new Response(body, { status: instructions.status, headers });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface PaymentContext {
|
|
48
|
+
paymentPayload: PaymentPayload;
|
|
49
|
+
paymentRequirements: PaymentRequirements;
|
|
50
|
+
declaredExtensions?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Thin wrapper around x402HTTPResourceServer
|
|
55
|
+
*/
|
|
56
|
+
export class PaymentGateway {
|
|
57
|
+
readonly resourceServer: x402ResourceServer;
|
|
58
|
+
private httpServer?: x402HTTPResourceServer;
|
|
59
|
+
private readonly config: AixyzConfig;
|
|
60
|
+
private readonly pendingRoutes = new Map<string, RouteConfig>();
|
|
61
|
+
private readonly verifiedPayments = new WeakMap<Request, PaymentContext>();
|
|
62
|
+
|
|
63
|
+
constructor(facilitators: FacilitatorClient | FacilitatorClient[], config: AixyzConfig) {
|
|
64
|
+
this.resourceServer = new x402ResourceServer(facilitators);
|
|
65
|
+
this.config = config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Register an EVM payment scheme for the given network (e.g. Base mainnet). */
|
|
69
|
+
register(network: Network) {
|
|
70
|
+
this.resourceServer.register(network, new ExactEvmScheme());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Returns the canonical lookup key for a payment route (e.g. "POST /agent"). */
|
|
74
|
+
getRouteKey(method: string, path: string) {
|
|
75
|
+
return `${method.toUpperCase()} ${path}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Add a payment-gated route. Must be called before initialize().
|
|
80
|
+
*/
|
|
81
|
+
addRoute(method: string, path: string, accepts: AcceptsX402): void {
|
|
82
|
+
const pattern = this.getRouteKey(method, path);
|
|
83
|
+
this.pendingRoutes.set(pattern, {
|
|
84
|
+
accepts: {
|
|
85
|
+
scheme: accepts.scheme,
|
|
86
|
+
payTo: accepts.payTo ?? this.config.x402.payTo,
|
|
87
|
+
price: accepts.price,
|
|
88
|
+
network: (accepts.network as Network) ?? (this.config.x402.network as Network),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialize the payment gateway. Builds the x402HTTPResourceServer from registered routes.
|
|
95
|
+
* Must be called after all routes are added.
|
|
96
|
+
*/
|
|
97
|
+
async initialize(): Promise<void> {
|
|
98
|
+
const routes: RoutesConfig =
|
|
99
|
+
this.pendingRoutes.size > 0 ? Object.fromEntries(this.pendingRoutes) : { "* /*": { accepts: [] } };
|
|
100
|
+
this.httpServer = new x402HTTPResourceServer(this.resourceServer, routes);
|
|
101
|
+
await this.httpServer.initialize();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Verify payment for a request. Returns a 402 Response if payment is required/invalid,
|
|
106
|
+
* or null if the request is authorized to proceed.
|
|
107
|
+
*/
|
|
108
|
+
async verify(request: Request): Promise<Response | null> {
|
|
109
|
+
if (!this.httpServer) {
|
|
110
|
+
throw new Error("PaymentGateway not initialized. Call initialize() first.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const adapter = toHTTPAdapter(request);
|
|
114
|
+
const context: HTTPRequestContext = {
|
|
115
|
+
adapter,
|
|
116
|
+
path: adapter.getPath(),
|
|
117
|
+
method: adapter.getMethod(),
|
|
118
|
+
paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("PAYMENT-SIGNATURE"),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const result = await this.httpServer.processHTTPRequest(context);
|
|
122
|
+
|
|
123
|
+
switch (result.type) {
|
|
124
|
+
case "no-payment-required":
|
|
125
|
+
return null;
|
|
126
|
+
|
|
127
|
+
case "payment-verified":
|
|
128
|
+
this.verifiedPayments.set(request, {
|
|
129
|
+
paymentPayload: result.paymentPayload,
|
|
130
|
+
paymentRequirements: result.paymentRequirements,
|
|
131
|
+
declaredExtensions: result.declaredExtensions,
|
|
132
|
+
});
|
|
133
|
+
return null;
|
|
134
|
+
|
|
135
|
+
case "payment-error":
|
|
136
|
+
return toResponse(result.response);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Settle a previously verified payment. Call after the handler responds successfully.
|
|
142
|
+
* Returns settlement headers to merge into the response, or null if nothing to settle.
|
|
143
|
+
*/
|
|
144
|
+
async settle(request: Request): Promise<ProcessSettleResultResponse | null> {
|
|
145
|
+
const ctx = this.verifiedPayments.get(request);
|
|
146
|
+
if (!ctx || !this.httpServer) return null;
|
|
147
|
+
this.verifiedPayments.delete(request);
|
|
148
|
+
|
|
149
|
+
const result = await this.httpServer.processSettlement(
|
|
150
|
+
ctx.paymentPayload,
|
|
151
|
+
ctx.paymentRequirements,
|
|
152
|
+
ctx.declaredExtensions,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/app/plugin.ts
ADDED
|
@@ -4,28 +4,30 @@ import {
|
|
|
4
4
|
DefaultRequestHandler,
|
|
5
5
|
ExecutionEventBus,
|
|
6
6
|
InMemoryTaskStore,
|
|
7
|
+
JsonRpcTransportHandler,
|
|
7
8
|
RequestContext,
|
|
8
9
|
TaskStore,
|
|
9
10
|
} from "@a2a-js/sdk/server";
|
|
10
11
|
import { AgentCard, Message, Task, TaskArtifactUpdateEvent, TaskStatusUpdateEvent, TextPart } from "@a2a-js/sdk";
|
|
11
12
|
import type { ToolLoopAgent, ToolSet } from "ai";
|
|
12
13
|
import { getAixyzConfigRuntime } from "../../config";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { BasePlugin } from "../plugin";
|
|
15
|
+
import type { AixyzApp } from "../index";
|
|
15
16
|
import { Accepts, AcceptsScheme } from "../../accepts";
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Wraps a Vercel AI SDK ToolLoopAgent into the A2A AgentExecutor interface.
|
|
20
|
+
* Streams text chunks as artifact updates and publishes task lifecycle events.
|
|
21
|
+
*/
|
|
17
22
|
export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements AgentExecutor {
|
|
18
23
|
constructor(private agent: ToolLoopAgent<never, TOOLS>) {}
|
|
19
24
|
|
|
20
25
|
async execute(requestContext: RequestContext, eventBus: ExecutionEventBus): Promise<void> {
|
|
21
26
|
const { taskId, contextId, userMessage, task } = requestContext;
|
|
22
27
|
try {
|
|
23
|
-
// Extract the user's message text
|
|
24
28
|
const textParts = userMessage.parts.filter((part): part is TextPart => part.kind === "text");
|
|
25
29
|
const prompt = textParts.map((part) => part.text).join("\n");
|
|
26
30
|
|
|
27
|
-
// Publish the initial Task object if one does not exist yet — required by ResultManager
|
|
28
|
-
// before any TaskArtifactUpdateEvent can be processed.
|
|
29
31
|
if (!task) {
|
|
30
32
|
const initialTask: Task = {
|
|
31
33
|
kind: "task",
|
|
@@ -37,7 +39,6 @@ export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements A
|
|
|
37
39
|
eventBus.publish(initialTask);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
// Signal that the agent is working
|
|
41
42
|
const workingUpdate: TaskStatusUpdateEvent = {
|
|
42
43
|
kind: "status-update",
|
|
43
44
|
taskId,
|
|
@@ -47,7 +48,6 @@ export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements A
|
|
|
47
48
|
};
|
|
48
49
|
eventBus.publish(workingUpdate);
|
|
49
50
|
|
|
50
|
-
// Stream the response and publish artifact chunks as they arrive
|
|
51
51
|
const result = await this.agent.stream({ prompt });
|
|
52
52
|
const artifactId = randomUUID();
|
|
53
53
|
let firstChunk = true;
|
|
@@ -69,7 +69,6 @@ export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements A
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
// Publish the final completed status
|
|
73
72
|
const completedUpdate: TaskStatusUpdateEvent = {
|
|
74
73
|
kind: "status-update",
|
|
75
74
|
taskId,
|
|
@@ -80,7 +79,6 @@ export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements A
|
|
|
80
79
|
eventBus.publish(completedUpdate);
|
|
81
80
|
eventBus.finished();
|
|
82
81
|
} catch (error) {
|
|
83
|
-
// Handle errors by publishing an error message
|
|
84
82
|
const errorMessage: Message = {
|
|
85
83
|
kind: "message",
|
|
86
84
|
messageId: randomUUID(),
|
|
@@ -93,18 +91,17 @@ export class ToolLoopAgentExecutor<TOOLS extends ToolSet = ToolSet> implements A
|
|
|
93
91
|
],
|
|
94
92
|
contextId,
|
|
95
93
|
};
|
|
96
|
-
|
|
97
94
|
eventBus.publish(errorMessage);
|
|
98
95
|
eventBus.finished();
|
|
99
96
|
}
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
async cancelTask(_taskId: string, eventBus: ExecutionEventBus): Promise<void> {
|
|
103
|
-
// TODO(@fuxingloh): The ToolLoopAgent doesn't support cancellation, so we just finish
|
|
104
100
|
eventBus.finished();
|
|
105
101
|
}
|
|
106
102
|
}
|
|
107
103
|
|
|
104
|
+
/** Build an A2A AgentCard from the runtime config, pointing to the given agent endpoint path. */
|
|
108
105
|
export function getAgentCard(agentPath = "/agent"): AgentCard {
|
|
109
106
|
const config = getAixyzConfigRuntime();
|
|
110
107
|
return {
|
|
@@ -113,58 +110,73 @@ export function getAgentCard(agentPath = "/agent"): AgentCard {
|
|
|
113
110
|
protocolVersion: "0.3.0",
|
|
114
111
|
version: config.version,
|
|
115
112
|
url: new URL(agentPath, config.url).toString(),
|
|
116
|
-
capabilities: {
|
|
117
|
-
streaming: true,
|
|
118
|
-
pushNotifications: false,
|
|
119
|
-
},
|
|
113
|
+
capabilities: { streaming: true, pushNotifications: false },
|
|
120
114
|
defaultInputModes: ["text/plain"],
|
|
121
115
|
defaultOutputModes: ["text/plain"],
|
|
122
116
|
skills: config.skills,
|
|
123
117
|
};
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// but it might be a better idea to do it in aixyz-cli (aixyz-pack).
|
|
141
|
-
return;
|
|
120
|
+
/**
|
|
121
|
+
* A2A protocol plugin. Registers the well-known agent card endpoint
|
|
122
|
+
* and a JSON-RPC endpoint that delegates to the given ToolLoopAgent.
|
|
123
|
+
* Routes are only registered if the agent exports a valid `accepts` payment config.
|
|
124
|
+
*/
|
|
125
|
+
export class A2APlugin<TOOLS extends ToolSet = ToolSet> extends BasePlugin {
|
|
126
|
+
readonly name = "a2a";
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
private exports: { default: ToolLoopAgent<never, TOOLS>; accepts?: Accepts },
|
|
130
|
+
private prefix?: string,
|
|
131
|
+
private taskStore: TaskStore = new InMemoryTaskStore(),
|
|
132
|
+
) {
|
|
133
|
+
super();
|
|
142
134
|
}
|
|
143
135
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const requestHandler = new DefaultRequestHandler(getAgentCard(agentPath), taskStore, agentExecutor);
|
|
136
|
+
register(app: AixyzApp): void {
|
|
137
|
+
if (this.exports.accepts) {
|
|
138
|
+
AcceptsScheme.parse(this.exports.accepts);
|
|
139
|
+
} else {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
151
142
|
|
|
152
|
-
|
|
153
|
-
wellKnownPath
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
const agentPath: `/${string}` = this.prefix ? `/${this.prefix}/agent` : "/agent";
|
|
144
|
+
const wellKnownPath: `/${string}` = this.prefix
|
|
145
|
+
? `/${this.prefix}/.well-known/agent-card.json`
|
|
146
|
+
: "/.well-known/agent-card.json";
|
|
147
|
+
|
|
148
|
+
const agentExecutor = new ToolLoopAgentExecutor(this.exports.default);
|
|
149
|
+
const requestHandler = new DefaultRequestHandler(getAgentCard(agentPath), this.taskStore, agentExecutor);
|
|
150
|
+
const jsonRpcTransport = new JsonRpcTransportHandler(requestHandler);
|
|
151
|
+
|
|
152
|
+
// Agent card — pure web-standard handler
|
|
153
|
+
app.route("GET", wellKnownPath, async () => {
|
|
154
|
+
const card = await requestHandler.getAgentCard();
|
|
155
|
+
return Response.json(card);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// JSON-RPC endpoint — pure web-standard handler using JsonRpcTransportHandler
|
|
159
|
+
app.route(
|
|
160
|
+
"POST",
|
|
161
|
+
agentPath,
|
|
162
|
+
async (request: Request) => {
|
|
163
|
+
const body = await request.json();
|
|
164
|
+
const result = await jsonRpcTransport.handle(body);
|
|
165
|
+
|
|
166
|
+
// If result is an AsyncGenerator (streaming), collect all chunks
|
|
167
|
+
if (Symbol.asyncIterator in Object(result)) {
|
|
168
|
+
const chunks: unknown[] = [];
|
|
169
|
+
for await (const chunk of result as AsyncGenerator) {
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
}
|
|
172
|
+
return Response.json(chunks[chunks.length - 1]);
|
|
173
|
+
}
|
|
158
174
|
|
|
159
|
-
|
|
160
|
-
|
|
175
|
+
return Response.json(result);
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
payment: this.exports.accepts.scheme === "exact" ? this.exports.accepts : undefined,
|
|
179
|
+
},
|
|
180
|
+
);
|
|
161
181
|
}
|
|
162
|
-
|
|
163
|
-
app.express.use(
|
|
164
|
-
agentPath,
|
|
165
|
-
jsonRpcHandler({
|
|
166
|
-
requestHandler,
|
|
167
|
-
userBuilder: UserBuilder.noAuthentication,
|
|
168
|
-
}),
|
|
169
|
-
);
|
|
170
182
|
}
|