@ryubyte/dsh-a2a 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.
- package/LICENSE +21 -0
- package/README.md +224 -0
- package/README.zh-CN.md +196 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +574 -0
- package/lib/index.js +1896 -0
- package/lib/types/a2a-config.d.ts +107 -0
- package/lib/types/a2a-config.d.ts.map +1 -0
- package/lib/types/card.d.ts +40 -0
- package/lib/types/card.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +22 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client.d.ts +52 -0
- package/lib/types/client.d.ts.map +1 -0
- package/lib/types/dashboard.d.ts +144 -0
- package/lib/types/dashboard.d.ts.map +1 -0
- package/lib/types/errors.d.ts +24 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/index.d.ts +89 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/jsonrpc.d.ts +40 -0
- package/lib/types/jsonrpc.d.ts.map +1 -0
- package/lib/types/outbound.d.ts +45 -0
- package/lib/types/outbound.d.ts.map +1 -0
- package/lib/types/protocol.d.ts +330 -0
- package/lib/types/protocol.d.ts.map +1 -0
- package/lib/types/server.d.ts +142 -0
- package/lib/types/server.d.ts.map +1 -0
- package/package.json +89 -0
- package/src/a2a-config.ts +201 -0
- package/src/card.ts +143 -0
- package/src/client/index.ts +613 -0
- package/src/client.ts +141 -0
- package/src/dashboard.ts +350 -0
- package/src/errors.ts +39 -0
- package/src/index.ts +659 -0
- package/src/jsonrpc.ts +185 -0
- package/src/outbound.ts +186 -0
- package/src/protocol.ts +308 -0
- package/src/server.ts +467 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-a2a — persisted configuration (`a2a.json`).
|
|
3
|
+
*
|
|
4
|
+
* Runtime-editable configuration lives in a per-profile `a2a.json`, so the
|
|
5
|
+
* plugin's UI changes survive restarts without touching the composition
|
|
6
|
+
* (`cordis.patch.yml` / `package.json` stay pure entry points).
|
|
7
|
+
*
|
|
8
|
+
* File resolution order:
|
|
9
|
+
* 1. `process.cwd()/a2a.json` — profile dir when launched via `dsh --profile <x>`
|
|
10
|
+
* 2. `~/.dsh/profiles/<cwd-basename>/a2a.json` — fallback when cwd is not the profile dir
|
|
11
|
+
* 3. `$DSH_HOME/a2a.json` — last-resort global location
|
|
12
|
+
*
|
|
13
|
+
* Shape (all fields optional):
|
|
14
|
+
* {
|
|
15
|
+
* "mode": "both", // client | server | both (default both)
|
|
16
|
+
* "timeoutMs": 8000, // client global default (ms)
|
|
17
|
+
* "mapSkills": true, // per-skill tools vs one agent-wide tool
|
|
18
|
+
* "agents": [{ "name", "agentCardUrl", "bearerToken?", "timeoutMs?", "mapSkills?", "enabled?" }],
|
|
19
|
+
* "server": { "baseUrl"?, "agentName"?, "agentDescription"?, "agentVersion"?,
|
|
20
|
+
* "endpointPath"?, "skills"?: [{ "id","name","description"?, "tags"? }] },
|
|
21
|
+
* "dashboard": true
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
26
|
+
import { dirname, join } from 'node:path';
|
|
27
|
+
import { homedir } from 'node:os';
|
|
28
|
+
import type { A2APluginConfig } from './index.js';
|
|
29
|
+
|
|
30
|
+
export const A2A_CONFIG_FILENAME = 'a2a.json';
|
|
31
|
+
|
|
32
|
+
export interface PersistedAgentConfig {
|
|
33
|
+
name: string;
|
|
34
|
+
agentCardUrl: string;
|
|
35
|
+
bearerToken?: string;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
mapSkills?: boolean;
|
|
38
|
+
/** False disables the connection without removing the entry (default true). */
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PersistedServerConfig {
|
|
43
|
+
/** Explicitly enable the inbound server (default off). */
|
|
44
|
+
enabled?: boolean;
|
|
45
|
+
baseUrl?: string;
|
|
46
|
+
agentName?: string;
|
|
47
|
+
agentDescription?: string;
|
|
48
|
+
agentVersion?: string;
|
|
49
|
+
endpointPath?: string;
|
|
50
|
+
skills?: Array<{ id: string; name: string; description: string; tags?: string[]; examples?: string[] }>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface PersistedA2AConfig {
|
|
54
|
+
mode?: 'client' | 'server' | 'both';
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
mapSkills?: boolean;
|
|
57
|
+
agents?: PersistedAgentConfig[];
|
|
58
|
+
server?: PersistedServerConfig;
|
|
59
|
+
dashboard?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse `--profile <name>` (or `--profile=<name>`) from the process argv.
|
|
64
|
+
*
|
|
65
|
+
* dsh keeps its launcher flags in the process command line (`node …/dsh
|
|
66
|
+
* --profile a2a-test --port 3083`), so the active profile name is directly
|
|
67
|
+
* observable no matter which directory the process was started from. This is
|
|
68
|
+
* the most reliable way to locate the per-profile config — far better than
|
|
69
|
+
* guessing from cwd's basename.
|
|
70
|
+
*/
|
|
71
|
+
export function profileNameFromArgv(): string | undefined {
|
|
72
|
+
const argv = process.argv ?? [];
|
|
73
|
+
for (let i = 0; i < argv.length; i++) {
|
|
74
|
+
const a = argv[i];
|
|
75
|
+
if (a === '--profile' && i + 1 < argv.length) {
|
|
76
|
+
const v = argv[i + 1];
|
|
77
|
+
if (v && !v.startsWith('-')) return v;
|
|
78
|
+
} else if (a.startsWith('--profile=')) {
|
|
79
|
+
const v = a.slice('--profile='.length);
|
|
80
|
+
if (v) return v;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the a2a.json path for the current process, without depending on
|
|
88
|
+
* the launch directory. The plugin must work no matter where `dsh` was
|
|
89
|
+
* started from (dsh does NOT chdir into the profile directory), so this
|
|
90
|
+
* prefers the profile named in argv (`--profile <name>`), then walks
|
|
91
|
+
* outward from cwd, then falls back to the profile dir that matches cwd's
|
|
92
|
+
* basename, then the harness home.
|
|
93
|
+
*
|
|
94
|
+
* Order:
|
|
95
|
+
* 0. `$DSH_HOME/profiles/<argv --profile>/a2a.json` — the active profile
|
|
96
|
+
* as named on the command line (most authoritative; works from ANY cwd)
|
|
97
|
+
* 1. `cwd/a2a.json` — explicit when launched inside a profile dir
|
|
98
|
+
* 2. outward from cwd: the nearest ancestor holding `a2a.json` — covers
|
|
99
|
+
* launching from e.g. a repo checkout whose parent chain includes the
|
|
100
|
+
* profile, or any subdir of the profile
|
|
101
|
+
* 3. `$DSH_HOME/profiles/<cwd-basename>/a2a.json` — started from the
|
|
102
|
+
* profile's *parent* (e.g. `~/.dsh/profiles`) or when the profile dir
|
|
103
|
+
* is cwd but the file hasn't been created yet
|
|
104
|
+
* 4. `$DSH_HOME/a2a.json` — last-resort global location
|
|
105
|
+
*
|
|
106
|
+
* Writes always go to the *resolved* path (the one we read), so a config
|
|
107
|
+
* loaded from any step is saved back to the same place, never re-resolved
|
|
108
|
+
* to cwd.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveConfigPath(): string {
|
|
111
|
+
const cwd = process.cwd();
|
|
112
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
113
|
+
const base = cwd.split(/[\\/]/).pop() ?? '';
|
|
114
|
+
|
|
115
|
+
// 0. Active profile from argv — authoritative regardless of cwd.
|
|
116
|
+
const profileName = profileNameFromArgv();
|
|
117
|
+
if (profileName && profileName !== '.' && profileName !== '..' && !profileName.includes('/') && !profileName.includes('\\')) {
|
|
118
|
+
const argvFile = join(home, 'profiles', profileName, A2A_CONFIG_FILENAME);
|
|
119
|
+
if (existsSync(argvFile)) return argvFile;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 1. cwd (most explicit).
|
|
123
|
+
const cwdFile = join(cwd, A2A_CONFIG_FILENAME);
|
|
124
|
+
if (existsSync(cwdFile)) return cwdFile;
|
|
125
|
+
|
|
126
|
+
// 2. Nearest ancestor of cwd holding an a2a.json (walk upward, max 12 levels).
|
|
127
|
+
let dir = cwd;
|
|
128
|
+
for (let i = 0; i < 12; i++) {
|
|
129
|
+
const up = join(dir, A2A_CONFIG_FILENAME);
|
|
130
|
+
if (existsSync(up)) return up;
|
|
131
|
+
const parent = dirname(dir);
|
|
132
|
+
if (parent === dir) break;
|
|
133
|
+
dir = parent;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3. Profile dir matching cwd's basename (started from profiles/ or home).
|
|
137
|
+
const profileFile = join(home, 'profiles', base, A2A_CONFIG_FILENAME);
|
|
138
|
+
if (base && existsSync(profileFile)) return profileFile;
|
|
139
|
+
|
|
140
|
+
// 4. Global fallback.
|
|
141
|
+
const globalFile = join(home, A2A_CONFIG_FILENAME);
|
|
142
|
+
if (existsSync(globalFile)) return globalFile;
|
|
143
|
+
|
|
144
|
+
// Nothing exists yet: prefer writing to cwd (an explicitly cd'd profile
|
|
145
|
+
// dir or a repo that wants its config local); the profile fallback is next.
|
|
146
|
+
return cwdFile;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Read the persisted config (empty object when absent or unreadable). */
|
|
150
|
+
export function loadConfig(): PersistedA2AConfig {
|
|
151
|
+
const path = resolveConfigPath();
|
|
152
|
+
if (!existsSync(path)) return {};
|
|
153
|
+
try {
|
|
154
|
+
const raw = readFileSync(path, 'utf8');
|
|
155
|
+
const parsed = JSON.parse(raw) as PersistedA2AConfig;
|
|
156
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`[a2a] failed to read ${path}: ${(err as Error).message}`);
|
|
159
|
+
return {};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Atomically write the persisted config (creates the directory when needed). */
|
|
164
|
+
export function saveConfig(config: PersistedA2AConfig): { ok: boolean; path: string; message?: string } {
|
|
165
|
+
const path = resolveConfigPath();
|
|
166
|
+
try {
|
|
167
|
+
const dir = dirname(path);
|
|
168
|
+
mkdirSync(dir, { recursive: true });
|
|
169
|
+
const tmp = `${path}.tmp`;
|
|
170
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
171
|
+
// rename is atomic on POSIX; write then move avoids torn reads.
|
|
172
|
+
renameSync(tmp, path);
|
|
173
|
+
return { ok: true, path };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { ok: false, path, message: (err as Error).message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Merge persisted config on top of the in-composition config, so the file
|
|
181
|
+
* (UI-editable, survives restart) wins over static defaults.
|
|
182
|
+
*/
|
|
183
|
+
export function mergePersisted(base: A2APluginConfig, persisted: PersistedA2AConfig): A2APluginConfig {
|
|
184
|
+
const merged: A2APluginConfig = { ...base };
|
|
185
|
+
if (persisted.mode) merged.mode = persisted.mode;
|
|
186
|
+
if (persisted.timeoutMs !== undefined) merged.timeoutMs = persisted.timeoutMs;
|
|
187
|
+
if (persisted.mapSkills !== undefined) merged.mapSkills = persisted.mapSkills;
|
|
188
|
+
if (persisted.dashboard !== undefined) merged.dashboard = persisted.dashboard;
|
|
189
|
+
|
|
190
|
+
const pServer = persisted.server;
|
|
191
|
+
if (pServer) {
|
|
192
|
+
if (pServer.enabled !== undefined) merged.serverEnabled = pServer.enabled;
|
|
193
|
+
if (pServer.baseUrl) merged.baseUrl = pServer.baseUrl;
|
|
194
|
+
if (pServer.agentName) merged.agentName = pServer.agentName;
|
|
195
|
+
if (pServer.agentDescription) merged.agentDescription = pServer.agentDescription;
|
|
196
|
+
if (pServer.agentVersion) merged.agentVersion = pServer.agentVersion;
|
|
197
|
+
if (pServer.endpointPath) merged.endpointPath = pServer.endpointPath;
|
|
198
|
+
if (pServer.skills) merged.skills = pServer.skills;
|
|
199
|
+
}
|
|
200
|
+
return merged;
|
|
201
|
+
}
|
package/src/card.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A2A (Agent2Agent) Protocol v1.0 — AgentCard discovery and validation.
|
|
3
|
+
*
|
|
4
|
+
* Reproduces the protocol's discovery contract: fetch the manifest at
|
|
5
|
+
* `/.well-known/agent-card.json` (or a configured URL). Per the spec, when a
|
|
6
|
+
* path is configured without a well-known segment the client SHOULD probe the
|
|
7
|
+
* well-known location first, then fall back to the configured path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AgentCard, AgentInterface } from './protocol.js';
|
|
11
|
+
import { A2AError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
export interface AgentCardOptions {
|
|
14
|
+
/** How long a single fetch may take (ms). Default 15000. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Optional bearer token sent as `Authorization: Bearer <token>`. */
|
|
17
|
+
bearerToken?: string;
|
|
18
|
+
/** Optional custom fetch implementation (for tests). */
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isAgentCard(value: unknown): value is AgentCard {
|
|
23
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
24
|
+
const v = value as Record<string, unknown>;
|
|
25
|
+
return (
|
|
26
|
+
typeof v.name === 'string' &&
|
|
27
|
+
typeof v.description === 'string' &&
|
|
28
|
+
typeof v.version === 'string' &&
|
|
29
|
+
Array.isArray(v.supportedInterfaces) &&
|
|
30
|
+
typeof v.capabilities === 'object' &&
|
|
31
|
+
v.capabilities !== null &&
|
|
32
|
+
Array.isArray(v.defaultInputModes) &&
|
|
33
|
+
Array.isArray(v.defaultOutputModes) &&
|
|
34
|
+
Array.isArray(v.skills)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeUrl(base: string, path: string): string {
|
|
39
|
+
const b = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
40
|
+
const p = path.startsWith('/') ? path : `/${path}`;
|
|
41
|
+
return `${b}${p}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fetch and validate an agent's AgentCard.
|
|
46
|
+
*
|
|
47
|
+
* Discovery order (per spec §AgentCard discovery):
|
|
48
|
+
* 1. Probe `GET {origin}/.well-known/agent-card.json` first.
|
|
49
|
+
* 2. Fall back to the exact configured URL.
|
|
50
|
+
* A configured URL that already ends in `agent-card.json` is used directly.
|
|
51
|
+
*/
|
|
52
|
+
export async function fetchAgentCard(
|
|
53
|
+
baseUrl: string,
|
|
54
|
+
options: AgentCardOptions = {},
|
|
55
|
+
): Promise<AgentCard> {
|
|
56
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
57
|
+
const timeoutMs = options.timeoutMs ?? 15000;
|
|
58
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
59
|
+
if (options.bearerToken) headers.Authorization = `Bearer ${options.bearerToken}`;
|
|
60
|
+
|
|
61
|
+
const wellKnown = normalizeUrl(baseUrl, '/.well-known/agent-card.json');
|
|
62
|
+
const isExactCard =
|
|
63
|
+
baseUrl.endsWith('agent-card.json') || baseUrl.endsWith('agent-card.json/');
|
|
64
|
+
|
|
65
|
+
const candidates = isExactCard ? [baseUrl] : [wellKnown, baseUrl];
|
|
66
|
+
|
|
67
|
+
let lastError: Error | undefined;
|
|
68
|
+
for (const url of candidates) {
|
|
69
|
+
const ctrl = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetchImpl(url, { headers, signal: ctrl.signal });
|
|
73
|
+
if (res.status === 404) {
|
|
74
|
+
lastError = new A2AError(
|
|
75
|
+
404,
|
|
76
|
+
`AgentCard not found at ${url}`,
|
|
77
|
+
undefined,
|
|
78
|
+
true,
|
|
79
|
+
);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!res.ok) {
|
|
83
|
+
throw new A2AError(
|
|
84
|
+
res.status,
|
|
85
|
+
`AgentCard request failed (${res.status}) at ${url}`,
|
|
86
|
+
undefined,
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const json: unknown = await res.json();
|
|
91
|
+
if (!isAgentCard(json)) {
|
|
92
|
+
throw new A2AError(
|
|
93
|
+
400,
|
|
94
|
+
`Invalid AgentCard at ${url}: missing required fields`,
|
|
95
|
+
undefined,
|
|
96
|
+
true,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return json;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
102
|
+
throw new A2AError(408, `AgentCard fetch timed out after ${timeoutMs}ms at ${url}`, undefined, true);
|
|
103
|
+
}
|
|
104
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
105
|
+
} finally {
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw (
|
|
110
|
+
lastError ??
|
|
111
|
+
new A2AError(404, `AgentCard could not be fetched from ${baseUrl}`, undefined, true)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pick the best interface to talk to an agent from its AgentCard.
|
|
117
|
+
*
|
|
118
|
+
* Preference order: a JSONRPC interface first, then gRPC, then any other
|
|
119
|
+
* binding; the card's own ordering ("first entry is preferred") is respected
|
|
120
|
+
* within each binding class. Returns the interface and a normalized base URL
|
|
121
|
+
* to post JSON-RPC requests to.
|
|
122
|
+
*/
|
|
123
|
+
export function pickInterface(
|
|
124
|
+
card: AgentCard,
|
|
125
|
+
preferred?: string,
|
|
126
|
+
): { iface: AgentInterface; url: string } {
|
|
127
|
+
const ifaces = card.supportedInterfaces ?? [];
|
|
128
|
+
if (ifaces.length === 0) {
|
|
129
|
+
throw new A2AError(
|
|
130
|
+
-32000,
|
|
131
|
+
`AgentCard for "${card.name}" declares no supportedInterfaces`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const want = preferred ?? 'JSONRPC';
|
|
135
|
+
const ordered = [...ifaces].sort((a, b) => {
|
|
136
|
+
const ab = a.protocolBinding === want ? 0 : a.protocolBinding === 'GRPC' ? 1 : 2;
|
|
137
|
+
const bb = b.protocolBinding === want ? 0 : b.protocolBinding === 'GRPC' ? 1 : 2;
|
|
138
|
+
return ab - bb;
|
|
139
|
+
});
|
|
140
|
+
const iface = ordered[0];
|
|
141
|
+
const url = iface.url.endsWith('/') ? iface.url.slice(0, -1) : iface.url;
|
|
142
|
+
return { iface, url };
|
|
143
|
+
}
|