aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
411 lines • 16.3 kB
JavaScript
// A2A client — sends messages, subscribes to task streams, manages push
// notification configs against an agentic-sandbox v2 instance.
//
// Mirrors the executor's REST surface
// (agentic-sandbox-executor/src/bindings/rest.rs):
//
// POST /agents/{id}/v1/messages:send
// GET /agents/{id}/v1/tasks/{tid}
// POST /agents/{id}/v1/tasks/{tid}/cancel
// GET /agents/{id}/v1/tasks/{tid}/subscribe (SSE)
// POST /agents/{id}/v1/tasks/{tid}/pushNotificationConfigs
// GET /agents/{id}/v1/tasks/{tid}/pushNotificationConfigs/{cid}
// DELETE /agents/{id}/v1/tasks/{tid}/pushNotificationConfigs/{cid}
// GET /agents/{id}/.well-known/agent-card.json
// GET /agents/{id}/v1/extendedAgentCard (extended card)
// GET /agents/{id}/v1/card (legacy extended card)
import { A2AError, A2AHttpClient } from './http.js';
export const A2A_RUNTIME_V1 = 'https://agentic-sandbox.aiwg.io/extensions/runtime/v1';
export const A2A_IDEMPOTENCY_V1 = 'https://agentic-sandbox.aiwg.io/extensions/idempotency/v1';
export const A2A_HITL_PROMPT_V1 = 'https://agentic-sandbox.aiwg.io/extensions/hitl-prompt/v1';
export const A2A_MULTI_TENANT_V1 = 'https://agentic-sandbox.aiwg.io/extensions/multi-tenant/v1';
export const A2A_PTY_EXTENSIONS_V1 = 'https://agentic-sandbox.aiwg.io/extensions/pty-extensions/v1';
/** Required-by-default extension set — the executor's `RequireA2AExtensions`
* middleware (sandbox#236) rejects mutating calls without these. */
export const DEFAULT_REQUIRED_EXTENSIONS = [A2A_RUNTIME_V1, A2A_IDEMPOTENCY_V1];
export class A2AClient {
instanceId;
http;
extensionSet;
constructor(opts) {
this.instanceId = opts.instanceId;
const required = opts.requiredExtensions ?? DEFAULT_REQUIRED_EXTENSIONS;
const optional = opts.optionalExtensions ?? [];
this.extensionSet = [...required, ...optional];
this.http = new A2AHttpClient({
...opts,
defaultExtensions: this.extensionSet,
});
}
agentPath() {
return `/agents/${encodeURIComponent(this.instanceId)}/v1`;
}
// ---------- AgentCard ----------
/**
* Fetch the well-known unsigned card. Callers that need verification should
* use `src/a2a/agent-card.ts` instead, which wraps this with JWS verification.
*/
async getAgentCard() {
const paths = [
`/agents/${encodeURIComponent(this.instanceId)}/.well-known/agent-card.json`,
`${this.agentPath()}/extendedAgentCard`,
];
let last404;
for (const path of paths) {
try {
const resp = await this.http.request(path, { method: 'GET' });
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty AgentCard response',
code: 'aiwg.empty_agent_card',
});
}
return resp.body;
}
catch (err) {
if (err instanceof A2AError && err.status === 404) {
last404 = err;
continue;
}
throw err;
}
}
throw last404 ?? new A2AError(404, paths[0], {
type: 'about:blank',
title: 'AgentCard not found',
code: 'aiwg.agent_card_not_found',
});
}
/** Fetch the extended AgentCard (authenticated view). */
async getExtendedAgentCard() {
const paths = [`${this.agentPath()}/extendedAgentCard`, `${this.agentPath()}/card`];
let last404;
for (const path of paths) {
try {
const resp = await this.http.request(path, { method: 'GET' });
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty extended AgentCard response',
code: 'aiwg.empty_agent_card',
});
}
return resp.body;
}
catch (err) {
if (err instanceof A2AError && err.status === 404) {
last404 = err;
continue;
}
throw err;
}
}
throw last404 ?? new A2AError(404, paths[0], {
type: 'about:blank',
title: 'Extended AgentCard not found',
code: 'aiwg.extended_agent_card_not_found',
});
}
// ---------- Messages ----------
/**
* Send a message; the executor creates a Task in state `submitted` and
* returns 202 + Task JSON. Idempotent on `message.messageId` — repeating
* with the same id + body returns the cached Task with
* `idempotentReplayed: true`.
*/
async sendMessage(message, opts = {}) {
const path = `${this.agentPath()}/messages:send`;
const requestOptions = {
method: 'POST',
body: { message },
extensions: opts.extensions ? [...opts.extensions] : this.extensionSet,
};
if (opts.signal)
requestOptions.signal = opts.signal;
const resp = await this.http.request(path, requestOptions);
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty sendMessage response',
code: 'aiwg.empty_send_message',
});
}
return {
task: resp.body,
idempotentReplayed: resp.idempotentReplayed,
activatedExtensions: resp.activatedExtensions,
};
}
// ---------- Tasks ----------
async getTask(taskId, opts = {}) {
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}`;
const requestOptions = { method: 'GET' };
if (opts.signal)
requestOptions.signal = opts.signal;
const resp = await this.http.request(path, requestOptions);
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty getTask response',
code: 'aiwg.empty_get_task',
});
}
return resp.body;
}
/** List tasks for this instance, optionally filtered by state. */
async listTasks(filter = {}) {
const params = new URLSearchParams();
if (filter.state)
params.set('state', filter.state);
if (filter.limit !== undefined)
params.set('limit', String(filter.limit));
const query = params.toString();
const path = `${this.agentPath()}/tasks${query ? '?' + query : ''}`;
const resp = await this.http.request(path, { method: 'GET' });
if (!resp.body)
return [];
return Array.isArray(resp.body) ? resp.body : resp.body.tasks ?? [];
}
async cancelTask(taskId, opts = {}) {
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}/cancel`;
const requestOptions = {
method: 'POST',
body: {},
extensions: opts.extensions ? [...opts.extensions] : this.extensionSet,
};
if (opts.signal)
requestOptions.signal = opts.signal;
const resp = await this.http.request(path, requestOptions);
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty cancelTask response',
code: 'aiwg.empty_cancel_task',
});
}
return resp.body;
}
// ---------- Task subscription (SSE) ----------
/**
* Subscribe to a task's event stream over SSE. Returns an async iterator
* over `StreamEvent` payloads.
*
* The executor sends `event: <kind>` lines and `data: <json>` lines per
* the WHATWG event-stream format. We parse the multi-line frames and yield
* one `StreamEvent` per frame.
*
* Abort the iteration by aborting the supplied signal (or just stop
* consuming and call `return()` on the iterator — the underlying response
* body will be cancelled).
*/
subscribeToTask(taskId, opts = {}) {
const params = new URLSearchParams();
if (opts.replayFromSeq !== undefined) {
params.set('replay_from', String(opts.replayFromSeq));
}
const query = params.toString();
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}/subscribe${query ? '?' + query : ''}`;
const controller = new AbortController();
const externalSignal = opts.signal;
if (externalSignal) {
if (externalSignal.aborted)
controller.abort();
else
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
}
const http = this.http;
let cancelled = false;
async function* iterate() {
const resp = await http.request(path, {
method: 'GET',
headers: { accept: 'text/event-stream' },
signal: controller.signal,
raw: true,
});
const isStream = resp.headers.get('content-type')?.includes('text/event-stream');
if (!isStream) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Subscribe did not return an SSE stream',
code: 'aiwg.subscribe_not_sse',
});
}
if (!resp.rawBody) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'SSE response had no body',
code: 'aiwg.subscribe_empty_body',
});
}
for await (const frame of parseEventStream(resp.rawBody)) {
if (cancelled)
return;
const event = decodeStreamEvent(frame);
if (event)
yield event;
}
}
const generator = iterate();
return {
[Symbol.asyncIterator]() {
return generator;
},
close() {
cancelled = true;
controller.abort();
},
};
}
// ---------- Push notification configs ----------
async createPushNotificationConfig(taskId, config) {
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs`;
const resp = await this.http.request(path, {
method: 'POST',
body: config,
extensions: [...this.extensionSet],
});
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty createPushNotificationConfig response',
code: 'aiwg.empty_push_config_create',
});
}
return resp.body;
}
async getPushNotificationConfig(taskId, configId) {
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs/${encodeURIComponent(configId)}`;
const resp = await this.http.request(path, { method: 'GET' });
if (!resp.body) {
throw new A2AError(resp.status, path, {
type: 'about:blank',
title: 'Empty getPushNotificationConfig response',
code: 'aiwg.empty_push_config_get',
});
}
return resp.body;
}
async deletePushNotificationConfig(taskId, configId) {
const path = `${this.agentPath()}/tasks/${encodeURIComponent(taskId)}/pushNotificationConfigs/${encodeURIComponent(configId)}`;
await this.http.request(path, {
method: 'DELETE',
extensions: [...this.extensionSet],
});
}
}
/**
* Parse a WHATWG event-stream over a ReadableStream<Uint8Array>.
* Yields one SseFrame per complete `event: + data: + blank line` block.
*/
export async function* parseEventStream(stream) {
const decoder = new TextDecoder('utf-8');
const reader = stream.getReader();
let buffer = '';
let event;
let dataLines = [];
let id;
try {
while (true) {
const { value, done } = await reader.read();
if (done)
break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf('\n')) >= 0) {
let line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
// Strip optional CR.
if (line.endsWith('\r'))
line = line.slice(0, -1);
if (line.length === 0) {
// End of frame.
if (dataLines.length > 0 || event !== undefined) {
const frame = { data: dataLines.join('\n') };
if (event !== undefined)
frame.event = event;
if (id !== undefined)
frame.id = id;
yield frame;
}
event = undefined;
dataLines = [];
id = undefined;
continue;
}
if (line.startsWith(':'))
continue; // comment
const colon = line.indexOf(':');
const field = colon >= 0 ? line.slice(0, colon) : line;
const valueStr = colon >= 0 ? line.slice(colon + 1).replace(/^ /, '') : '';
switch (field) {
case 'event':
event = valueStr;
break;
case 'data':
dataLines.push(valueStr);
break;
case 'id':
id = valueStr;
break;
// retry / unknown — ignored
}
}
}
}
finally {
reader.releaseLock();
}
}
function decodeStreamEvent(frame) {
if (!frame.data)
return null;
try {
const obj = JSON.parse(frame.data);
// Prefer the `kind` field if present (matches StreamEvent discriminator).
// Otherwise fall back to `event:` header from the frame.
const kindFromBody = typeof obj['kind'] === 'string' ? obj['kind'] : undefined;
const kind = kindFromBody ?? frame.event;
if (!kind)
return null;
switch (kind) {
case 'task-state':
if (obj['task']) {
return { kind: 'task-state', task: obj['task'] };
}
return null;
case 'status-update':
if (typeof obj['taskId'] === 'string' && obj['status']) {
const out = {
kind: 'status-update',
taskId: obj['taskId'],
status: obj['status'],
};
if (typeof obj['final'] === 'boolean') {
out.final = obj['final'];
}
return out;
}
return null;
case 'artifact-update':
if (typeof obj['taskId'] === 'string' && obj['artifact']) {
const out = {
kind: 'artifact-update',
taskId: obj['taskId'],
artifact: obj['artifact'],
};
if (typeof obj['append'] === 'boolean') {
out.append = obj['append'];
}
return out;
}
return null;
default:
return null;
}
}
catch {
return null;
}
}
//# sourceMappingURL=client.js.map