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
|
-
|
|
4124
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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)
|
|
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
|
|
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
|
}
|