vessels 0.7.0 → 0.8.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/dist/index.js CHANGED
@@ -4061,7 +4061,8 @@ var InteractionTypeSchema = external_exports.enum([
4061
4061
  "approval",
4062
4062
  "choice",
4063
4063
  "checklist",
4064
- "text_input"
4064
+ "text_input",
4065
+ "questions"
4065
4066
  ]);
4066
4067
  var ApprovalInteractionSchema = external_exports.object({
4067
4068
  type: external_exports.literal("approval"),
@@ -4104,11 +4105,39 @@ var TextInputInteractionSchema = external_exports.object({
4104
4105
  submitLabel: external_exports.string().optional(),
4105
4106
  metadata: external_exports.record(external_exports.unknown()).optional()
4106
4107
  });
4108
+ var QuestionOptionSchema = external_exports.object({
4109
+ id: external_exports.string().min(1),
4110
+ label: external_exports.string().min(1),
4111
+ /** Optional one-line explanation of what this option means. */
4112
+ description: external_exports.string().optional()
4113
+ });
4114
+ var QuestionSchema = external_exports.object({
4115
+ /** Stable id used to key this question's answer in the response. */
4116
+ id: external_exports.string().min(1),
4117
+ /** The question text shown to the human. */
4118
+ question: external_exports.string().min(1),
4119
+ /** Optional short chip label (≤12 chars) — e.g. "Date", "Guests". */
4120
+ header: external_exports.string().max(24).optional(),
4121
+ options: external_exports.array(QuestionOptionSchema).min(2).max(4),
4122
+ /** Allow selecting more than one option (checkboxes instead of radios). */
4123
+ multiSelect: external_exports.boolean().optional(),
4124
+ /** Offer a free-text "Other" field alongside the options (default true). */
4125
+ allowOther: external_exports.boolean().optional()
4126
+ });
4127
+ var QuestionsInteractionSchema = external_exports.object({
4128
+ type: external_exports.literal("questions"),
4129
+ /** Overall heading / context for the batch (the surface prompt). */
4130
+ prompt: external_exports.string().min(1),
4131
+ questions: external_exports.array(QuestionSchema).min(1).max(4),
4132
+ submitLabel: external_exports.string().optional(),
4133
+ metadata: external_exports.record(external_exports.unknown()).optional()
4134
+ });
4107
4135
  var InteractionSchema = external_exports.discriminatedUnion("type", [
4108
4136
  ApprovalInteractionSchema,
4109
4137
  ChoiceInteractionSchema,
4110
4138
  ChecklistInteractionSchema,
4111
- TextInputInteractionSchema
4139
+ TextInputInteractionSchema,
4140
+ QuestionsInteractionSchema
4112
4141
  ]);
4113
4142
  var AgentActivityTypeSchema = external_exports.enum(["thinking", "searching", "tool_use", "browsing", "processing"]);
4114
4143
  var AgentTodoStatusSchema = external_exports.enum(["pending", "in_progress", "done"]);
@@ -4116,12 +4145,14 @@ var AgentTodoInputSchema = external_exports.object({
4116
4145
  label: external_exports.string().min(1).max(200),
4117
4146
  status: AgentTodoStatusSchema.optional()
4118
4147
  });
4148
+ var AgentActivityStatusInputSchema = external_exports.enum(["working", "awaiting_input"]);
4119
4149
  var AgentActivitySchema = external_exports.object({
4120
4150
  type: AgentActivityTypeSchema.optional(),
4121
4151
  label: external_exports.string().max(200).optional(),
4122
- todos: external_exports.array(AgentTodoInputSchema).max(50).optional()
4123
- }).refine((d) => d.type != null || d.todos != null, {
4124
- message: "agentActivity requires `type` (a step) or `todos` (a plan)"
4152
+ todos: external_exports.array(AgentTodoInputSchema).max(50).optional(),
4153
+ status: AgentActivityStatusInputSchema.optional()
4154
+ }).refine((d) => d.type != null || d.todos != null || d.status != null, {
4155
+ message: "agentActivity requires `type` (a step), `todos` (a plan), or `status`"
4125
4156
  });
4126
4157
  var CardFieldSchema = external_exports.object({
4127
4158
  label: external_exports.string().min(1),
@@ -4227,11 +4258,20 @@ var ChecklistResponseSchema = external_exports.object({
4227
4258
  var TextInputResponseSchema = external_exports.object({
4228
4259
  text: external_exports.string()
4229
4260
  });
4261
+ var QuestionAnswerSchema = external_exports.object({
4262
+ questionId: external_exports.string().min(1),
4263
+ selected: external_exports.array(external_exports.string()),
4264
+ other: external_exports.string().optional()
4265
+ });
4266
+ var QuestionsResponseSchema = external_exports.object({
4267
+ answers: external_exports.array(QuestionAnswerSchema)
4268
+ });
4230
4269
  var InteractionResponseSchema = external_exports.discriminatedUnion("interactionType", [
4231
4270
  external_exports.object({ interactionType: external_exports.literal("approval"), response: ApprovalResponseSchema }),
4232
4271
  external_exports.object({ interactionType: external_exports.literal("choice"), response: ChoiceResponseSchema }),
4233
4272
  external_exports.object({ interactionType: external_exports.literal("checklist"), response: ChecklistResponseSchema }),
4234
- external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema })
4273
+ external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema }),
4274
+ external_exports.object({ interactionType: external_exports.literal("questions"), response: QuestionsResponseSchema })
4235
4275
  ]);
4236
4276
  var WebhookVesselSchema = external_exports.object({
4237
4277
  id: external_exports.string(),
package/package.json CHANGED
@@ -1,31 +1,31 @@
1
1
  {
2
- "name": "vessels",
3
- "version": "0.7.0",
4
- "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
- "type": "module",
6
- "bin": {
7
- "vessels": "./dist/index.js"
8
- },
9
- "files": [
10
- "dist",
11
- "template"
12
- ],
13
- "scripts": {
14
- "build": "tsup",
15
- "dev": "tsup --watch"
16
- },
17
- "license": "MIT",
18
- "keywords": [
19
- "ai",
20
- "agents",
21
- "vessels",
22
- "cli"
23
- ],
24
- "devDependencies": {
25
- "tsup": "^8.5.1",
26
- "typescript": "^5",
27
- "@types/node": "^25.5.2",
28
- "@vessels/types": "workspace:*"
29
- },
30
- "dependencies": {}
2
+ "name": "vessels",
3
+ "version": "0.8.0",
4
+ "description": "Vessels CLI — manage your agent communication layer from the terminal",
5
+ "type": "module",
6
+ "bin": {
7
+ "vessels": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup",
15
+ "dev": "tsup --watch"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "ai",
20
+ "agents",
21
+ "vessels",
22
+ "cli"
23
+ ],
24
+ "devDependencies": {
25
+ "tsup": "^8.5.1",
26
+ "typescript": "^5",
27
+ "@types/node": "^25.5.2",
28
+ "@vessels/types": "workspace:*"
29
+ },
30
+ "dependencies": {}
31
31
  }
@@ -35,7 +35,7 @@ import {
35
35
  cleanLabels,
36
36
  cleanAttachments,
37
37
  } from './vessels-tools.js';
38
- import type { AgentStore } from './store.js';
38
+ import type { AgentStore, ResumeMarker } from './store.js';
39
39
 
40
40
  const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
41
41
  const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
@@ -157,6 +157,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
157
157
  // so we thread on top of it instead of clobbering it.
158
158
  const messages: MessageParam[] = await store.loadState(vessel);
159
159
  appendHumanTurn(messages, humanInput);
160
+ // A prior turn may have paused a plan on a mid-plan checkpoint — recover its handle
161
+ // so we re-attach to the SAME working card below instead of opening a new one.
162
+ const resume = await store.loadResume(vessel);
160
163
 
161
164
  const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
162
165
  const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
@@ -191,6 +194,16 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
191
194
  // Authoritative todo list for the working card; we PATCH the full list each update.
192
195
  type Todo = { label: string; status: AgentTodoStatus };
193
196
  let todos: Todo[] = [];
197
+
198
+ // RESUME a paused plan: re-attach to the SAME working card and its plan instead of
199
+ // opening a fresh one, so the operator sees ONE continuous card across the whole
200
+ // multi-step flow. The first work patch flips it from awaiting_input back to working.
201
+ if (resume?.activityId) {
202
+ activityId = resume.activityId;
203
+ cardOpened = true;
204
+ if (Array.isArray(resume.todos)) todos = resume.todos.map((t) => ({ label: String(t.label), status: t.status }));
205
+ log('resume', { activityId, todos: todos.length });
206
+ }
194
207
  const patchActivity = async (body: Record<string, unknown>) => {
195
208
  if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
196
209
  };
@@ -260,6 +273,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
260
273
  const pushes: PendingPush[] = [];
261
274
 
262
275
  let ended = false;
276
+ // A mid-plan checkpoint (request_* with keepWorking) ends the MODEL loop but does NOT
277
+ // seal the card: the plan pauses on the human and resumes on their reply (see finally).
278
+ let pauseForInput = false;
263
279
  const recordEnding = (name: string, input: Record<string, unknown>) => {
264
280
  const msg = String(input.message ?? '');
265
281
  const endPin = sanitizeCard(input.pinCard);
@@ -270,6 +286,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
270
286
  pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
271
287
  } else {
272
288
  const interaction = buildInteraction(name, input);
289
+ if (input.keepWorking === true) pauseForInput = true;
273
290
  pushes.push({
274
291
  message: msg || String(input.prompt ?? 'Please respond.'),
275
292
  kind: 'surface',
@@ -372,7 +389,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
372
389
  const input = (tu.input ?? {}) as Record<string, unknown>;
373
390
  if (tu.name === 'plan') {
374
391
  const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
375
- todos = labels.map((label) => ({ label, status: 'pending' as AgentTodoStatus }));
392
+ // Merge by label, preserving the status of tasks already in flight — matters on
393
+ // a RESUME (the seeded plan keeps its done steps) and any same-turn re-plan.
394
+ todos = labels.map((label) => {
395
+ const prev = todos.find((t) => t.label.toLowerCase() === label.toLowerCase());
396
+ return { label, status: prev?.status ?? ('pending' as AgentTodoStatus) };
397
+ });
376
398
  await patchActivity({ todos });
377
399
  toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'plan set' });
378
400
  } else if (tu.name === 'step') {
@@ -472,10 +494,19 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
472
494
  log('turn error', err);
473
495
  pushes.push({ message: 'I hit a snag and had to stop early.' });
474
496
  } finally {
475
- // Guarantee the sealthe working card this turn opened MUST resolve, even on an error.
497
+ // Resolve the working card UNLESS this turn paused on a mid-plan checkpoint, in
498
+ // which case we leave it live (awaiting_input) for the next turn to resume. Either way
499
+ // the card never orphans, even on an error.
476
500
  try {
477
501
  await stopStream();
478
- if (activityId) await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
502
+ if (activityId) {
503
+ await safePatch(vessels, activityId, {
504
+ agentActivity: pauseForInput ? { status: 'awaiting_input' } : null,
505
+ tokenStream: null,
506
+ });
507
+ }
508
+ // Persist the paused-plan handle (or clear a prior one now the plan resumed/ended).
509
+ await store.saveResume(vessel, pauseForInput && activityId ? { activityId, todos } : null);
479
510
  } catch (e) {
480
511
  log('seal failed', e);
481
512
  }
@@ -103,12 +103,13 @@ const server = http.createServer(async (req, res) => {
103
103
  work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
104
104
  }
105
105
  } else if (event.type === 'interaction.response') {
106
- const prompt = (event.originMessage?.interaction?.prompt as string | undefined) ?? undefined;
106
+ const originInteraction = event.originMessage?.interaction ?? null;
107
+ const prompt = (originInteraction?.prompt as string | undefined) ?? undefined;
107
108
  work = runTurn({
108
109
  vessels,
109
110
  store,
110
111
  vessel,
111
- humanInput: renderInteractionResponse(event.interactionType, event.response, prompt),
112
+ humanInput: renderInteractionResponse(event.interactionType, event.response, prompt, originInteraction),
112
113
  idempotencyKeyBase: `ir:${event.id}`,
113
114
  });
114
115
  }
@@ -88,8 +88,16 @@ Tools:
88
88
  - request_choice — pick one option (with options[])
89
89
  - request_checklist — pick several options (with options[])
90
90
  - request_text — free-text answer
91
+ - request_questions — SEVERAL questions at once, answered together (a short form)
91
92
  - finish — wrap up; no further human action needed
92
93
 
94
+ MID-PLAN CHECKPOINTS — keepWorking: when a multi-step plan needs the operator's input
95
+ PART-WAY THROUGH (e.g. plan = Draft → Get approval → Send), raise the request_* with
96
+ keepWorking:true. The plan card stays LIVE and paused on them — pending tasks intact, not
97
+ greyed out — and when they answer you pick the SAME plan back up and finish the remaining
98
+ steps, one continuous card. Omit keepWorking on the FINAL question/decision, which seals
99
+ the plan and ends the turn.
100
+
93
101
  0. quick_reply(message, done?) — ALWAYS your first action (see the lead-with-a-reply rule
94
102
  above): one conversational line, pushed instantly. done:true → it's the whole answer and
95
103
  the turn ends. done false/omitted → it's your "on it" line; the working card opens right
@@ -18,12 +18,26 @@
18
18
  * Swap in Redis/Dynamo/your-DB by implementing the same `AgentStore` interface.
19
19
  */
20
20
  import type { MessageParam } from '@anthropic-ai/sdk/resources/messages';
21
+ import type { AgentTodoStatus } from 'vessels-sdk';
22
+
23
+ /**
24
+ * A paused plan's handle, saved when a turn ends on a MID-PLAN checkpoint
25
+ * (a request_* with keepWorking). The next turn re-attaches to the SAME working
26
+ * card (`activityId`) and its `todos` instead of opening a new one — so the
27
+ * operator sees one continuous card across a multi-step flow. Like everything in
28
+ * the store, this is the agent's own runtime state, never Vessels'.
29
+ */
30
+ export type ResumeMarker = { activityId: string; todos: { label: string; status: AgentTodoStatus }[] };
21
31
 
22
32
  export interface AgentStore {
23
33
  /** The agent's conversation history for this vessel (empty array if new). */
24
34
  loadState(vessel: string): Promise<MessageParam[]>;
25
35
  /** Persist the full conversation history for this vessel. */
26
36
  saveState(vessel: string, messages: MessageParam[]): Promise<void>;
37
+ /** The paused-plan handle for this vessel, or null when no plan is paused. */
38
+ loadResume(vessel: string): Promise<ResumeMarker | null>;
39
+ /** Save the paused-plan handle (or null to clear it once the plan resumes/ends). */
40
+ saveResume(vessel: string, marker: ResumeMarker | null): Promise<void>;
27
41
  /** Try to take the per-vessel lock. Returns false if someone else holds it. TTL bounds a crash. */
28
42
  acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
29
43
  /** Release the per-vessel lock. */
@@ -36,6 +50,7 @@ export interface AgentStore {
36
50
 
37
51
  export class MemoryStore implements AgentStore {
38
52
  private state = new Map<string, MessageParam[]>();
53
+ private resume = new Map<string, ResumeMarker>();
39
54
  private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
40
55
 
41
56
  async loadState(vessel: string): Promise<MessageParam[]> {
@@ -46,6 +61,15 @@ export class MemoryStore implements AgentStore {
46
61
  this.state.set(vessel, messages);
47
62
  }
48
63
 
64
+ async loadResume(vessel: string): Promise<ResumeMarker | null> {
65
+ return this.resume.get(vessel) ?? null;
66
+ }
67
+
68
+ async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
69
+ if (marker) this.resume.set(vessel, marker);
70
+ else this.resume.delete(vessel);
71
+ }
72
+
49
73
  async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
50
74
  const now = Date.now();
51
75
  const until = this.locks.get(vessel);
@@ -79,6 +103,11 @@ export class PostgresStore implements AgentStore {
79
103
  vessel TEXT PRIMARY KEY,
80
104
  expires_at TIMESTAMPTZ NOT NULL
81
105
  );
106
+ CREATE TABLE IF NOT EXISTS agent_resume (
107
+ vessel TEXT PRIMARY KEY,
108
+ marker JSONB NOT NULL,
109
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
110
+ );
82
111
  `);
83
112
  }
84
113
 
@@ -104,6 +133,27 @@ export class PostgresStore implements AgentStore {
104
133
  );
105
134
  }
106
135
 
136
+ async loadResume(vessel: string): Promise<ResumeMarker | null> {
137
+ const { rows } = await this.db.query<{ marker: ResumeMarker }>(
138
+ 'SELECT marker FROM agent_resume WHERE vessel = $1',
139
+ [vessel]
140
+ );
141
+ return rows[0]?.marker ?? null;
142
+ }
143
+
144
+ async saveResume(vessel: string, marker: ResumeMarker | null): Promise<void> {
145
+ if (!marker) {
146
+ await this.db.query('DELETE FROM agent_resume WHERE vessel = $1', [vessel]);
147
+ return;
148
+ }
149
+ await this.db.query(
150
+ `INSERT INTO agent_resume (vessel, marker, updated_at)
151
+ VALUES ($1, $2, now())
152
+ ON CONFLICT (vessel) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now()`,
153
+ [vessel, JSON.stringify(marker)]
154
+ );
155
+ }
156
+
107
157
  async acquireLock(vessel: string, ttlSeconds: number): Promise<boolean> {
108
158
  // Atomic: take the row if free, OR steal it if the prior holder's TTL has lapsed.
109
159
  const { rows } = await this.db.query(
@@ -82,6 +82,12 @@ const METADATA_FIELD = {
82
82
  additionalProperties: true,
83
83
  };
84
84
 
85
+ const KEEP_WORKING_FIELD = {
86
+ type: 'boolean' as const,
87
+ description:
88
+ 'TRUE when this question is a MID-PLAN checkpoint — you still have remaining plan steps to do AFTER you get the answer. The working card stays live (paused on the operator, plan intact, not greyed out), and you pick the SAME plan back up on their reply instead of starting over. Use it for a multi-step plan where one step needs a sign-off before the next. OMIT (or false) for the FINAL decision of the turn — that seals the plan and hands back.',
89
+ };
90
+
85
91
  // ─── The control tools ──────────────────────────────────────────────────────────
86
92
 
87
93
  export const CONTROL_TOOLS: Tool[] = [
@@ -189,6 +195,7 @@ export const CONTROL_TOOLS: Tool[] = [
189
195
  pinCard: PIN_CARD_FIELD,
190
196
  labels: LABELS_FIELD,
191
197
  metadata: METADATA_FIELD,
198
+ keepWorking: KEEP_WORKING_FIELD,
192
199
  },
193
200
  required: ['message', 'prompt'],
194
201
  },
@@ -209,6 +216,7 @@ export const CONTROL_TOOLS: Tool[] = [
209
216
  pinCard: PIN_CARD_FIELD,
210
217
  labels: LABELS_FIELD,
211
218
  metadata: METADATA_FIELD,
219
+ keepWorking: KEEP_WORKING_FIELD,
212
220
  },
213
221
  required: ['message', 'prompt', 'options'],
214
222
  },
@@ -229,6 +237,7 @@ export const CONTROL_TOOLS: Tool[] = [
229
237
  pinCard: PIN_CARD_FIELD,
230
238
  labels: LABELS_FIELD,
231
239
  metadata: METADATA_FIELD,
240
+ keepWorking: KEEP_WORKING_FIELD,
232
241
  },
233
242
  required: ['message', 'prompt', 'options'],
234
243
  },
@@ -248,10 +257,60 @@ export const CONTROL_TOOLS: Tool[] = [
248
257
  pinCard: PIN_CARD_FIELD,
249
258
  labels: LABELS_FIELD,
250
259
  metadata: METADATA_FIELD,
260
+ keepWorking: KEEP_WORKING_FIELD,
251
261
  },
252
262
  required: ['message', 'prompt'],
253
263
  },
254
264
  },
265
+ {
266
+ name: 'request_questions',
267
+ description:
268
+ 'Ask the operator SEVERAL questions AT ONCE — a short form they fill in and submit together. Each question is a single- or multi-select over 2–4 options, with an optional free-text Other. Use when a step needs a few answers at once instead of a back-and-forth. A full-width surface; ends your turn (or pauses it mid-plan with keepWorking).',
269
+ input_schema: {
270
+ type: 'object',
271
+ properties: {
272
+ title: { type: 'string', description: 'The surface heading' },
273
+ message: { type: 'string', description: 'Optional context body (block markdown) above the questions' },
274
+ prompt: { type: 'string', description: 'One line framing the batch of questions' },
275
+ questions: {
276
+ type: 'array',
277
+ minItems: 1,
278
+ maxItems: 4,
279
+ items: {
280
+ type: 'object',
281
+ properties: {
282
+ id: { type: 'string', description: 'Stable id used to key this answer' },
283
+ question: { type: 'string', description: 'The question text' },
284
+ header: { type: 'string', description: 'Short chip label, ≤12 chars (e.g. "Date", "Guests")' },
285
+ options: {
286
+ type: 'array',
287
+ minItems: 2,
288
+ maxItems: 4,
289
+ items: {
290
+ type: 'object',
291
+ properties: {
292
+ id: { type: 'string' },
293
+ label: { type: 'string' },
294
+ description: { type: 'string', description: 'Optional one-line explanation' },
295
+ },
296
+ required: ['id', 'label'],
297
+ },
298
+ },
299
+ multiSelect: { type: 'boolean', description: 'Allow more than one option (checkboxes).' },
300
+ allowOther: { type: 'boolean', description: 'Offer a free-text Other field (default true).' },
301
+ },
302
+ required: ['id', 'question', 'options'],
303
+ },
304
+ },
305
+ submitLabel: { type: 'string' },
306
+ pinCard: PIN_CARD_FIELD,
307
+ labels: LABELS_FIELD,
308
+ metadata: METADATA_FIELD,
309
+ keepWorking: KEEP_WORKING_FIELD,
310
+ },
311
+ required: ['prompt', 'questions'],
312
+ },
313
+ },
255
314
  {
256
315
  name: 'finish',
257
316
  description: 'Conclude — no further human action needed. Ends your turn.',
@@ -293,7 +352,7 @@ export const CONTROL_TOOLS: Tool[] = [
293
352
  export const CONTROL_TOOL_NAMES = new Set(CONTROL_TOOLS.map((t) => t.name));
294
353
 
295
354
  /** The tools that END a turn — exactly one is the final action. */
296
- export const ENDING_TOOLS = new Set(['request_approval', 'request_choice', 'request_checklist', 'request_text', 'finish']);
355
+ export const ENDING_TOOLS = new Set(['request_approval', 'request_choice', 'request_checklist', 'request_text', 'request_questions', 'finish']);
297
356
 
298
357
  // ─── Default narration ──────────────────────────────────────────────────────────
299
358
 
@@ -347,16 +406,26 @@ export function buildInteraction(toolName: string, input: Record<string, unknown
347
406
  ...(input.multiline ? { multiline: true } : {}),
348
407
  ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
349
408
  });
409
+ case 'request_questions':
410
+ return withMeta({
411
+ type: 'questions',
412
+ prompt: String(input.prompt),
413
+ questions: input.questions,
414
+ ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
415
+ });
350
416
  default:
351
417
  return null;
352
418
  }
353
419
  }
354
420
 
355
- /** Render a human's interaction response as a readable user turn for the model. */
421
+ /** Render a human's interaction response as a readable user turn for the model.
422
+ * `interaction` (the original interaction object) is optional but lets the questions
423
+ * renderer map option ids back to their human labels. */
356
424
  export function renderInteractionResponse(
357
425
  interactionType: string,
358
426
  response: Record<string, unknown>,
359
- prompt?: string
427
+ prompt?: string,
428
+ interaction?: Record<string, unknown> | null
360
429
  ): string {
361
430
  const head = prompt ? `Re: "${prompt}" — ` : '';
362
431
  switch (interactionType) {
@@ -375,6 +444,23 @@ export function renderInteractionResponse(
375
444
  }
376
445
  case 'text_input':
377
446
  return `${head}${response.text ?? ''}`;
447
+ case 'questions': {
448
+ const qs = Array.isArray(interaction?.questions)
449
+ ? (interaction!.questions as Array<{ id: string; question?: string; header?: string; options?: Array<{ id: string; label: string }> }>)
450
+ : [];
451
+ const answers = Array.isArray(response.answers)
452
+ ? (response.answers as Array<{ questionId: string; selected?: string[]; other?: string }>)
453
+ : [];
454
+ const lines = answers.map((a) => {
455
+ const q = qs.find((x) => x.id === a.questionId);
456
+ const label = q?.question ?? q?.header ?? a.questionId;
457
+ const opts = q?.options ?? [];
458
+ const picked = (a.selected ?? []).map((id) => opts.find((o) => o.id === id)?.label ?? id);
459
+ if (a.other) picked.push(a.other);
460
+ return `• ${label}: ${picked.length ? picked.join(', ') : '(none)'}`;
461
+ });
462
+ return `${head}I answered:\n${lines.join('\n')}`;
463
+ }
378
464
  default:
379
465
  return `${head}${JSON.stringify(response)}`;
380
466
  }