@arnilo/prism-coding-agent 0.0.8 → 0.0.11

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,471 @@
1
+ import { suspend, } from "@arnilo/prism-workflows";
2
+ import { enforceExecutionPolicy } from "./execution-policy.js";
3
+ import { validateCodingLimit } from "./limits.js";
4
+ export const ASK_USER_DECISION_TOOL_NAME = "ask_user_decision";
5
+ export const ASK_USER_DECISION_SUSPEND_REASON = "ask_user_decision";
6
+ /** Exactly three rationale bullets per side. */
7
+ export const ASK_USER_DECISION_RATIONALE_COUNT = 3;
8
+ export const DEFAULT_MAX_ASK_USER_DECISION_OPTIONS = 6;
9
+ export const HARD_MAX_ASK_USER_DECISION_OPTIONS = 16;
10
+ export const DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES = 2_048;
11
+ export const HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES = 8_192;
12
+ export const DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES = 512;
13
+ export const HARD_MAX_ASK_USER_DECISION_LABEL_BYTES = 2_048;
14
+ export const DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES = 512;
15
+ export const HARD_MAX_ASK_USER_DECISION_BULLET_BYTES = 2_048;
16
+ /** Same ceiling as question text — free-text answers stay short. */
17
+ export const DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES = DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES;
18
+ export const HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES = HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES;
19
+ export function resolveAskUserDecisionLimits(options) {
20
+ return {
21
+ maxOptions: validateCodingLimit("maxOptions", options?.maxOptions ?? DEFAULT_MAX_ASK_USER_DECISION_OPTIONS, HARD_MAX_ASK_USER_DECISION_OPTIONS),
22
+ maxQuestionBytes: validateCodingLimit("maxQuestionBytes", options?.maxQuestionBytes ?? DEFAULT_MAX_ASK_USER_DECISION_QUESTION_BYTES, HARD_MAX_ASK_USER_DECISION_QUESTION_BYTES),
23
+ maxLabelBytes: validateCodingLimit("maxLabelBytes", options?.maxLabelBytes ?? DEFAULT_MAX_ASK_USER_DECISION_LABEL_BYTES, HARD_MAX_ASK_USER_DECISION_LABEL_BYTES),
24
+ maxBulletBytes: validateCodingLimit("maxBulletBytes", options?.maxBulletBytes ?? DEFAULT_MAX_ASK_USER_DECISION_BULLET_BYTES, HARD_MAX_ASK_USER_DECISION_BULLET_BYTES),
25
+ maxCustomTextBytes: validateCodingLimit("maxCustomTextBytes", options?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES, HARD_MAX_ASK_USER_DECISION_CUSTOM_BYTES),
26
+ };
27
+ }
28
+ function errorResult(toolCallId, message) {
29
+ return {
30
+ toolCallId,
31
+ name: ASK_USER_DECISION_TOOL_NAME,
32
+ content: [{ type: "text", text: message }],
33
+ error: { message },
34
+ };
35
+ }
36
+ function assertByteLimit(label, text, maxBytes) {
37
+ const bytes = Buffer.byteLength(text, "utf8");
38
+ if (bytes < 1 || bytes > maxBytes) {
39
+ throw new Error(`${label} must be 1..${maxBytes} UTF-8 bytes`);
40
+ }
41
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) {
42
+ throw new Error(`${label} contains control characters`);
43
+ }
44
+ }
45
+ function requireThreeBullets(value, label, maxBytes) {
46
+ if (!Array.isArray(value) || value.length !== ASK_USER_DECISION_RATIONALE_COUNT) {
47
+ throw new Error(`${label} must be exactly ${ASK_USER_DECISION_RATIONALE_COUNT} strings`);
48
+ }
49
+ const out = [];
50
+ for (let i = 0; i < ASK_USER_DECISION_RATIONALE_COUNT; i++) {
51
+ const item = value[i];
52
+ if (typeof item !== "string") {
53
+ throw new Error(`${label}[${i}] must be a string`);
54
+ }
55
+ const trimmed = item.trim();
56
+ assertByteLimit(`${label}[${i}]`, trimmed, maxBytes);
57
+ out.push(trimmed);
58
+ }
59
+ return out;
60
+ }
61
+ function parseSelectionMode(value) {
62
+ if (value === undefined || value === null)
63
+ return "single";
64
+ if (value === "single" || value === "multiple")
65
+ return value;
66
+ throw new Error('selectionMode must be "single" or "multiple"');
67
+ }
68
+ function hasSelectionFields(answer) {
69
+ if (!answer)
70
+ return false;
71
+ if (typeof answer.selectedId === "string" && answer.selectedId.trim() !== "")
72
+ return true;
73
+ return Array.isArray(answer.selectedIds) && answer.selectedIds.length > 0;
74
+ }
75
+ /** Normalize host answer against mode + allowCustom. Exported for tests. */
76
+ export function resolveAskUserDecisionAnswer(answer, selectionMode, options, gates) {
77
+ const customRaw = answer && typeof answer.customText === "string" ? answer.customText.trim() : "";
78
+ const hasCustom = customRaw.length > 0;
79
+ const hasSelection = hasSelectionFields(answer);
80
+ if (hasCustom && hasSelection) {
81
+ throw new Error("customText is mutually exclusive with selectedId/selectedIds");
82
+ }
83
+ if (hasCustom) {
84
+ if (!gates.allowCustom)
85
+ throw new Error("customText rejected (allowCustom=false)");
86
+ assertByteLimit("customText", customRaw, gates.maxCustomTextBytes);
87
+ return { kind: "custom", customText: customRaw };
88
+ }
89
+ const byId = new Map(options.map((o) => [o.id, o]));
90
+ const rawIds = [];
91
+ if (answer && Array.isArray(answer.selectedIds)) {
92
+ for (const id of answer.selectedIds) {
93
+ if (typeof id !== "string")
94
+ throw new Error("selectedIds entries must be strings");
95
+ rawIds.push(id.trim());
96
+ }
97
+ }
98
+ if (answer && typeof answer.selectedId === "string") {
99
+ const id = answer.selectedId.trim();
100
+ if (rawIds.length === 0)
101
+ rawIds.push(id);
102
+ else if (!(rawIds.length === 1 && rawIds[0] === id)) {
103
+ throw new Error("selectedId and selectedIds disagree");
104
+ }
105
+ }
106
+ if (rawIds.length === 0) {
107
+ throw new Error(gates.allowCustom
108
+ ? "ask() must return selectedId/selectedIds or customText"
109
+ : selectionMode === "multiple"
110
+ ? "ask() must return non-empty selectedIds"
111
+ : "ask() must return selectedId");
112
+ }
113
+ const seen = new Set();
114
+ const selectedIds = [];
115
+ for (const id of rawIds) {
116
+ if (!byId.has(id))
117
+ throw new Error(`ask() returned unknown selectedId: ${id}`);
118
+ if (seen.has(id))
119
+ throw new Error(`duplicate selectedId: ${id}`);
120
+ seen.add(id);
121
+ selectedIds.push(id);
122
+ }
123
+ if (selectionMode === "single" && selectedIds.length !== 1) {
124
+ // Single accepts selectedIds only when length is exactly 1.
125
+ throw new Error("single selectionMode requires exactly one selected id");
126
+ }
127
+ return { kind: "selection", selectedIds, selectedId: selectedIds[0] };
128
+ }
129
+ function parseAllowCustom(value) {
130
+ if (value === undefined || value === null)
131
+ return false;
132
+ if (typeof value !== "boolean")
133
+ throw new Error("allowCustom must be a boolean");
134
+ return value;
135
+ }
136
+ /** Parse + validate model args into a bounded decision request. Exported for tests. */
137
+ export function parseAskUserDecisionArgs(args, limits) {
138
+ if (typeof args.question !== "string") {
139
+ throw new Error("question must be a string");
140
+ }
141
+ const question = args.question.trim();
142
+ assertByteLimit("question", question, limits.maxQuestionBytes);
143
+ const selectionMode = parseSelectionMode(args.selectionMode);
144
+ const allowCustom = parseAllowCustom(args.allowCustom);
145
+ if (!Array.isArray(args.options)) {
146
+ throw new Error("options must be an array");
147
+ }
148
+ if (args.options.length < 2) {
149
+ throw new Error("options must include at least 2 choices");
150
+ }
151
+ if (args.options.length > limits.maxOptions) {
152
+ throw new Error(`options exceeds maxOptions (${limits.maxOptions})`);
153
+ }
154
+ const seen = new Set();
155
+ const options = [];
156
+ for (let i = 0; i < args.options.length; i++) {
157
+ const raw = args.options[i];
158
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
159
+ throw new Error(`options[${i}] must be an object`);
160
+ }
161
+ const row = raw;
162
+ if (typeof row.id !== "string")
163
+ throw new Error(`options[${i}].id must be a string`);
164
+ const id = row.id.trim();
165
+ if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(id)) {
166
+ throw new Error(`options[${i}].id has invalid format`);
167
+ }
168
+ if (seen.has(id))
169
+ throw new Error(`duplicate option id: ${id}`);
170
+ seen.add(id);
171
+ if (typeof row.label !== "string")
172
+ throw new Error(`options[${i}].label must be a string`);
173
+ const label = row.label.trim();
174
+ assertByteLimit(`options[${i}].label`, label, limits.maxLabelBytes);
175
+ options.push({
176
+ id,
177
+ label,
178
+ pros: requireThreeBullets(row.pros, `options[${i}].pros`, limits.maxBulletBytes),
179
+ cons: requireThreeBullets(row.cons, `options[${i}].cons`, limits.maxBulletBytes),
180
+ });
181
+ }
182
+ return { question, options, selectionMode, allowCustom };
183
+ }
184
+ /**
185
+ * Create the opt-in `ask_user_decision` tool.
186
+ * Host must supply `ask`; factory throws if missing.
187
+ */
188
+ export function createAskUserDecisionTool(options) {
189
+ if (typeof options?.ask !== "function") {
190
+ throw new Error("ask_user_decision requires options.ask");
191
+ }
192
+ const limits = resolveAskUserDecisionLimits(options);
193
+ const ask = options.ask;
194
+ return {
195
+ name: ASK_USER_DECISION_TOOL_NAME,
196
+ description: "Ask the user to choose a direction when instructions are ambiguous and the choice matters. " +
197
+ "Provide 2+ options; each option MUST include exactly 3 pros and 3 cons. " +
198
+ 'Use selectionMode "multiple" when several options may apply together; default is single choice. ' +
199
+ "Set allowCustom=true only when a short free-text alternative to the listed options is acceptable " +
200
+ "(custom answer is mutually exclusive with selecting option ids). " +
201
+ "Do not use for trivia, confirmations that are already clear, or when a single safe default exists.",
202
+ exclusive: true,
203
+ parameters: {
204
+ type: "object",
205
+ properties: {
206
+ question: {
207
+ type: "string",
208
+ description: "Clear decision question for the user",
209
+ },
210
+ selectionMode: {
211
+ type: "string",
212
+ enum: ["single", "multiple"],
213
+ description: 'single (default) or multiple selection',
214
+ },
215
+ allowCustom: {
216
+ type: "boolean",
217
+ description: "When true, host may return customText instead of selecting option ids (XOR). Default false.",
218
+ },
219
+ options: {
220
+ type: "array",
221
+ minItems: 2,
222
+ maxItems: limits.maxOptions,
223
+ items: {
224
+ type: "object",
225
+ properties: {
226
+ id: {
227
+ type: "string",
228
+ description: "Stable option id returned when selected (e.g. keep_sqlite)",
229
+ },
230
+ label: {
231
+ type: "string",
232
+ description: "User-facing option label",
233
+ },
234
+ pros: {
235
+ type: "array",
236
+ minItems: 3,
237
+ maxItems: 3,
238
+ items: { type: "string" },
239
+ description: "Exactly 3 advantages of this option",
240
+ },
241
+ cons: {
242
+ type: "array",
243
+ minItems: 3,
244
+ maxItems: 3,
245
+ items: { type: "string" },
246
+ description: "Exactly 3 disadvantages of this option",
247
+ },
248
+ },
249
+ required: ["id", "label", "pros", "cons"],
250
+ additionalProperties: false,
251
+ },
252
+ description: `2..${limits.maxOptions} options with pros/cons`,
253
+ },
254
+ },
255
+ required: ["question", "options"],
256
+ additionalProperties: false,
257
+ },
258
+ async execute(args, context) {
259
+ const toolCallId = context.toolCallId;
260
+ if (context.signal?.aborted)
261
+ return errorResult(toolCallId, "Operation aborted");
262
+ let parsed;
263
+ try {
264
+ parsed = parseAskUserDecisionArgs(args, limits);
265
+ }
266
+ catch (error) {
267
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
268
+ }
269
+ const policyCheck = await enforceExecutionPolicy(options.executionPolicy, {
270
+ kind: "ask_user_decision",
271
+ operation: "ask",
272
+ risk: "medium",
273
+ metadata: {
274
+ optionCount: parsed.options.length,
275
+ optionIds: parsed.options.map((o) => o.id),
276
+ selectionMode: parsed.selectionMode,
277
+ allowCustom: parsed.allowCustom,
278
+ sessionId: context.sessionId,
279
+ runId: context.runId,
280
+ signal: context.signal,
281
+ },
282
+ }, toolCallId, ASK_USER_DECISION_TOOL_NAME);
283
+ if (!policyCheck.allowed)
284
+ return policyCheck.result;
285
+ let answer;
286
+ try {
287
+ answer = await ask({
288
+ question: parsed.question,
289
+ options: parsed.options,
290
+ selectionMode: parsed.selectionMode,
291
+ allowCustom: parsed.allowCustom,
292
+ toolCallId,
293
+ sessionId: context.sessionId,
294
+ runId: context.runId,
295
+ signal: context.signal,
296
+ });
297
+ }
298
+ catch (error) {
299
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
300
+ }
301
+ if (context.signal?.aborted)
302
+ return errorResult(toolCallId, "Operation aborted");
303
+ let resolved;
304
+ try {
305
+ resolved = resolveAskUserDecisionAnswer(answer, parsed.selectionMode, parsed.options, {
306
+ allowCustom: parsed.allowCustom,
307
+ maxCustomTextBytes: limits.maxCustomTextBytes,
308
+ });
309
+ }
310
+ catch (error) {
311
+ return errorResult(toolCallId, error instanceof Error ? error.message : String(error));
312
+ }
313
+ if (resolved.kind === "custom") {
314
+ return {
315
+ toolCallId,
316
+ name: ASK_USER_DECISION_TOOL_NAME,
317
+ content: [
318
+ {
319
+ type: "text",
320
+ text: `User provided custom answer: ${resolved.customText}`,
321
+ },
322
+ ],
323
+ metadata: {
324
+ customText: resolved.customText,
325
+ selectionMode: parsed.selectionMode,
326
+ allowCustom: parsed.allowCustom,
327
+ question: parsed.question,
328
+ options: parsed.options,
329
+ },
330
+ };
331
+ }
332
+ const selected = resolved.selectedIds.map((id) => parsed.options.find((o) => o.id === id));
333
+ const labelText = selected.map((o) => `"${o.label}" (id=${o.id})`).join(", ");
334
+ return {
335
+ toolCallId,
336
+ name: ASK_USER_DECISION_TOOL_NAME,
337
+ content: [
338
+ {
339
+ type: "text",
340
+ text: parsed.selectionMode === "multiple"
341
+ ? `User selected ${selected.length} option(s): ${labelText}.`
342
+ : `User selected ${labelText}.`,
343
+ },
344
+ ],
345
+ metadata: {
346
+ selectedId: resolved.selectedId,
347
+ selectedIds: resolved.selectedIds,
348
+ selectedLabels: selected.map((o) => o.label),
349
+ selectionMode: parsed.selectionMode,
350
+ allowCustom: parsed.allowCustom,
351
+ question: parsed.question,
352
+ options: parsed.options,
353
+ },
354
+ };
355
+ },
356
+ };
357
+ }
358
+ /** JSON Schema describing resume `input` (= AskUserDecisionAnswer). */
359
+ export function askUserDecisionResumeSchema(request) {
360
+ const optionIds = request.options.map((o) => o.id);
361
+ const selectionProps = {
362
+ selectedId: { type: "string", enum: optionIds },
363
+ selectedIds: {
364
+ type: "array",
365
+ minItems: 1,
366
+ maxItems: optionIds.length,
367
+ items: { type: "string", enum: optionIds },
368
+ },
369
+ };
370
+ if (!request.allowCustom) {
371
+ return {
372
+ type: "object",
373
+ additionalProperties: false,
374
+ properties: selectionProps,
375
+ // Host may send either field; tool/validator enforces mode + XOR with custom.
376
+ anyOf: [{ required: ["selectedId"] }, { required: ["selectedIds"] }],
377
+ };
378
+ }
379
+ return {
380
+ type: "object",
381
+ additionalProperties: false,
382
+ properties: {
383
+ ...selectionProps,
384
+ customText: {
385
+ type: "string",
386
+ minLength: 1,
387
+ maxLength: DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES,
388
+ },
389
+ },
390
+ anyOf: [
391
+ { required: ["selectedId"] },
392
+ { required: ["selectedIds"] },
393
+ { required: ["customText"] },
394
+ ],
395
+ };
396
+ }
397
+ export function toAskUserDecisionSuspendData(request) {
398
+ return {
399
+ question: request.question,
400
+ options: request.options,
401
+ selectionMode: request.selectionMode,
402
+ allowCustom: request.allowCustom,
403
+ ...(request.toolCallId ? { toolCallId: request.toolCallId } : {}),
404
+ ...(request.sessionId ? { sessionId: request.sessionId } : {}),
405
+ ...(request.runId ? { runId: request.runId } : {}),
406
+ };
407
+ }
408
+ function isAskUserDecisionSuspendData(value) {
409
+ if (!value || typeof value !== "object" || Array.isArray(value))
410
+ return false;
411
+ const row = value;
412
+ return (typeof row.question === "string"
413
+ && Array.isArray(row.options)
414
+ && (row.selectionMode === "single" || row.selectionMode === "multiple")
415
+ && typeof row.allowCustom === "boolean");
416
+ }
417
+ /**
418
+ * Return from a workflow node to pause for a user decision (opt-in durable path).
419
+ * Host resumes via `resumeWorkflow` + `createAskUserDecisionResumeValidator` / `validateAskUserDecisionResume`.
420
+ */
421
+ export function suspendAskUserDecision(request, options) {
422
+ const data = toAskUserDecisionSuspendData(request);
423
+ if (data.options.length < 2) {
424
+ throw new Error("suspendAskUserDecision requires at least 2 options");
425
+ }
426
+ return suspend({
427
+ reason: options?.reason ?? ASK_USER_DECISION_SUSPEND_REASON,
428
+ data,
429
+ resumeSchema: askUserDecisionResumeSchema(data),
430
+ });
431
+ }
432
+ /**
433
+ * Validate resume input against the original decision request.
434
+ * Shared by workflow `validateResume` and host-held agent resume adapters.
435
+ */
436
+ export function validateAskUserDecisionResume(request, value, limits) {
437
+ if (!isAskUserDecisionSuspendData(request)) {
438
+ throw new Error("invalid ask_user_decision suspend data");
439
+ }
440
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
441
+ throw new Error("resume input must be an ask_user_decision answer object");
442
+ }
443
+ const maxCustomTextBytes = limits?.maxCustomTextBytes ?? DEFAULT_MAX_ASK_USER_DECISION_CUSTOM_BYTES;
444
+ return resolveAskUserDecisionAnswer(value, request.selectionMode, request.options, { allowCustom: request.allowCustom, maxCustomTextBytes });
445
+ }
446
+ /**
447
+ * Workflow `validateResume` adapter. Reads durable request from `suspension.data`
448
+ * (written by `suspendAskUserDecision`). Deny paths skip answer validation.
449
+ */
450
+ export function createAskUserDecisionResumeValidator(limits) {
451
+ return (input) => {
452
+ if (!isAskUserDecisionSuspendData(input.suspension.data)) {
453
+ throw new Error("suspension.data missing ask_user_decision request");
454
+ }
455
+ // Deny (and other no-input resumes) may omit answer; approve supplies it.
456
+ if (input.value === undefined || input.value === null)
457
+ return;
458
+ validateAskUserDecisionResume(input.suspension.data, input.value, limits);
459
+ };
460
+ }
461
+ /**
462
+ * Thin agent-path adapter: same validation as workflow resume, for hosts that
463
+ * persist `AskUserDecisionSuspendData` outside `AgentRunInterruption` (core kinds
464
+ * unchanged in 0.0.11). Call after operator supplies an answer.
465
+ */
466
+ export function validateAskUserDecisionAgentResume(input) {
467
+ return validateAskUserDecisionResume(input.request, input.answer, input.maxCustomTextBytes === undefined
468
+ ? undefined
469
+ : { maxCustomTextBytes: input.maxCustomTextBytes });
470
+ }
471
+ //# sourceMappingURL=ask-user-decision.js.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Named check tool: host declares fixed executable+args; model selects only a name.
3
+ */
4
+ import type { ExecutionPolicy, ToolDefinition } from "@arnilo/prism";
5
+ export interface NamedCheckDefinition {
6
+ /** Absolute executable path, or a basename resolved only via host-supplied env PATH. */
7
+ readonly file: string;
8
+ readonly args: readonly string[];
9
+ readonly cwd?: string;
10
+ /** Exact env allow-list (never inherits process.env unless host copies values in). */
11
+ readonly env?: Readonly<Record<string, string>>;
12
+ readonly timeoutMs?: number;
13
+ }
14
+ export interface CodingCheckToolOptions {
15
+ readonly executionPolicy?: ExecutionPolicy;
16
+ readonly checks: Readonly<Record<string, NamedCheckDefinition>>;
17
+ readonly maxConcurrency?: number;
18
+ readonly maxDiagnosticLines?: number;
19
+ readonly maxOutputBytes?: number;
20
+ readonly defaultTimeoutMs?: number;
21
+ }
22
+ /**
23
+ * Create the `coding_check` tool. Model may only select a declared name — never
24
+ * executable path or arguments.
25
+ */
26
+ export declare function createCodingCheckTool(cwd: string, options: CodingCheckToolOptions): ToolDefinition;