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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A tiny, dependency-free tool toolkit.
3
+ *
4
+ * DSH ships `defineTool`, but importing it means a bare `@deepseek-ai/...`
5
+ * specifier, and a plugin that lives outside the installation (a profile
6
+ * plugin, a workspace plugin) is loaded by plain Node resolution: its own bare
7
+ * imports must resolve from the plugin's own directory. This module keeps that
8
+ * property while still giving the registry the exact `ToolDefinition` shape it
9
+ * demands.
10
+ *
11
+ * Deliberate duplication of `dsh-session-bus/lib/toolkit.js`: depending on
12
+ * another plugin's internals would break when either is upgraded on its own, and
13
+ * this file is pure functions. Sharing the shape matters; sharing the file does
14
+ * not.
15
+ *
16
+ * The delivered surface is purposely the same:
17
+ * { name, description, parameters, output: { schema, render }, execute }
18
+ *
19
+ * @module dsh-a2a/toolkit
20
+ */
21
+
22
+ /**
23
+ * Build one registry-ready tool definition with strict argument validation.
24
+ * @param {object} options - tool definition.
25
+ * @param {string} options.name - model-facing tool name.
26
+ * @param {string} options.description - model-facing description.
27
+ * @param {object} [options.parameters] - raw JSON Schema for the arguments.
28
+ * @param {{schema: object, render: (args: object, value: object) => Array<object>}} options.output - result schema and renderer.
29
+ * @param {(args: object, exec: object) => Promise<object>} options.execute - tool body.
30
+ * @returns {object} the registry-ready definition.
31
+ */
32
+ export function defineTool(options) {
33
+ if (typeof options?.name !== 'string' || options.name === '') throw new Error('tool definition needs a non-empty name');
34
+ if (typeof options.description !== 'string' || options.description === '') throw new Error(`tool "${options.name}" needs a description`);
35
+ if (options.output === undefined || typeof options.output.render !== 'function') throw new Error(`tool "${options.name}" must declare output.render`);
36
+ if (options.output.schema === undefined) throw new Error(`tool "${options.name}" must declare output.schema`);
37
+ const parameters = options.parameters ?? emptyObjectSchema();
38
+ return {
39
+ name: options.name,
40
+ description: options.description,
41
+ parameters,
42
+ output: {
43
+ schema: options.output.schema,
44
+ render: (args, value) => options.output.render(args, value),
45
+ },
46
+ async execute(args, exec) {
47
+ const violations = validateArgs(parameters, args);
48
+ if (violations.length > 0) throw new Error(`invalid arguments: ${violations.join('; ')}`);
49
+ // Shape the result to the declared contract BEFORE it reaches the registry.
50
+ // The registry validates results with `additionalProperties: false` and
51
+ // rejects the whole call on one undeclared field — but by then the tool
52
+ // body has already run (a Session may have been created, a message
53
+ // delivered), so a schema drift would surface as "the tool failed" while
54
+ // the side effect stood. Projecting here makes that class impossible.
55
+ return projectToSchema(await options.execute(args, exec), options.output.schema);
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Keep only the fields the declared output schema describes.
62
+ *
63
+ * The schema is the contract: a field the schema does not declare is dropped
64
+ * rather than shipped to a validator that would fail the call. Recurses through
65
+ * arrays and nested objects, so item shapes are covered too. A `null` or
66
+ * `undefined` value is treated as absent, which is how optional keys are
67
+ * omitted rather than sent as `null`.
68
+ *
69
+ * @param {unknown} value - the tool body's result.
70
+ * @param {object} schema - the declared output schema.
71
+ * @returns {unknown} the projected result.
72
+ */
73
+ export function projectToSchema(value, schema) {
74
+ if (value === undefined || value === null) return value;
75
+ if (schema?.type === 'array' && Array.isArray(value)) {
76
+ if (schema.items === undefined) return value;
77
+ return value.map((item) => projectToSchema(item, schema.items));
78
+ }
79
+ if (schema?.type === 'object' && isPlainObject(value) && isPlainObject(schema.properties)) {
80
+ const projected = {};
81
+ for (const key of Object.keys(schema.properties)) {
82
+ const item = value[key];
83
+ if (item === undefined || item === null) continue;
84
+ projected[key] = projectToSchema(item, schema.properties[key]);
85
+ }
86
+ return projected;
87
+ }
88
+ return value;
89
+ }
90
+
91
+ /** A parameterless tool's argument schema. */
92
+ export function emptyObjectSchema() {
93
+ return { type: 'object', additionalProperties: false, properties: {} };
94
+ }
95
+
96
+ /**
97
+ * Validate model-supplied arguments against a raw JSON Schema subset.
98
+ * @param {object} schema - schema with `type`, `properties`, `required`, `additionalProperties`.
99
+ * @param {unknown} args - candidate arguments.
100
+ * @param {string} [root] - diagnostic path prefix for nested calls.
101
+ * @returns {string[]} path-qualified violations; empty means valid.
102
+ */
103
+ export function validateArgs(schema, args, root = '') {
104
+ if (!isPlainObject(args)) return [`${root === '' ? 'arguments' : `"${root.replace(/\.$/, '')}"`} must be a JSON object`];
105
+ const properties = isPlainObject(schema?.properties) ? schema.properties : {};
106
+ const required = Array.isArray(schema?.required) ? schema.required : [];
107
+ const violations = [];
108
+ for (const key of required) {
109
+ if (!Object.hasOwn(args, key) || args[key] === undefined) violations.push(`missing required property "${root}${key}"`);
110
+ }
111
+ if (schema?.additionalProperties === false) {
112
+ for (const key of Object.keys(args)) {
113
+ if (!Object.hasOwn(properties, key)) violations.push(`"${root}${key}" is not a declared property`);
114
+ }
115
+ }
116
+ for (const [key, value] of Object.entries(args)) {
117
+ const spec = properties[key];
118
+ if (spec === undefined || value === undefined) continue;
119
+ violations.push(...checkValue(spec, value, `${root}${key}`));
120
+ }
121
+ return violations;
122
+ }
123
+
124
+ /** Validate one value against one schema node. */
125
+ function checkValue(spec, value, path) {
126
+ const violations = [];
127
+ switch (spec.type) {
128
+ case 'string':
129
+ if (typeof value !== 'string') return [`"${path}" must be a string`];
130
+ break;
131
+ case 'integer':
132
+ if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`];
133
+ break;
134
+ case 'number':
135
+ if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`];
136
+ break;
137
+ case 'boolean':
138
+ if (typeof value !== 'boolean') return [`"${path}" must be a boolean`];
139
+ break;
140
+ case 'array':
141
+ if (!Array.isArray(value)) return [`"${path}" must be an array`];
142
+ if (spec.items !== undefined) {
143
+ for (let index = 0; index < value.length; index += 1) {
144
+ violations.push(...checkValue(spec.items, value[index], `${path}[${index}]`));
145
+ }
146
+ }
147
+ return violations;
148
+ case 'object':
149
+ // `root` ends with a dot so nested keys keep one uniform path.
150
+ return validateArgs(spec, value, `${path}.`);
151
+ default:
152
+ break;
153
+ }
154
+ if (Array.isArray(spec.enum) && !spec.enum.includes(value)) {
155
+ violations.push(`"${path}" must be one of ${JSON.stringify(spec.enum)}`);
156
+ }
157
+ if (Object.hasOwn(spec, 'const') && value !== spec.const) {
158
+ violations.push(`"${path}" must be ${JSON.stringify(spec.const)}`);
159
+ }
160
+ return violations;
161
+ }
162
+
163
+ /** Whether a value is a plain JSON object (not an array, not null). */
164
+ function isPlainObject(value) {
165
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
166
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The DSH adapter for the tool surface.
3
+ *
4
+ * Three things happen here and nothing else: read the shared specs, hand each one to the
5
+ * Host's `defineTool`, and route execution through the shared runtime.
6
+ *
7
+ * The split exists because a second host is being added. An MCP server needs the same
8
+ * nineteen tools with the same descriptions and the same results; if this file also held
9
+ * the logic, that server would either duplicate it or drift from it. Now the two hosts
10
+ * differ only in how they are called:
11
+ *
12
+ * DSH ctx.tools.register(spec + execute) → execute(args, exec) → runtime.call(name, args, { sessionId: exec.agent.id })
13
+ * MCP tools/list + tools/call → runtime.call(name, args, { sessionId })
14
+ *
15
+ * @module dsh-a2a/tools
16
+ */
17
+
18
+ import { defineTool } from './toolkit.js';
19
+ import { allSpecs } from '../shared/tool-specs.js';
20
+ import { createToolRuntime } from '../shared/tool-runtime.js';
21
+
22
+ /**
23
+ * Register every tool on the Host.
24
+ *
25
+ * @param {object} ctx - Host context; `ctx.tools.register` is the only capability used.
26
+ * @param {object} deps - the store, registry, bridge and friends the runtime needs.
27
+ * @returns {{names: string[], runtime: object}} what was registered, for tests and logs.
28
+ */
29
+ export function registerTools(ctx, deps) {
30
+ const runtime = createToolRuntime(deps);
31
+
32
+ /** The Session a tool call arrives from. DSH puts it at `exec.agent.id`. */
33
+ const callerOf = (exec) => ({
34
+ sessionId: exec?.agent?.id,
35
+ });
36
+
37
+ const registered = [];
38
+ for (const spec of allSpecs()) {
39
+ ctx.tools.register(defineTool({
40
+ name: spec.name,
41
+ description: spec.description,
42
+ parameters: spec.parameters,
43
+ output: spec.output,
44
+ // One line of glue: hand the call to the shared runtime with the Session identity.
45
+ // These tool bodies are host-agnostic, so the adapter supplies the one fact only
46
+ // this host knows.
47
+ execute: (args, exec) => runtime.call(spec.name, args, callerOf(exec)),
48
+ }));
49
+ registered.push(spec.name);
50
+ }
51
+
52
+ return { names: registered, runtime };
53
+ }
54
+
55
+ export function joinUrl(base, agent) {
56
+ return `${String(base).replace(/\/+$/, '')}/${String(agent).replace(/^\/+/, '')}`;
57
+ }
58
+
59
+ export function coded(code, message) {
60
+ const error = new Error(`[${code}] ${message}`);
61
+ error.code = code;
62
+ return error;
63
+ }
64
+
65
+ export function parseInviteLink(link) {
66
+ let url;
67
+ try {
68
+ url = new URL(link);
69
+ } catch {
70
+ throw coded('invalid_link', 'that does not look like an invite link (expected agent-a2a://group?...).');
71
+ }
72
+ if (url.protocol !== 'agent-a2a:' || url.hostname !== 'group') {
73
+ throw coded('invalid_link', 'that is not an agent-a2a group invite link.');
74
+ }
75
+ const params = url.searchParams;
76
+ const groupId = params.get('g') ?? '';
77
+ if (groupId === '') throw coded('invalid_link', 'the invite link carries no group id.');
78
+ // The gateway base and agent name are what make the link usable from another
79
+ // machine; a link without them can only address a group on this instance.
80
+ const gatewayBase = params.get('gw') ?? '';
81
+ const agent = params.get('agent') ?? '';
82
+ const direct = params.get('url') ?? '';
83
+ const token = params.get('t') ?? '';
84
+ // `agent` is the HOST's name in the gateway, not the group id. Falling back to
85
+ // the group id here would send a colleague to an agent that does not exist —
86
+ // and because a group id looks plausible, the failure reads as "the gateway is
87
+ // broken" rather than "this link is malformed".
88
+ const endpoint = direct !== ''
89
+ ? direct
90
+ : (gatewayBase === '' || agent === '' ? undefined : joinUrl(gatewayBase, agent));
91
+ return {
92
+ groupId,
93
+ token: token === '' ? undefined : token,
94
+ gatewayBase: gatewayBase === '' ? undefined : gatewayBase,
95
+ agent: agent === '' ? undefined : agent,
96
+ endpoint,
97
+ baseUrl: endpoint,
98
+ groupName: params.get('n') ?? undefined,
99
+ expiresAt: params.get('e') ?? undefined,
100
+ };
101
+ }