dsh-a2a-groups 0.0.0-stage → 0.1.2
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/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in skills: `ping` and `session/ask`.
|
|
3
|
+
*
|
|
4
|
+
* Both exist to answer one question through the whole stack — "did this message
|
|
5
|
+
* actually reach a Session, and did the Session really work on it?" A `ping`
|
|
6
|
+
* that only echoes proves the transport; `session/ask` proves the last mile,
|
|
7
|
+
* because it has to wait for a real turn to end and read what the model said.
|
|
8
|
+
*
|
|
9
|
+
* The session skill deliberately does **not** stream. A DSH turn is not a
|
|
10
|
+
* sequence of protocol-level progress events; pretending its intermediate steps
|
|
11
|
+
* were A2A `statusUpdate`s would put a made-up progress narrative on the wire.
|
|
12
|
+
* It reports `submitted` → `working` → a final artifact, which is the truth.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-a2a/skills/session
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How long one ask waits for its turn to finish, when the caller names nothing. */
|
|
18
|
+
const DEFAULT_ASK_TIMEOUT_MS = 600000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `ping` — a transport check.
|
|
22
|
+
*
|
|
23
|
+
* Returns the bound Session id and the plugin version so a caller can tell
|
|
24
|
+
* "the gateway reached a DSH instance" from "the gateway reached *the right*
|
|
25
|
+
* DSH instance", which is the failure mode that costs an hour when several
|
|
26
|
+
* machines are behind one gateway.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} deps - `{ version, sessionIdOf }`.
|
|
29
|
+
* @returns {object} the skill definition.
|
|
30
|
+
*/
|
|
31
|
+
export function createPingSkill({ version, sessionIdOf }) {
|
|
32
|
+
return {
|
|
33
|
+
id: 'ping',
|
|
34
|
+
name: 'Ping',
|
|
35
|
+
description: 'Report the DSH instance behind this agent: its bound Session id and plugin version. Use it to verify connectivity before a real call.',
|
|
36
|
+
examples: ['ping'],
|
|
37
|
+
async run() {
|
|
38
|
+
return {
|
|
39
|
+
text: JSON.stringify({ pong: true, version, sessionId: sessionIdOf() ?? null }, null, 2),
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* `session/ask` — put a task into the bound Session and wait for the answer.
|
|
47
|
+
*
|
|
48
|
+
* This is the skill that makes a DSH instance an A2A agent: the caller's
|
|
49
|
+
* message becomes a real turn (the left-hand "task message received" card, not a
|
|
50
|
+
* bubble that looks like the user typed it), and the Session's last assistant
|
|
51
|
+
* message becomes the artifact.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} deps - `{ deliverer, sessions, logger, config }`.
|
|
54
|
+
* @returns {object} the skill definition.
|
|
55
|
+
*/
|
|
56
|
+
export function createSessionAskSkill({ deliverer, sessions, logger, config }) {
|
|
57
|
+
return {
|
|
58
|
+
id: 'session/ask',
|
|
59
|
+
name: 'Ask this Session',
|
|
60
|
+
description:
|
|
61
|
+
'Deliver a message into this DSH instance\'s bound Session as a task message, wait for the turn to finish, and return what the Session answered.',
|
|
62
|
+
examples: ['Refactor the invoice module and report what changed', 'Summarise today\'s incident and list the open questions'],
|
|
63
|
+
async run(input, context) {
|
|
64
|
+
const text = typeof input.text === 'string' ? input.text.trim() : '';
|
|
65
|
+
if (text === '') {
|
|
66
|
+
// A caller that sends no words has nothing to deliver; answering with an
|
|
67
|
+
// error is better than waking a Session to ask it what it was told.
|
|
68
|
+
const error = new Error('session/ask needs a non-empty message text');
|
|
69
|
+
error.code = 'empty_message';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
const sessionId = input.sessionId ?? sessions.resolveBoundSessionId();
|
|
73
|
+
if (typeof sessionId !== 'string' || sessionId === '') {
|
|
74
|
+
const error = new Error(
|
|
75
|
+
'session/ask has no target Session: set server.bindSessionId, or call from a Session once so it can bind itself',
|
|
76
|
+
);
|
|
77
|
+
error.code = 'no_bound_session';
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
const timeoutMs = clampTimeout(input.timeoutMs, config.server.taskTimeoutMs);
|
|
81
|
+
const messageId = input.messageId ?? context.messageId;
|
|
82
|
+
|
|
83
|
+
// The turn is observed from the Session's own event stream rather than
|
|
84
|
+
// polled: an answer can arrive in 200ms or in ten minutes, and a poll
|
|
85
|
+
// interval is either wasteful or slow at exactly the wrong moment.
|
|
86
|
+
const watcher = sessions.watchTurn(sessionId, { timeoutMs, signal: context.signal });
|
|
87
|
+
const receipt = await deliverer.deliver({
|
|
88
|
+
sessionId,
|
|
89
|
+
text,
|
|
90
|
+
messageId,
|
|
91
|
+
mode: 'queue',
|
|
92
|
+
source: { kind: 'a2a', skill: 'session/ask', taskId: context.taskId, from: input.from },
|
|
93
|
+
});
|
|
94
|
+
const outcome = await watcher.done;
|
|
95
|
+
if (outcome.timedOut) {
|
|
96
|
+
const error = new Error(`session/ask timed out after ${timeoutMs}ms waiting for the Session to finish`);
|
|
97
|
+
error.code = 'turn_timeout';
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
if (receipt.injected === 'duplicate') {
|
|
101
|
+
logger?.info?.(`a2a: session/ask message ${messageId} was already delivered; answered from the Session's state`);
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
text: outcome.text === '' ? '(the Session finished its turn without a text answer)' : outcome.text,
|
|
105
|
+
meta: { sessionId, injected: receipt.injected, timedOut: false },
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Keep a caller-supplied timeout inside what this server is willing to hold. */
|
|
112
|
+
function clampTimeout(value, ceiling) {
|
|
113
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
114
|
+
if (!Number.isFinite(number) || number <= 0) return Math.min(DEFAULT_ASK_TIMEOUT_MS, ceiling);
|
|
115
|
+
return Math.max(1000, Math.min(Math.trunc(number), ceiling));
|
|
116
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The skill registry the A2A server dispatches through.
|
|
3
|
+
*
|
|
4
|
+
* A skill is the A2A protocol's unit of capability: it is what the agent card
|
|
5
|
+
* advertises, what a caller names in `message.metadata.skill`, and what the
|
|
6
|
+
* server turns into work. Keeping registration separate from both the server
|
|
7
|
+
* (which knows HTTP and JSON-RPC) and the implementations (which know groups and
|
|
8
|
+
* sessions) means the server can be tested with fake skills and a skill can be
|
|
9
|
+
* tested without a socket.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-a2a/skills
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the registry.
|
|
16
|
+
*
|
|
17
|
+
* Registration is by id and duplicate ids throw: two skills claiming one id
|
|
18
|
+
* would make a caller's `skill` field ambiguous and the card advertise a
|
|
19
|
+
* capability that dispatches somewhere unpredictable.
|
|
20
|
+
*
|
|
21
|
+
* @returns {{
|
|
22
|
+
* register: (skill: object) => (() => boolean),
|
|
23
|
+
* get: (id: string) => any,
|
|
24
|
+
* list: () => any[],
|
|
25
|
+
* ids: () => string[],
|
|
26
|
+
* }} the registry. The concrete signatures matter: a bare `Function` is not
|
|
27
|
+
* assignable to a declared call signature, so callers that pass the registry
|
|
28
|
+
* into `createServer()` would each report an error at their own call site.
|
|
29
|
+
*/
|
|
30
|
+
export function createSkillRegistry() {
|
|
31
|
+
/** @type {Map<string, object>} */
|
|
32
|
+
const skills = new Map();
|
|
33
|
+
|
|
34
|
+
const register = (skill) => {
|
|
35
|
+
if (!isPlainObject(skill)) throw new Error('a2a: a skill must be an object');
|
|
36
|
+
const id = skill.id;
|
|
37
|
+
if (typeof id !== 'string' || id === '') throw new Error('a2a: a skill needs a non-empty id');
|
|
38
|
+
if (typeof skill.run !== 'function') throw new Error(`a2a: skill "${id}" needs a run function`);
|
|
39
|
+
if (skills.has(id)) throw new Error(`a2a: skill "${id}" is registered twice`);
|
|
40
|
+
skills.set(id, {
|
|
41
|
+
id,
|
|
42
|
+
name: typeof skill.name === 'string' && skill.name !== '' ? skill.name : id,
|
|
43
|
+
description: typeof skill.description === 'string' ? skill.description : '',
|
|
44
|
+
examples: Array.isArray(skill.examples) ? skill.examples.filter((item) => typeof item === 'string') : [],
|
|
45
|
+
streaming: skill.streaming === true,
|
|
46
|
+
run: skill.run,
|
|
47
|
+
});
|
|
48
|
+
return () => skills.delete(id);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const get = (id) => skills.get(id);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The card's `skills[]`, i.e. what a caller may name.
|
|
55
|
+
*
|
|
56
|
+
* Only metadata leaves this function — never `run` — because the card is
|
|
57
|
+
* serialized into an HTTP response and a function there would either be
|
|
58
|
+
* dropped silently or, worse, stringified into the wire.
|
|
59
|
+
*/
|
|
60
|
+
const list = () => [...skills.values()].map((skill) => ({
|
|
61
|
+
id: skill.id,
|
|
62
|
+
name: skill.name,
|
|
63
|
+
description: skill.description,
|
|
64
|
+
tags: skill.streaming ? ['streaming'] : [],
|
|
65
|
+
examples: skill.examples,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
const ids = () => [...skills.keys()];
|
|
69
|
+
|
|
70
|
+
return { register, get, list, ids };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether a value is a plain JSON object (not an array, not null). */
|
|
74
|
+
function isPlainObject(value) {
|
|
75
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
76
|
+
}
|