@jini-ai/mcp 0.4.3 → 0.6.10
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/CHANGELOG.md +76 -0
- package/dist/agent-install/install.js +102 -194
- package/dist/bin/serve.d.ts +4 -1
- package/dist/bin/serve.js +115 -43
- package/dist/client/client.js +71 -58
- package/dist/core/config.js +113 -149
- package/dist/core/tokens.js +29 -53
- package/dist/federation/adapter.http.js +3 -2
- package/dist/federation/approvals/call-confirmation.js +5 -70
- package/dist/federation/approvals/remembered-approval.d.ts +39 -0
- package/dist/federation/approvals/remembered-approval.js +98 -0
- package/dist/federation/approvals/revocation-reason.d.ts +9 -0
- package/dist/federation/approvals/revocation-reason.js +11 -0
- package/dist/federation/approvals/revocation.js +19 -13
- package/dist/federation/approvals/scope-option.d.ts +6 -0
- package/dist/federation/approvals/scope-option.js +6 -0
- package/dist/federation/stdio/adapter.stdio.js +14 -6
- package/dist/federation/stdio/default-connect.js +24 -27
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
- package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
- package/dist/federation/tool-approvals.js +3 -1
- package/dist/federation/trust.d.ts +23 -2
- package/dist/federation/trust.js +71 -36
- package/dist/server/daemon-client.d.ts +2 -0
- package/dist/server/daemon-client.js +86 -69
- package/dist/server/tool-arguments.d.ts +46 -0
- package/dist/server/tool-arguments.js +111 -0
- package/dist/server/tool-protocol.d.ts +62 -7
- package/dist/server/tool-protocol.js +30 -40
- package/dist/server/tool-server.d.ts +19 -0
- package/dist/server/tool-server.js +91 -19
- package/dist/server/tools/delegated-tool.d.ts +14 -0
- package/dist/server/tools/delegated-tool.js +43 -4
- package/dist/server/tools/tool-catalog-tools.js +30 -8
- package/dist/tools/ask-choice/index.d.ts +213 -14
- package/dist/tools/ask-choice/index.js +90 -46
- package/dist/tools/ask-choice/question.d.ts +5 -1
- package/dist/tools/ask-choice/question.js +36 -2
- package/dist/tools/ask-choice/schema.d.ts +196 -10
- package/dist/tools/ask-choice/schema.js +33 -12
- package/dist/tools/ask-choice/types.d.ts +8 -0
- package/package.json +7 -5
|
@@ -4,24 +4,40 @@
|
|
|
4
4
|
* `search_tools` / `describe_tool` — the discovery half of `ai-control-plane.md` §29 /
|
|
5
5
|
* `PROP-tool-catalog-discovery-2026-07-26.md` §6.1, proxying `@jini-ai/http-kit`'s
|
|
6
6
|
* `GET /api/tools/search` / `GET /api/tools/:id` exactly as `run-tools.ts`'s tools proxy
|
|
7
|
-
* `runs.ts` — no separate authorization mechanism
|
|
7
|
+
* `runs.ts` — no separate authorization mechanism or catalog cache.
|
|
8
8
|
*
|
|
9
9
|
* v0 scope: no principal scoping, no versioning, no schema narrowing. Execution is unchanged —
|
|
10
|
-
* a caller
|
|
11
|
-
*
|
|
10
|
+
* a caller runs a discovered id through the delegated gateway (`../tools/delegated-tool.js`),
|
|
11
|
+
* either with its legacy wrapper or a concrete definition published after a successful describe.
|
|
12
|
+
* Both reach the same `ToolExecutor`.
|
|
12
13
|
* Finding a tool id here grants nothing (`PROP` §6.3).
|
|
13
14
|
*/
|
|
14
15
|
import { getDaemonJson } from '../daemon-client.js';
|
|
16
|
+
import { isRecord } from '../tool-arguments.js';
|
|
15
17
|
import { daemonCallOptions, requireString } from '../tool-protocol.js';
|
|
16
18
|
const READ_ANNOTATIONS = {
|
|
17
19
|
readOnlyHint: true,
|
|
18
20
|
idempotentHint: true,
|
|
19
21
|
openWorldHint: false,
|
|
20
22
|
};
|
|
23
|
+
/** Validate the daemon's identity before publishing; discovery cannot substitute another tool.
|
|
24
|
+
* Unknown risk remains unclassified and therefore cannot claim a read-only annotation.
|
|
25
|
+
* @complexity O(1); no effects. The schema is checked by the publication boundary.
|
|
26
|
+
*/
|
|
27
|
+
function describedDescriptor({ id, data }) {
|
|
28
|
+
if (!isRecord(data) || data.id !== id)
|
|
29
|
+
throw new Error('daemon returned a mismatched tool descriptor');
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
...(typeof data.description === 'string' ? { description: data.description } : {}),
|
|
33
|
+
...(typeof data.readOnly === 'boolean' ? { readOnly: data.readOnly } : {}),
|
|
34
|
+
...(data.inputSchema === undefined ? {} : { inputSchema: data.inputSchema }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
21
37
|
/** `search_tools` -> `GET /api/tools/search` (`packages/http/src/tool-catalog.ts`'s `toolCatalogSearchRoute`). */
|
|
22
38
|
export const searchToolsTool = {
|
|
23
39
|
name: 'search_tools',
|
|
24
|
-
description: 'Search the durable tool catalog. Returns ranked {id, description, source, score} candidates only — no input schemas, so this stays cheap to call broadly. Call describe_tool on the 1-3 candidates that look right before calling execute_delegated_tool. If nothing in the results fits, re-search with a higher limit or different phrasing rather than assuming the tool does not exist.',
|
|
40
|
+
description: 'Search the durable tool catalog. Returns ranked {id, description, source, score, readOnly, sideEffects} candidates only — no input schemas, so this stays cheap to call broadly. Call describe_tool on the 1-3 candidates that look right before calling execute_delegated_tool. If nothing in the results fits, re-search with a higher limit or different phrasing rather than assuming the tool does not exist.',
|
|
25
41
|
inputSchema: {
|
|
26
42
|
type: 'object',
|
|
27
43
|
properties: {
|
|
@@ -80,19 +96,25 @@ export const searchToolsTool = {
|
|
|
80
96
|
/** `describe_tool` -> `GET /api/tools/:id` (`packages/http/src/tool-catalog.ts`'s `toolCatalogDescribeRoute`). */
|
|
81
97
|
export const describeToolTool = {
|
|
82
98
|
name: 'describe_tool',
|
|
83
|
-
description: 'Get the full descriptor for one tool id found via search_tools — description
|
|
99
|
+
description: 'Get the full descriptor for one tool id found via search_tools — description, input schema, readOnly and sideEffects. When dynamic publication is enabled, this also exposes that operation as a concrete tool in tools/list; call it with its own arguments. Legacy fallback: use execute_readonly_delegated_tool only when readOnly is true. Supply id (aliases: toolId or name). Paid for only on the candidates that survive search, per the tool catalog\'s staged-discovery design.',
|
|
84
100
|
inputSchema: {
|
|
85
101
|
type: 'object',
|
|
86
102
|
properties: {
|
|
87
|
-
id: { type: 'string', description: 'Tool id returned by search_tools.
|
|
103
|
+
id: { type: 'string', description: 'Tool id returned by search_tools. Supply id, toolId, or name.' },
|
|
104
|
+
toolId: { type: 'string', description: 'Alias of id, matching the delegated-execute wrappers.' },
|
|
105
|
+
name: { type: 'string', description: 'Alias of id.' },
|
|
88
106
|
},
|
|
89
|
-
required: ['id'],
|
|
107
|
+
anyOf: [{ required: ['id'] }, { required: ['toolId'] }, { required: ['name'] }],
|
|
90
108
|
additionalProperties: false,
|
|
91
109
|
},
|
|
110
|
+
argumentNormalization: { aliases: { toolId: 'id', name: 'id' } },
|
|
92
111
|
annotations: { ...READ_ANNOTATIONS, title: 'Describe a tool' },
|
|
93
112
|
handler: async ({ args, ctx }) => {
|
|
94
113
|
const id = requireString({ value: args.id, name: 'id' });
|
|
95
|
-
|
|
114
|
+
const data = await getDaemonJson({ baseUrl: ctx.baseUrl, route: `/api/tools/${encodeURIComponent(id)}` }, daemonCallOptions({ ctx }));
|
|
115
|
+
if (ctx.publishTool)
|
|
116
|
+
await ctx.publishTool({ descriptor: describedDescriptor({ id, data }) });
|
|
117
|
+
return data;
|
|
96
118
|
},
|
|
97
119
|
};
|
|
98
120
|
/** Both tool-catalog discovery tools, ready to pass as `createMcpToolServer`'s `tools` option. */
|
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
import type { AskChoiceCall, AskChoiceExchangeStore, AskChoiceMessages, AskChoicePendingStore, AskChoicePolicy, AskChoicePresentation } from './types.js';
|
|
1
|
+
import type { AskChoiceCall, AskChoiceExchange, AskChoiceExchangeStore, AskChoiceMessages, AskChoicePendingStore, AskChoicePolicy, AskChoicePresentation, AskChoiceQuestion } from './types.js';
|
|
2
2
|
export type * from './types.js';
|
|
3
3
|
export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
4
4
|
export { createAskChoiceAnswerTicketStore } from './pending.js';
|
|
5
5
|
/** Existing callback wire keys; the tool's identity is supplied by the host. */
|
|
6
6
|
export declare const ASK_CHOICE_ANSWER_TICKET_PARAM = "__askChoiceAnswerTicket";
|
|
7
|
+
/** Run a trusted host question through the same abort, delivery, validation and cleanup lifecycle
|
|
8
|
+
* as the model-facing tool. The host owns presentation and action policy, never exchange mechanics.
|
|
9
|
+
* @returns Classified human answer, including an offered action when configured.
|
|
10
|
+
* @complexity O(n) offered options and selections.
|
|
11
|
+
*/
|
|
12
|
+
export declare function askChoice({ exchange, question, toolId, principalId, presentation, signal, messages }: {
|
|
13
|
+
exchange: AskChoiceExchange;
|
|
14
|
+
question: AskChoiceQuestion;
|
|
15
|
+
toolId: string;
|
|
16
|
+
principalId: string;
|
|
17
|
+
presentation: Pick<AskChoicePresentation, 'render'>;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
messages: AskChoiceMessages;
|
|
20
|
+
}, _options?: {}): Promise<Record<string, unknown>>;
|
|
7
21
|
/** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
|
|
8
22
|
* The host composes this descriptor/handler into its registry and mounts callbacks against the
|
|
9
23
|
* same exchange and pending stores. Policy and presentation are required, with no product defaults.
|
|
@@ -29,15 +43,50 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
|
|
|
29
43
|
inputSchema: {
|
|
30
44
|
readonly type: "object";
|
|
31
45
|
readonly additionalProperties: false;
|
|
32
|
-
readonly required: readonly ["title"];
|
|
33
46
|
readonly anyOf: readonly [{
|
|
34
|
-
readonly required: readonly ["
|
|
47
|
+
readonly required: readonly ["question", "options"];
|
|
35
48
|
}, {
|
|
36
|
-
readonly required: readonly ["
|
|
49
|
+
readonly required: readonly ["title", "singleSelect"];
|
|
50
|
+
}, {
|
|
51
|
+
readonly required: readonly ["title", "multiSelect"];
|
|
37
52
|
}];
|
|
38
53
|
readonly properties: {
|
|
54
|
+
readonly question: {
|
|
55
|
+
readonly type: "string";
|
|
56
|
+
readonly minLength: 1;
|
|
57
|
+
readonly description: "The question to ask the human.";
|
|
58
|
+
};
|
|
59
|
+
readonly options: {
|
|
60
|
+
readonly type: "array";
|
|
61
|
+
readonly minItems: 1;
|
|
62
|
+
readonly items: {
|
|
63
|
+
readonly type: "object";
|
|
64
|
+
readonly additionalProperties: false;
|
|
65
|
+
readonly required: readonly ["label"];
|
|
66
|
+
readonly properties: {
|
|
67
|
+
readonly label: {
|
|
68
|
+
readonly type: "string";
|
|
69
|
+
readonly minLength: 1;
|
|
70
|
+
};
|
|
71
|
+
readonly value: {
|
|
72
|
+
readonly type: "string";
|
|
73
|
+
readonly minLength: 1;
|
|
74
|
+
readonly description: "Defaults to label when omitted.";
|
|
75
|
+
};
|
|
76
|
+
readonly description: {
|
|
77
|
+
readonly type: "string";
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
readonly multiple: {
|
|
83
|
+
readonly type: "boolean";
|
|
84
|
+
readonly description: "Allow several selections. Defaults to false.";
|
|
85
|
+
};
|
|
39
86
|
readonly title: {
|
|
40
87
|
readonly type: "string";
|
|
88
|
+
readonly minLength: 1;
|
|
89
|
+
readonly description: "Legacy alias: title with singleSelect or multiSelect.";
|
|
41
90
|
};
|
|
42
91
|
readonly description: {
|
|
43
92
|
readonly type: "string";
|
|
@@ -46,25 +95,33 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
|
|
|
46
95
|
readonly type: "string";
|
|
47
96
|
};
|
|
48
97
|
readonly singleSelect: {
|
|
98
|
+
readonly description: "Legacy nested single-choice alias.";
|
|
49
99
|
readonly type: "object";
|
|
50
100
|
readonly additionalProperties: false;
|
|
51
101
|
readonly required: readonly ["label", "options"];
|
|
52
102
|
readonly properties: {
|
|
53
103
|
readonly label: {
|
|
54
104
|
readonly type: "string";
|
|
105
|
+
readonly minLength: 1;
|
|
55
106
|
};
|
|
56
107
|
readonly options: {
|
|
57
108
|
readonly type: "array";
|
|
58
109
|
readonly minItems: 1;
|
|
59
110
|
readonly items: {
|
|
111
|
+
readonly required: readonly ["value", "label"];
|
|
60
112
|
readonly type: "object";
|
|
61
113
|
readonly additionalProperties: false;
|
|
62
|
-
readonly required: readonly ["value", "label"];
|
|
63
114
|
readonly properties: {
|
|
115
|
+
readonly label: {
|
|
116
|
+
readonly type: "string";
|
|
117
|
+
readonly minLength: 1;
|
|
118
|
+
};
|
|
64
119
|
readonly value: {
|
|
65
120
|
readonly type: "string";
|
|
121
|
+
readonly minLength: 1;
|
|
122
|
+
readonly description: "Defaults to label when omitted.";
|
|
66
123
|
};
|
|
67
|
-
readonly
|
|
124
|
+
readonly description: {
|
|
68
125
|
readonly type: "string";
|
|
69
126
|
};
|
|
70
127
|
};
|
|
@@ -73,25 +130,33 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
|
|
|
73
130
|
};
|
|
74
131
|
};
|
|
75
132
|
readonly multiSelect: {
|
|
133
|
+
readonly description: "Legacy nested multiple-choice alias.";
|
|
76
134
|
readonly properties: {
|
|
77
135
|
readonly hint: {
|
|
78
136
|
readonly type: "string";
|
|
79
137
|
};
|
|
80
138
|
readonly label: {
|
|
81
139
|
readonly type: "string";
|
|
140
|
+
readonly minLength: 1;
|
|
82
141
|
};
|
|
83
142
|
readonly options: {
|
|
84
143
|
readonly type: "array";
|
|
85
144
|
readonly minItems: 1;
|
|
86
145
|
readonly items: {
|
|
146
|
+
readonly required: readonly ["value", "label"];
|
|
87
147
|
readonly type: "object";
|
|
88
148
|
readonly additionalProperties: false;
|
|
89
|
-
readonly required: readonly ["value", "label"];
|
|
90
149
|
readonly properties: {
|
|
150
|
+
readonly label: {
|
|
151
|
+
readonly type: "string";
|
|
152
|
+
readonly minLength: 1;
|
|
153
|
+
};
|
|
91
154
|
readonly value: {
|
|
92
155
|
readonly type: "string";
|
|
156
|
+
readonly minLength: 1;
|
|
157
|
+
readonly description: "Defaults to label when omitted.";
|
|
93
158
|
};
|
|
94
|
-
readonly
|
|
159
|
+
readonly description: {
|
|
95
160
|
readonly type: "string";
|
|
96
161
|
};
|
|
97
162
|
};
|
|
@@ -102,19 +167,153 @@ export declare function createAskChoiceTool({ toolId, description, permission, p
|
|
|
102
167
|
readonly additionalProperties: false;
|
|
103
168
|
readonly required: readonly ["label", "options"];
|
|
104
169
|
};
|
|
105
|
-
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
invocationSchema: {
|
|
174
|
+
readonly anyOf: readonly [{
|
|
175
|
+
readonly required: readonly ["question", "options"];
|
|
176
|
+
}, {
|
|
177
|
+
readonly required: readonly ["title", "singleSelect"];
|
|
178
|
+
}, {
|
|
179
|
+
readonly required: readonly ["title", "multiSelect"];
|
|
180
|
+
}, {
|
|
181
|
+
readonly required: readonly ["choice", "__askChoiceAnswerTicket"];
|
|
182
|
+
}, {
|
|
183
|
+
readonly required: readonly ["selections", "__askChoiceAnswerTicket"];
|
|
184
|
+
}];
|
|
185
|
+
readonly properties: {
|
|
186
|
+
readonly choice: {
|
|
187
|
+
readonly type: "string";
|
|
188
|
+
};
|
|
189
|
+
readonly selections: {
|
|
190
|
+
readonly type: "array";
|
|
191
|
+
readonly items: {
|
|
106
192
|
readonly type: "string";
|
|
107
|
-
readonly description: "Set by the rendered form; never by the model.";
|
|
108
193
|
};
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
194
|
+
};
|
|
195
|
+
readonly __askChoiceAnswerTicket: {
|
|
196
|
+
readonly type: "string";
|
|
197
|
+
};
|
|
198
|
+
readonly question: {
|
|
199
|
+
readonly type: "string";
|
|
200
|
+
readonly minLength: 1;
|
|
201
|
+
readonly description: "The question to ask the human.";
|
|
202
|
+
};
|
|
203
|
+
readonly options: {
|
|
204
|
+
readonly type: "array";
|
|
205
|
+
readonly minItems: 1;
|
|
206
|
+
readonly items: {
|
|
207
|
+
readonly type: "object";
|
|
208
|
+
readonly additionalProperties: false;
|
|
209
|
+
readonly required: readonly ["label"];
|
|
210
|
+
readonly properties: {
|
|
211
|
+
readonly label: {
|
|
212
|
+
readonly type: "string";
|
|
213
|
+
readonly minLength: 1;
|
|
214
|
+
};
|
|
215
|
+
readonly value: {
|
|
216
|
+
readonly type: "string";
|
|
217
|
+
readonly minLength: 1;
|
|
218
|
+
readonly description: "Defaults to label when omitted.";
|
|
219
|
+
};
|
|
220
|
+
readonly description: {
|
|
221
|
+
readonly type: "string";
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
readonly multiple: {
|
|
227
|
+
readonly type: "boolean";
|
|
228
|
+
readonly description: "Allow several selections. Defaults to false.";
|
|
229
|
+
};
|
|
230
|
+
readonly title: {
|
|
231
|
+
readonly type: "string";
|
|
232
|
+
readonly minLength: 1;
|
|
233
|
+
readonly description: "Legacy alias: title with singleSelect or multiSelect.";
|
|
234
|
+
};
|
|
235
|
+
readonly description: {
|
|
236
|
+
readonly type: "string";
|
|
237
|
+
};
|
|
238
|
+
readonly submitLabel: {
|
|
239
|
+
readonly type: "string";
|
|
240
|
+
};
|
|
241
|
+
readonly singleSelect: {
|
|
242
|
+
readonly description: "Legacy nested single-choice alias.";
|
|
243
|
+
readonly type: "object";
|
|
244
|
+
readonly additionalProperties: false;
|
|
245
|
+
readonly required: readonly ["label", "options"];
|
|
246
|
+
readonly properties: {
|
|
247
|
+
readonly label: {
|
|
112
248
|
readonly type: "string";
|
|
249
|
+
readonly minLength: 1;
|
|
250
|
+
};
|
|
251
|
+
readonly options: {
|
|
252
|
+
readonly type: "array";
|
|
253
|
+
readonly minItems: 1;
|
|
254
|
+
readonly items: {
|
|
255
|
+
readonly required: readonly ["value", "label"];
|
|
256
|
+
readonly type: "object";
|
|
257
|
+
readonly additionalProperties: false;
|
|
258
|
+
readonly properties: {
|
|
259
|
+
readonly label: {
|
|
260
|
+
readonly type: "string";
|
|
261
|
+
readonly minLength: 1;
|
|
262
|
+
};
|
|
263
|
+
readonly value: {
|
|
264
|
+
readonly type: "string";
|
|
265
|
+
readonly minLength: 1;
|
|
266
|
+
readonly description: "Defaults to label when omitted.";
|
|
267
|
+
};
|
|
268
|
+
readonly description: {
|
|
269
|
+
readonly type: "string";
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
};
|
|
276
|
+
readonly multiSelect: {
|
|
277
|
+
readonly description: "Legacy nested multiple-choice alias.";
|
|
278
|
+
readonly properties: {
|
|
279
|
+
readonly hint: {
|
|
280
|
+
readonly type: "string";
|
|
281
|
+
};
|
|
282
|
+
readonly label: {
|
|
283
|
+
readonly type: "string";
|
|
284
|
+
readonly minLength: 1;
|
|
285
|
+
};
|
|
286
|
+
readonly options: {
|
|
287
|
+
readonly type: "array";
|
|
288
|
+
readonly minItems: 1;
|
|
289
|
+
readonly items: {
|
|
290
|
+
readonly required: readonly ["value", "label"];
|
|
291
|
+
readonly type: "object";
|
|
292
|
+
readonly additionalProperties: false;
|
|
293
|
+
readonly properties: {
|
|
294
|
+
readonly label: {
|
|
295
|
+
readonly type: "string";
|
|
296
|
+
readonly minLength: 1;
|
|
297
|
+
};
|
|
298
|
+
readonly value: {
|
|
299
|
+
readonly type: "string";
|
|
300
|
+
readonly minLength: 1;
|
|
301
|
+
readonly description: "Defaults to label when omitted.";
|
|
302
|
+
};
|
|
303
|
+
readonly description: {
|
|
304
|
+
readonly type: "string";
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
};
|
|
113
308
|
};
|
|
114
|
-
readonly description: "Set by the rendered form; never by the model.";
|
|
115
309
|
};
|
|
310
|
+
readonly type: "object";
|
|
311
|
+
readonly additionalProperties: false;
|
|
312
|
+
readonly required: readonly ["label", "options"];
|
|
116
313
|
};
|
|
117
314
|
};
|
|
315
|
+
readonly type: "object";
|
|
316
|
+
readonly additionalProperties: false;
|
|
118
317
|
};
|
|
119
318
|
handler({ ctx }: {
|
|
120
319
|
ctx: AskChoiceCall;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parseQuestion, QuestionShapeError } from './question.js';
|
|
2
|
-
import { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
1
|
+
import { matchesOptions, parseQuestion, QuestionShapeError } from './question.js';
|
|
2
|
+
import { ASK_CHOICE_INPUT_SCHEMA, ASK_CHOICE_INVOCATION_SCHEMA } from './schema.js';
|
|
3
3
|
export { ASK_CHOICE_INPUT_SCHEMA } from './schema.js';
|
|
4
4
|
export { createAskChoiceAnswerTicketStore } from './pending.js';
|
|
5
5
|
/** Existing callback wire keys; the tool's identity is supplied by the host. */
|
|
@@ -14,7 +14,32 @@ function describeAnswer(params, messages) {
|
|
|
14
14
|
return { submitted: false, reason: 'no-answer', note: messages.noAnswer };
|
|
15
15
|
return { submitted: true, ...(choice === undefined ? {} : { choice }), ...(selections === undefined ? {} : { selections }), note: messages.submitted };
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
// Classifies a delivered form answer: cancel wins over typed text, typed text wins over picks,
|
|
18
|
+
// and picks must stay inside the offered options and actions.
|
|
19
|
+
function classifyReceived({ params, question, messages }) {
|
|
20
|
+
if (params[SURFACE_DISMISSED_PARAM] === true)
|
|
21
|
+
return { submitted: false, reason: 'cancelled', note: messages.cancelled };
|
|
22
|
+
const typed = params[SURFACE_TYPED_ANSWER_PARAM];
|
|
23
|
+
if (typeof typed === 'string' && typed.trim())
|
|
24
|
+
return { submitted: true, freeText: typed.trim(), note: messages.typed };
|
|
25
|
+
const described = describeAnswer(params, messages);
|
|
26
|
+
if (described.submitted !== true)
|
|
27
|
+
return described;
|
|
28
|
+
return withOfferedAction({ described, params, question, messages });
|
|
29
|
+
}
|
|
30
|
+
function withOfferedAction({ described, params, question, messages }) {
|
|
31
|
+
const invalid = { submitted: false, reason: 'invalid-answer', note: messages.noAnswer };
|
|
32
|
+
// Live exchanges need the same offered-option boundary as fallback tickets.
|
|
33
|
+
if (!matchesOptions({ question, params }))
|
|
34
|
+
return invalid;
|
|
35
|
+
if (!question.actions)
|
|
36
|
+
return described;
|
|
37
|
+
const action = params['__choiceAction'];
|
|
38
|
+
if (!question.actions.some(offered => offered.id === action))
|
|
39
|
+
return invalid;
|
|
40
|
+
return { ...described, action };
|
|
41
|
+
}
|
|
42
|
+
async function waitForAnswer({ exchange, ui, signal, messages, question }) {
|
|
18
43
|
const close = () => exchange.close({});
|
|
19
44
|
signal.addEventListener('abort', close, { once: true });
|
|
20
45
|
try {
|
|
@@ -26,18 +51,31 @@ async function waitForAnswer({ exchange, ui, signal, messages }) {
|
|
|
26
51
|
const answer = await exchange.receive({});
|
|
27
52
|
if (answer.status !== 'received')
|
|
28
53
|
return { submitted: false, reason: answer.status, note: messages[answer.status] };
|
|
29
|
-
|
|
30
|
-
return { submitted: false, reason: 'cancelled', note: messages.cancelled };
|
|
31
|
-
const typed = answer.params[SURFACE_TYPED_ANSWER_PARAM];
|
|
32
|
-
if (typeof typed === 'string' && typed.trim())
|
|
33
|
-
return { submitted: true, freeText: typed.trim(), note: messages.typed };
|
|
34
|
-
return describeAnswer(answer.params, messages);
|
|
54
|
+
return classifyReceived({ params: answer.params, question, messages });
|
|
35
55
|
}
|
|
36
56
|
finally {
|
|
37
57
|
signal.removeEventListener('abort', close);
|
|
38
58
|
close();
|
|
39
59
|
}
|
|
40
60
|
}
|
|
61
|
+
/** Run a trusted host question through the same abort, delivery, validation and cleanup lifecycle
|
|
62
|
+
* as the model-facing tool. The host owns presentation and action policy, never exchange mechanics.
|
|
63
|
+
* @returns Classified human answer, including an offered action when configured.
|
|
64
|
+
* @complexity O(n) offered options and selections.
|
|
65
|
+
*/
|
|
66
|
+
export async function askChoice({ exchange, question, toolId, principalId, presentation, signal, messages }, _options = {}) {
|
|
67
|
+
let ui;
|
|
68
|
+
try {
|
|
69
|
+
ui = presentation.render({ toolName: toolId, principalId, question,
|
|
70
|
+
baseParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id },
|
|
71
|
+
}, { cancelParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id, [SURFACE_DISMISSED_PARAM]: true } });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
exchange.close({});
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
return waitForAnswer({ exchange, ui, signal, messages, question });
|
|
78
|
+
}
|
|
41
79
|
/** Creates a choice tool without a CMS, renderer, permission system, clock, or global store.
|
|
42
80
|
* The host composes this descriptor/handler into its registry and mounts callbacks against the
|
|
43
81
|
* same exchange and pending stores. Policy and presentation are required, with no product defaults.
|
|
@@ -49,50 +87,56 @@ export function createAskChoiceTool({ toolId, description, permission, policy, p
|
|
|
49
87
|
};
|
|
50
88
|
return {
|
|
51
89
|
descriptor,
|
|
90
|
+
invocationSchema: ASK_CHOICE_INVOCATION_SCHEMA,
|
|
52
91
|
async handler({ ctx }) {
|
|
53
92
|
await policy.authorize({ ctx, toolId });
|
|
54
93
|
const input = typeof ctx.input === 'object' && ctx.input !== null ? ctx.input : {};
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const ticket = typeof rawTicket === 'string' ? rawTicket : undefined;
|
|
59
|
-
if (!pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: input })) {
|
|
60
|
-
throw policy.inputError({ message: messages.forgedAnswer });
|
|
61
|
-
}
|
|
62
|
-
return describeAnswer(input, messages);
|
|
63
|
-
}
|
|
64
|
-
let question;
|
|
65
|
-
try {
|
|
66
|
-
question = parseQuestion({ input, toolId });
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
if (!(error instanceof QuestionShapeError))
|
|
70
|
-
throw error;
|
|
71
|
-
throw policy.shapeError({ message: error.message, toolId, inputSchema: ASK_CHOICE_INPUT_SCHEMA });
|
|
72
|
-
}
|
|
94
|
+
if (isAnswerCall(input))
|
|
95
|
+
return redeemAnswer({ input, ctx, pendingQuestions, policy, messages });
|
|
96
|
+
const question = parseOrRefuse({ input, toolId, policy });
|
|
73
97
|
if (ctx.signal.aborted)
|
|
74
98
|
return { submitted: false, reason: 'abandoned', note: messages.abandoned };
|
|
75
99
|
const exchange = ctx.emitSurface ? surfaceExchanges.open({ toolId, principalId: ctx.principalId, emit: ctx.emitSurface }) : undefined;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
ui = presentation.render({
|
|
81
|
-
toolName: toolId, principalId: ctx.principalId, question, baseParams,
|
|
82
|
-
}, { ...(exchange ? { cancelParams: { [SURFACE_EXCHANGE_ID_PARAM]: exchange.id, [SURFACE_DISMISSED_PARAM]: true } } : {}),
|
|
83
|
-
});
|
|
84
|
-
if (!exchange)
|
|
85
|
-
return presentation.buildResult({ modelText: messages.pending, ui });
|
|
86
|
-
}
|
|
87
|
-
catch (error) {
|
|
88
|
-
exchange?.close({});
|
|
89
|
-
// A form that was not delivered must not leave a redeemable fallback ticket behind.
|
|
90
|
-
if (ticket !== undefined)
|
|
91
|
-
pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: {} });
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
return waitForAnswer({ exchange, ui, signal: ctx.signal, messages });
|
|
100
|
+
if (exchange)
|
|
101
|
+
return askChoice({ exchange, question, toolId, principalId: ctx.principalId, presentation, signal: ctx.signal, messages });
|
|
102
|
+
return presentFallback({ question, toolId, principalId: ctx.principalId, pendingQuestions, presentation, messages });
|
|
95
103
|
},
|
|
96
104
|
};
|
|
97
105
|
}
|
|
106
|
+
// A second call carrying a pick (and no question title) is an answer to a fallback form.
|
|
107
|
+
function isAnswerCall(input) {
|
|
108
|
+
return typeof input['title'] !== 'string' && typeof input['question'] !== 'string' && (typeof input['choice'] === 'string' || Array.isArray(input['selections']));
|
|
109
|
+
}
|
|
110
|
+
// The ticket is the only proof the answer came from a rendered form: redeem is fail-closed.
|
|
111
|
+
function redeemAnswer({ input, ctx, pendingQuestions, policy, messages }) {
|
|
112
|
+
const rawTicket = input[ASK_CHOICE_ANSWER_TICKET_PARAM];
|
|
113
|
+
const ticket = typeof rawTicket === 'string' ? rawTicket : undefined;
|
|
114
|
+
if (!pendingQuestions.redeem({ ticket, principalId: ctx.principalId, params: input })) {
|
|
115
|
+
throw policy.inputError({ message: messages.forgedAnswer });
|
|
116
|
+
}
|
|
117
|
+
return describeAnswer(input, messages);
|
|
118
|
+
}
|
|
119
|
+
function parseOrRefuse({ input, toolId, policy }) {
|
|
120
|
+
try {
|
|
121
|
+
return parseQuestion({ input, toolId });
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (!(error instanceof QuestionShapeError))
|
|
125
|
+
throw error;
|
|
126
|
+
throw policy.shapeError({ message: error.message, toolId, inputSchema: ASK_CHOICE_INPUT_SCHEMA });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// No emit seam: return the form itself, bound to a freshly minted single-use ticket.
|
|
130
|
+
function presentFallback({ question, toolId, principalId, pendingQuestions, presentation, messages }) {
|
|
131
|
+
const ticket = pendingQuestions.mint({ principalId, question });
|
|
132
|
+
try {
|
|
133
|
+
const ui = presentation.render({ toolName: toolId, principalId, question, baseParams: { [ASK_CHOICE_ANSWER_TICKET_PARAM]: ticket } });
|
|
134
|
+
return presentation.buildResult({ modelText: messages.pending, ui });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
// A form that was not delivered must not leave a redeemable fallback ticket behind.
|
|
138
|
+
pendingQuestions.redeem({ ticket, principalId, params: {} });
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
98
142
|
//# sourceMappingURL=index.js.map
|
|
@@ -5,10 +5,14 @@ export declare class QuestionShapeError extends Error {
|
|
|
5
5
|
message: string;
|
|
6
6
|
}, options?: ErrorOptions);
|
|
7
7
|
}
|
|
8
|
+
/** Normalize the flat model question into the existing UI/answer contract.
|
|
9
|
+
* @throws QuestionShapeError for malformed questions or ambiguous mixed forms.
|
|
10
|
+
* @complexity O(n) in offered options; no effects.
|
|
11
|
+
*/
|
|
8
12
|
export declare function parseQuestion({ input, toolId }: {
|
|
9
13
|
input: Record<string, unknown>;
|
|
10
14
|
toolId: string;
|
|
11
|
-
}): AskChoiceQuestion;
|
|
15
|
+
}, _options?: {}): AskChoiceQuestion;
|
|
12
16
|
export declare function matchesOptions({ question, params }: {
|
|
13
17
|
question: AskChoiceQuestion;
|
|
14
18
|
params: Record<string, unknown>;
|
|
@@ -21,11 +21,45 @@ function readSelect(raw, field, toolId) {
|
|
|
21
21
|
if (typeof value !== 'string' || !value || typeof label !== 'string' || !label) {
|
|
22
22
|
throw new QuestionShapeError({ message: `${toolId}: every '${field}' option requires a string 'value' and 'label'.` });
|
|
23
23
|
}
|
|
24
|
-
return { value, label };
|
|
24
|
+
return { value, label, ...(typeof option['description'] === 'string' ? { description: option['description'] } : {}) };
|
|
25
25
|
});
|
|
26
26
|
return { label: spec['label'], options, ...(typeof spec['hint'] === 'string' ? { hint: spec['hint'] } : {}) };
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
function normalizeFlatQuestion({ input, toolId }, _options = {}) {
|
|
29
|
+
const question = input['question'];
|
|
30
|
+
if (typeof question !== 'string' || !question.trim())
|
|
31
|
+
throw new QuestionShapeError({ message: `${toolId}: 'question' is required.` });
|
|
32
|
+
if (input['multiple'] !== undefined && typeof input['multiple'] !== 'boolean') {
|
|
33
|
+
throw new QuestionShapeError({ message: `${toolId}: 'multiple' must be a boolean.` });
|
|
34
|
+
}
|
|
35
|
+
const options = normalizeFlatOptions({ rawOptions: input['options'] });
|
|
36
|
+
const select = { label: question, options };
|
|
37
|
+
return { title: question, ...(typeof input['description'] === 'string' ? { description: input['description'] } : {}),
|
|
38
|
+
...(typeof input['submitLabel'] === 'string' ? { submitLabel: input['submitLabel'] } : {}),
|
|
39
|
+
[input['multiple'] === true ? 'multiSelect' : 'singleSelect']: select };
|
|
40
|
+
}
|
|
41
|
+
function normalizeFlatOptions({ rawOptions }, _options = {}) {
|
|
42
|
+
return Array.isArray(rawOptions) ? rawOptions.map(entry => {
|
|
43
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
|
|
44
|
+
return entry;
|
|
45
|
+
const option = entry;
|
|
46
|
+
return { ...option, value: option['value'] === undefined ? option['label'] : option['value'] };
|
|
47
|
+
}) : rawOptions;
|
|
48
|
+
}
|
|
49
|
+
function normalizeQuestion({ input, toolId }, _options = {}) {
|
|
50
|
+
if (input['question'] === undefined && input['options'] === undefined && input['multiple'] === undefined)
|
|
51
|
+
return input;
|
|
52
|
+
if (input['title'] !== undefined || input['singleSelect'] !== undefined || input['multiSelect'] !== undefined) {
|
|
53
|
+
throw new QuestionShapeError({ message: `${toolId}: use {question, options, multiple?} or the legacy nested selects; do not mix them.` });
|
|
54
|
+
}
|
|
55
|
+
return normalizeFlatQuestion({ input, toolId });
|
|
56
|
+
}
|
|
57
|
+
/** Normalize the flat model question into the existing UI/answer contract.
|
|
58
|
+
* @throws QuestionShapeError for malformed questions or ambiguous mixed forms.
|
|
59
|
+
* @complexity O(n) in offered options; no effects.
|
|
60
|
+
*/
|
|
61
|
+
export function parseQuestion({ input, toolId }, _options = {}) {
|
|
62
|
+
input = normalizeQuestion({ input, toolId });
|
|
29
63
|
const title = input['title'];
|
|
30
64
|
if (typeof title !== 'string' || !title)
|
|
31
65
|
throw new QuestionShapeError({ message: `${toolId}: 'title' is required.` });
|