vessels 0.7.0 → 0.9.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 +92 -7
- package/package.json +29 -29
- package/template/agent/README.md +19 -3
- package/template/agent/_env.example +4 -0
- package/template/agent/package.json +1 -1
- package/template/agent/src/agent.ts +48 -8
- package/template/agent/src/inbound.ts +125 -0
- package/template/agent/src/index.ts +28 -6
- package/template/agent/src/protocol.ts +26 -2
- package/template/agent/src/store.ts +64 -6
- package/template/agent/src/vessels-tools.ts +89 -3
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(),
|
|
@@ -4274,6 +4314,13 @@ var SupersededInteractionSchema = external_exports.object({
|
|
|
4274
4314
|
interaction_type: InteractionTypeSchema,
|
|
4275
4315
|
prompt: external_exports.string().nullable()
|
|
4276
4316
|
});
|
|
4317
|
+
var WebhookEventAttachmentSchema = external_exports.object({
|
|
4318
|
+
type: external_exports.enum(["image", "file"]),
|
|
4319
|
+
filename: external_exports.string().nullish(),
|
|
4320
|
+
fileId: external_exports.string().optional(),
|
|
4321
|
+
downloadUrl: external_exports.string().optional(),
|
|
4322
|
+
url: external_exports.string().optional()
|
|
4323
|
+
});
|
|
4277
4324
|
var WebhookUserMessagePayloadSchema = external_exports.object({
|
|
4278
4325
|
event: external_exports.literal("message.user"),
|
|
4279
4326
|
vessel_id: external_exports.string(),
|
|
@@ -4284,6 +4331,7 @@ var WebhookUserMessagePayloadSchema = external_exports.object({
|
|
|
4284
4331
|
content: external_exports.string(),
|
|
4285
4332
|
vessel: WebhookVesselSchema,
|
|
4286
4333
|
context: external_exports.array(WebhookContextMessageSchema),
|
|
4334
|
+
attachments: external_exports.array(WebhookEventAttachmentSchema).optional(),
|
|
4287
4335
|
superseded_interaction: SupersededInteractionSchema.optional()
|
|
4288
4336
|
})
|
|
4289
4337
|
});
|
|
@@ -4298,7 +4346,8 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
|
|
|
4298
4346
|
message_id: external_exports.string(),
|
|
4299
4347
|
content: external_exports.string().nullable(),
|
|
4300
4348
|
created_at: external_exports.string()
|
|
4301
|
-
})
|
|
4349
|
+
}),
|
|
4350
|
+
attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
|
|
4302
4351
|
})
|
|
4303
4352
|
});
|
|
4304
4353
|
var WebhookMessageCancelledPayloadSchema = external_exports.object({
|
|
@@ -4631,6 +4680,37 @@ async function cmdPush(args) {
|
|
|
4631
4680
|
}
|
|
4632
4681
|
console.log(`Message sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
|
|
4633
4682
|
}
|
|
4683
|
+
var FEEDBACK_TYPES = ["bug", "feature", "other"];
|
|
4684
|
+
async function cmdFeedback(args) {
|
|
4685
|
+
const flags = parseFlags(args);
|
|
4686
|
+
const positionals = [];
|
|
4687
|
+
for (let i = 0; i < args.length; i++) {
|
|
4688
|
+
if (args[i].startsWith("--")) {
|
|
4689
|
+
i++;
|
|
4690
|
+
continue;
|
|
4691
|
+
}
|
|
4692
|
+
positionals.push(args[i]);
|
|
4693
|
+
}
|
|
4694
|
+
let message = (flags.message || positionals.join(" ")).trim();
|
|
4695
|
+
if (!message) message = (await prompt("Your feedback (bug or feature request): ")).trim();
|
|
4696
|
+
if (!message) {
|
|
4697
|
+
console.error("Nothing to submit.");
|
|
4698
|
+
process.exit(1);
|
|
4699
|
+
}
|
|
4700
|
+
const type = (flags.type || "other").toLowerCase();
|
|
4701
|
+
if (!FEEDBACK_TYPES.includes(type)) {
|
|
4702
|
+
console.error(`Type must be one of: ${FEEDBACK_TYPES.join(", ")}`);
|
|
4703
|
+
process.exit(1);
|
|
4704
|
+
}
|
|
4705
|
+
const data = await api("/api/v1/feedback", {
|
|
4706
|
+
method: "POST",
|
|
4707
|
+
body: JSON.stringify({ type, message })
|
|
4708
|
+
});
|
|
4709
|
+
const label = type === "other" ? "Feedback" : type === "bug" ? "Bug report" : "Feature request";
|
|
4710
|
+
console.log(`
|
|
4711
|
+
${label} submitted \u2014 thank you!`);
|
|
4712
|
+
console.log(` id ${data.id}`);
|
|
4713
|
+
}
|
|
4634
4714
|
function readStdin() {
|
|
4635
4715
|
return new Promise((resolve2) => {
|
|
4636
4716
|
const chunks = [];
|
|
@@ -4936,6 +5016,10 @@ Commands:
|
|
|
4936
5016
|
vessels types disable
|
|
4937
5017
|
Manage vessel types and the user-initiated-vessels feature flag.
|
|
4938
5018
|
|
|
5019
|
+
vessels feedback <message> [--type bug|feature|other]
|
|
5020
|
+
Send a bug report or feature request to the Vessels team. Requires login.
|
|
5021
|
+
Message can be positional or --message; --type defaults to "other".
|
|
5022
|
+
|
|
4939
5023
|
vessels push --vessel <id> --message <text> --key <api_key>
|
|
4940
5024
|
(--key can be omitted if VESSELS_API_KEY is set)
|
|
4941
5025
|
|
|
@@ -5019,6 +5103,7 @@ Run: vessels help`);
|
|
|
5019
5103
|
Run: vessels help`);
|
|
5020
5104
|
process.exit(1);
|
|
5021
5105
|
}
|
|
5106
|
+
if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
|
|
5022
5107
|
if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
|
|
5023
5108
|
if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
|
|
5024
5109
|
if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
|
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.9.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
|
}
|
package/template/agent/README.md
CHANGED
|
@@ -92,9 +92,25 @@ chat **bubbles** and full-width **surfaces**; all four **interactions** (`approv
|
|
|
92
92
|
**metadata** that rides back to you for routing); the live **working card** with a ticking **plan**,
|
|
93
93
|
auto-narrated **steps**, and **token streaming**; **pinned cards**, **labels**, **attachments**
|
|
94
94
|
(images/files you host), **preview links**, **suggested replies**, vessel **naming/renaming**, and
|
|
95
|
-
user-initiated vessel **types
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
user-initiated vessel **types**; plus **inbound files** the human sends you — downloaded, stored on
|
|
96
|
+
your infra, resolved, and (for images) read by the model via **vision** (see below). Idempotency
|
|
97
|
+
keys, the per-vessel lock, **one card per turn**, and the resolve-before-ask discipline are handled
|
|
98
|
+
for you. (It deliberately keeps the notification rule too: the working card stays silent, only your
|
|
99
|
+
reply and outcome buzz the human's phone.)
|
|
100
|
+
|
|
101
|
+
## Receiving files (inbound)
|
|
102
|
+
|
|
103
|
+
Vessels is **not a file store** — when the human sends a photo/document, it relays the bytes
|
|
104
|
+
transiently and hands you a signed, short-lived `downloadUrl`. The engine does the full handshake in
|
|
105
|
+
`src/inbound.ts`: **download** it → **store** it on your infra (`store.putInboundFile`) → **resolve**
|
|
106
|
+
the permanent link back (`vessels.resolveInboundFile`) so the human sees your hosted copy and Vessels
|
|
107
|
+
drops its copy → for images, hand the bytes to the model as a **vision** block so your agent actually
|
|
108
|
+
sees them.
|
|
109
|
+
|
|
110
|
+
The zero-infra default stores files in the same `MemoryStore`/`PostgresStore` and serves them from the
|
|
111
|
+
agent's own `GET /files/:id` route, so the resolved link points back at you. Set **`PUBLIC_URL`** to
|
|
112
|
+
your externally-reachable base (your tunnel in dev) so that link is fetchable. In production, point
|
|
113
|
+
`putInboundFile` at object storage (S3/R2/GCS) and return a CDN URL — nothing else changes.
|
|
98
114
|
|
|
99
115
|
A few Vessels features live on **your backend**, not inside a turn — call them on the `vessels`
|
|
100
116
|
SDK directly:
|
|
@@ -11,6 +11,10 @@ ANTHROPIC_API_KEY=sk-ant-...
|
|
|
11
11
|
# ANTHROPIC_MODEL=claude-sonnet-4-6
|
|
12
12
|
# Port the webhook server listens on (default: 3000).
|
|
13
13
|
# PORT=3000
|
|
14
|
+
# Your agent's externally-reachable base URL — where stored INBOUND files are served from
|
|
15
|
+
# (the human's app fetches the link you resolve). In local dev this is your tunnel, the same
|
|
16
|
+
# host as your webhook. Defaults to http://localhost:<PORT> (only reachable on this machine).
|
|
17
|
+
# PUBLIC_URL=https://your-tunnel.example.com
|
|
14
18
|
# Override the Vessels base URL (default: https://vessels.app).
|
|
15
19
|
# VESSELS_BASE_URL=https://vessels.app
|
|
16
20
|
# Set DEBUG=1 to log the turn flow (model calls, tools, pushes).
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* You usually don't edit this file. The pieces worth knowing are flagged inline.
|
|
17
17
|
*/
|
|
18
18
|
import Anthropic from '@anthropic-ai/sdk';
|
|
19
|
-
import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
19
|
+
import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
20
20
|
import { Vessels } from 'vessels-sdk';
|
|
21
|
-
import type { PushOptions, AgentActivityType, AgentTodoStatus } from 'vessels-sdk';
|
|
21
|
+
import type { PushOptions, AgentActivityType, AgentTodoStatus, InboundAttachment } from 'vessels-sdk';
|
|
22
22
|
import { ROLE } from './role.js';
|
|
23
23
|
import { VESSELS_PROTOCOL, NAME_RULE } from './protocol.js';
|
|
24
24
|
import { BACKEND_TOOLS } from './tools.js';
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
cleanAttachments,
|
|
37
37
|
} from './vessels-tools.js';
|
|
38
38
|
import type { AgentStore } from './store.js';
|
|
39
|
+
import { handleInboundFiles, attachImagesToLastUserMessage } from './inbound.js';
|
|
39
40
|
|
|
40
41
|
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
|
41
42
|
const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
|
|
@@ -100,6 +101,7 @@ export interface RunTurnOpts {
|
|
|
100
101
|
openingMessage?: string; // text for the working card (proactive triggers pass the headline)
|
|
101
102
|
vesselTitle?: string; // set on the first push (creates the vessel) for agent-initiated triggers
|
|
102
103
|
nameVessel?: boolean; // freshly-opened vessel with a placeholder title — agent names it (vessel.created)
|
|
104
|
+
attachments?: InboundAttachment[]; // files the human sent — download → store on your infra → resolve → view (inbound.ts)
|
|
103
105
|
}
|
|
104
106
|
|
|
105
107
|
/** A best-effort push that never throws — a failed push must not crash the turn. */
|
|
@@ -123,7 +125,7 @@ async function safePatch(vessels: Vessels, messageId: string, patch: Record<stri
|
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
126
|
-
const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel } = opts;
|
|
128
|
+
const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel, attachments } = opts;
|
|
127
129
|
log('turn_start', { vessel, humanInput: humanInput.slice(0, 120) });
|
|
128
130
|
|
|
129
131
|
// Per-vessel mutex: serialise turns on this vessel. A blocked turn must NOT be dropped —
|
|
@@ -156,7 +158,27 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
156
158
|
// Recover state AFTER the lock: if we waited for a prior turn, its work is now persisted,
|
|
157
159
|
// so we thread on top of it instead of clobbering it.
|
|
158
160
|
const messages: MessageParam[] = await store.loadState(vessel);
|
|
159
|
-
|
|
161
|
+
|
|
162
|
+
// Inbound files (human → agent). Before the model runs, do the handshake for any attachments
|
|
163
|
+
// on this event: download each from the Vessels relay, store it on YOUR infra, resolve the
|
|
164
|
+
// permanent link back (the human now sees it), and collect viewable images as vision blocks.
|
|
165
|
+
// The text note rides the persisted history; the image bytes ride ONLY this live model call.
|
|
166
|
+
let effectiveHumanInput = humanInput;
|
|
167
|
+
let inboundImages: ImageBlockParam[] = [];
|
|
168
|
+
if (attachments?.length) {
|
|
169
|
+
try {
|
|
170
|
+
const inbound = await handleInboundFiles({ vessels, store, attachments });
|
|
171
|
+
inboundImages = inbound.imageBlocks;
|
|
172
|
+
if (inbound.note) effectiveHumanInput = humanInput ? `${humanInput}\n\n${inbound.note}` : inbound.note;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
log('inbound files failed', e instanceof Error ? e.message : e);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
appendHumanTurn(messages, effectiveHumanInput);
|
|
179
|
+
// Attach the downloaded image bytes to the current user turn so the model can SEE them
|
|
180
|
+
// (vision). Only on this live call — the persisted history stays text-only.
|
|
181
|
+
attachImagesToLastUserMessage(messages, inboundImages);
|
|
160
182
|
|
|
161
183
|
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
|
|
162
184
|
const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
|
|
@@ -188,9 +210,15 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
188
210
|
activityId = r.messageId ?? null;
|
|
189
211
|
};
|
|
190
212
|
|
|
191
|
-
// Authoritative todo list for
|
|
213
|
+
// Authoritative todo list for THIS turn's working card; we PATCH the full list each update.
|
|
214
|
+
// ONE CARD PER TURN: always starts empty — the model declares the turn's plan with plan().
|
|
215
|
+
// We never re-attach to a prior turn's card. A card is anchored at one point in the stream;
|
|
216
|
+
// mutating it on a later turn renders its new steps ABOVE messages that already followed it
|
|
217
|
+
// (chronological inversion) and mis-files them under a stale task. So every turn opens its
|
|
218
|
+
// own card at the bottom and seals it on end. Continuity lives in the recovered history.
|
|
192
219
|
type Todo = { label: string; status: AgentTodoStatus };
|
|
193
220
|
let todos: Todo[] = [];
|
|
221
|
+
|
|
194
222
|
const patchActivity = async (body: Record<string, unknown>) => {
|
|
195
223
|
if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
|
|
196
224
|
};
|
|
@@ -270,6 +298,9 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
270
298
|
pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
|
|
271
299
|
} else {
|
|
272
300
|
const interaction = buildInteraction(name, input);
|
|
301
|
+
// keepWorking is purely semantic — it tells the model the task isn't finished, so the
|
|
302
|
+
// NEXT turn continues it in a fresh card. It does NOT keep this card alive: every turn
|
|
303
|
+
// seals its own card on end (one card per turn).
|
|
273
304
|
pushes.push({
|
|
274
305
|
message: msg || String(input.prompt ?? 'Please respond.'),
|
|
275
306
|
kind: 'surface',
|
|
@@ -372,7 +403,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
372
403
|
const input = (tu.input ?? {}) as Record<string, unknown>;
|
|
373
404
|
if (tu.name === 'plan') {
|
|
374
405
|
const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
|
|
375
|
-
|
|
406
|
+
// Merge by label, preserving the status of tasks already in flight — matters on
|
|
407
|
+
// a same-turn re-plan (a tweaked plan keeps the steps it already ticked).
|
|
408
|
+
todos = labels.map((label) => {
|
|
409
|
+
const prev = todos.find((t) => t.label.toLowerCase() === label.toLowerCase());
|
|
410
|
+
return { label, status: prev?.status ?? ('pending' as AgentTodoStatus) };
|
|
411
|
+
});
|
|
376
412
|
await patchActivity({ todos });
|
|
377
413
|
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'plan set' });
|
|
378
414
|
} else if (tu.name === 'step') {
|
|
@@ -472,10 +508,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
472
508
|
log('turn error', err);
|
|
473
509
|
pushes.push({ message: 'I hit a snag and had to stop early.' });
|
|
474
510
|
} finally {
|
|
475
|
-
//
|
|
511
|
+
// ALWAYS seal this turn's working card (one card per turn) — even when the turn ends on
|
|
512
|
+
// a keepWorking checkpoint, the card resolves here as a faithful record of THIS turn's
|
|
513
|
+
// work; the next turn opens its own fresh card. Never orphans, even on an error.
|
|
476
514
|
try {
|
|
477
515
|
await stopStream();
|
|
478
|
-
if (activityId)
|
|
516
|
+
if (activityId) {
|
|
517
|
+
await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
|
|
518
|
+
}
|
|
479
519
|
} catch (e) {
|
|
480
520
|
log('seal failed', e);
|
|
481
521
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INBOUND FILES — the human → agent handshake.
|
|
3
|
+
*
|
|
4
|
+
* When the human attaches a photo/document, Vessels holds the bytes TRANSIENTLY and hands
|
|
5
|
+
* you a signed, short-lived `downloadUrl` on the `message.user` / `vessel.created` event
|
|
6
|
+
* (Vessels is not a file store). You must fetch it, store it on YOUR own infra, and hand
|
|
7
|
+
* back a permanent link — then Vessels swaps in your link and drops its copy. Per file:
|
|
8
|
+
*
|
|
9
|
+
* 1. download — fetch the signed downloadUrl (plain HTTP)
|
|
10
|
+
* 2. store — `store.putInboundFile(...)` → a permanent URL on YOUR infra (store.ts)
|
|
11
|
+
* 3. resolve — `vessels.resolveInboundFile(fileId, url)` so the human sees your copy
|
|
12
|
+
* 4. view — for supported images, hand the bytes to the model as a vision block so
|
|
13
|
+
* your agent can ACTUALLY SEE the image
|
|
14
|
+
*
|
|
15
|
+
* You usually don't edit this file — point `store.putInboundFile` at S3/R2/GCS and you're done.
|
|
16
|
+
*/
|
|
17
|
+
import type { ImageBlockParam, MessageParam, ContentBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
18
|
+
import type { Vessels, InboundAttachment } from 'vessels-sdk';
|
|
19
|
+
import type { AgentStore } from './store.js';
|
|
20
|
+
|
|
21
|
+
// Anthropic vision accepts these as base64 image blocks. Other image kinds and all
|
|
22
|
+
// non-images are still stored + resolved, just not shown to the model inline.
|
|
23
|
+
const VISION_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
24
|
+
const MAX_VISION_BYTES = 4 * 1024 * 1024; // ~Anthropic's practical per-image base64 ceiling
|
|
25
|
+
|
|
26
|
+
function extToMime(filename?: string | null): string | undefined {
|
|
27
|
+
const ext = (filename ?? '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
|
|
28
|
+
switch (ext) {
|
|
29
|
+
case 'jpg':
|
|
30
|
+
case 'jpeg': return 'image/jpeg';
|
|
31
|
+
case 'png': return 'image/png';
|
|
32
|
+
case 'gif': return 'image/gif';
|
|
33
|
+
case 'webp': return 'image/webp';
|
|
34
|
+
case 'pdf': return 'application/pdf';
|
|
35
|
+
default: return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface InboundResult {
|
|
40
|
+
/** Image blocks to append to the model's current user turn (vision). */
|
|
41
|
+
imageBlocks: ImageBlockParam[];
|
|
42
|
+
/** A text note describing what arrived + where it landed, appended to the user turn. */
|
|
43
|
+
note: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the full inbound handshake for every attachment on this event, in parallel. Returns
|
|
48
|
+
* the image blocks to show the model and a note for the user turn. Best-effort per file —
|
|
49
|
+
* one file's failure never sinks the others (or the turn).
|
|
50
|
+
*/
|
|
51
|
+
export async function handleInboundFiles(opts: {
|
|
52
|
+
vessels: Vessels;
|
|
53
|
+
store: AgentStore;
|
|
54
|
+
attachments: InboundAttachment[];
|
|
55
|
+
}): Promise<InboundResult> {
|
|
56
|
+
const pending = opts.attachments.filter((a) => a.fileId && a.downloadUrl);
|
|
57
|
+
if (!pending.length) return { imageBlocks: [], note: '' };
|
|
58
|
+
|
|
59
|
+
const imageBlocks: ImageBlockParam[] = [];
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
|
|
62
|
+
const results = await Promise.all(
|
|
63
|
+
pending.map(async (att) => {
|
|
64
|
+
try {
|
|
65
|
+
// 1 — download from the Vessels relay (signed, short-lived URL).
|
|
66
|
+
const res = await fetch(att.downloadUrl!);
|
|
67
|
+
if (!res.ok) throw new Error(`download ${res.status}`);
|
|
68
|
+
const headerMime = res.headers.get('content-type')?.split(';')[0]?.trim();
|
|
69
|
+
const mime = headerMime && headerMime !== 'application/octet-stream'
|
|
70
|
+
? headerMime
|
|
71
|
+
: extToMime(att.filename) ?? (att.type === 'image' ? 'image/jpeg' : 'application/octet-stream');
|
|
72
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
73
|
+
|
|
74
|
+
// 2 — store on YOUR infra, 3 — resolve the permanent link back to Vessels.
|
|
75
|
+
const url = await opts.store.putInboundFile({ fileId: att.fileId, bytes, contentType: mime, filename: att.filename ?? undefined });
|
|
76
|
+
await opts.vessels.resolveInboundFile(att.fileId, url);
|
|
77
|
+
|
|
78
|
+
// 4 — supported, in-budget image → vision block.
|
|
79
|
+
const viewable = att.type === 'image' && VISION_MIME.has(mime) && bytes.byteLength <= MAX_VISION_BYTES;
|
|
80
|
+
if (viewable) {
|
|
81
|
+
imageBlocks.push({
|
|
82
|
+
type: 'image',
|
|
83
|
+
source: { type: 'base64', media_type: mime as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', data: Buffer.from(bytes).toString('base64') },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return { att, url, viewable, ok: true as const };
|
|
87
|
+
} catch {
|
|
88
|
+
return { att, ok: false as const };
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
for (const r of results) {
|
|
94
|
+
const label = `${r.att.filename ?? r.att.fileId} (${r.att.type})`;
|
|
95
|
+
if (!r.ok) lines.push(`- ${label} → could not be retrieved (the relay link may have expired)`);
|
|
96
|
+
else if (r.viewable) lines.push(`- ${label} → stored at ${r.url}; the image is attached to this message for you to view`);
|
|
97
|
+
else lines.push(`- ${label} → stored at ${r.url}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const one = pending.length === 1;
|
|
101
|
+
const note =
|
|
102
|
+
`[The operator attached ${one ? 'a file' : `${pending.length} files`}. Your backend downloaded ${one ? 'it' : 'them'} from ` +
|
|
103
|
+
`Vessels, stored ${one ? 'it' : 'them'} on your own storage, and confirmed the permanent link(s) back (the operator now ` +
|
|
104
|
+
`sees your hosted file(s)):\n${lines.join('\n')}\n` +
|
|
105
|
+
(imageBlocks.length
|
|
106
|
+
? `The image${imageBlocks.length === 1 ? ' is' : 's are'} included for you to read directly — act on what you actually see, don't guess.`
|
|
107
|
+
: `No previewable image was included; reference the stored file(s) by name.`);
|
|
108
|
+
|
|
109
|
+
return { imageBlocks, note };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Append image blocks to the most recent user message, converting its text to a block array. */
|
|
113
|
+
export function attachImagesToLastUserMessage(messages: MessageParam[], imageBlocks: ImageBlockParam[]): void {
|
|
114
|
+
if (!imageBlocks.length) return;
|
|
115
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
116
|
+
if (messages[i].role !== 'user') continue;
|
|
117
|
+
const existing = messages[i].content;
|
|
118
|
+
const textBlocks: ContentBlockParam[] =
|
|
119
|
+
typeof existing === 'string'
|
|
120
|
+
? existing ? [{ type: 'text', text: existing }] : []
|
|
121
|
+
: (existing as ContentBlockParam[]);
|
|
122
|
+
messages[i] = { role: 'user', content: [...textBlocks, ...imageBlocks] };
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -52,6 +52,21 @@ function runInBackground(work: Promise<unknown>): void {
|
|
|
52
52
|
|
|
53
53
|
const server = http.createServer(async (req, res) => {
|
|
54
54
|
if (req.method === 'GET') {
|
|
55
|
+
// Serve inbound files we stored on our own infra. After the human sends a photo/doc, the
|
|
56
|
+
// engine downloads it from Vessels, stores it here, and resolves a permanent link pointing
|
|
57
|
+
// back at this route (see inbound.ts + store.ts). The app fetches THIS url to render it.
|
|
58
|
+
// In production you'd store in object storage and serve from a CDN instead.
|
|
59
|
+
const fileMatch = req.url && /^\/files\/([^/?]+)/.exec(req.url);
|
|
60
|
+
if (fileMatch) {
|
|
61
|
+
const file = await store.getInboundFile(decodeURIComponent(fileMatch[1])).catch(() => null);
|
|
62
|
+
if (!file) {
|
|
63
|
+
res.writeHead(404).end();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, { 'Content-Type': file.contentType, 'Content-Length': String(file.bytes.byteLength) });
|
|
67
|
+
res.end(Buffer.from(file.bytes));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
55
70
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
56
71
|
res.end('Vessels agent is alive. POST webhook events here.');
|
|
57
72
|
return;
|
|
@@ -89,26 +104,33 @@ const server = http.createServer(async (req, res) => {
|
|
|
89
104
|
vessel,
|
|
90
105
|
humanInput: `${typeNote}${first || '(the operator opened a new vessel)'}`,
|
|
91
106
|
nameVessel: true,
|
|
107
|
+
// Files attached to the first message ride the vessel.created event too.
|
|
108
|
+
attachments: event.attachments,
|
|
92
109
|
idempotencyKeyBase: `vc:${event.message.id}`,
|
|
93
110
|
});
|
|
94
111
|
} else if (event.type === 'message.user') {
|
|
95
112
|
const content = (event.message.content ?? '').trim();
|
|
96
|
-
|
|
113
|
+
// Files the human attached (signed downloadUrls) — the engine does the download → store →
|
|
114
|
+
// resolve handshake before the model runs (inbound.ts). A message can be attachments-only.
|
|
115
|
+
const attachments = event.attachments;
|
|
116
|
+
if (content || attachments?.length) {
|
|
97
117
|
// If this message expired a live interaction, tell the agent so it reacts to what they
|
|
98
118
|
// actually said instead of waiting on the now-dead card.
|
|
99
119
|
const sup = event.supersededInteraction;
|
|
100
|
-
const
|
|
120
|
+
const base = content || '(the operator sent the attached file(s) with no message)';
|
|
121
|
+
const humanInput = sup && content
|
|
101
122
|
? `[The operator did not answer your ${sup.interactionType}${sup.prompt ? ` "${sup.prompt}"` : ''} — that card expired because they sent a message instead. Respond to what they actually said; re-offer or adjust only if it still makes sense.]\n${content}`
|
|
102
|
-
:
|
|
103
|
-
work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
|
|
123
|
+
: base;
|
|
124
|
+
work = runTurn({ vessels, store, vessel, humanInput, attachments, idempotencyKeyBase: `mu:${event.message.id}` });
|
|
104
125
|
}
|
|
105
126
|
} else if (event.type === 'interaction.response') {
|
|
106
|
-
const
|
|
127
|
+
const originInteraction = event.originMessage?.interaction ?? null;
|
|
128
|
+
const prompt = (originInteraction?.prompt as string | undefined) ?? undefined;
|
|
107
129
|
work = runTurn({
|
|
108
130
|
vessels,
|
|
109
131
|
store,
|
|
110
132
|
vessel,
|
|
111
|
-
humanInput: renderInteractionResponse(event.interactionType, event.response, prompt),
|
|
133
|
+
humanInput: renderInteractionResponse(event.interactionType, event.response, prompt, originInteraction),
|
|
112
134
|
idempotencyKeyBase: `ir:${event.id}`,
|
|
113
135
|
});
|
|
114
136
|
}
|
|
@@ -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 task needs the operator's input
|
|
95
|
+
PART-WAY THROUGH (e.g. Draft → Get approval → Send), raise the request_* with
|
|
96
|
+
keepWorking:true. It signals the task ISN'T finished — this turn's working card seals as a
|
|
97
|
+
clean record of what you just did, and when they answer you continue the remaining steps in
|
|
98
|
+
a FRESH card next turn (each turn is its own card, in order). Omit keepWorking on the FINAL
|
|
99
|
+
question/decision, where nothing follows.
|
|
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
|
|
@@ -112,9 +120,20 @@ Flow:
|
|
|
112
120
|
- ONE closing line per turn. The finishing tool's message IS the wrap-up — do NOT also send a
|
|
113
121
|
near-duplicate finish/send_update saying the same thing.
|
|
114
122
|
|
|
123
|
+
FILES the operator sends you (images, PDFs, docs):
|
|
124
|
+
- Your backend has ALREADY handled them before you read this turn: it downloaded each from
|
|
125
|
+
Vessels, stored it on YOUR storage, and confirmed the permanent link back — the operator
|
|
126
|
+
already sees your hosted copy. A bracketed note tells you what arrived and where it landed.
|
|
127
|
+
So NEVER say you "can't receive files" or ask them to re-send — the file is in hand.
|
|
128
|
+
- IMAGES are attached to this turn for you to actually SEE — read what's genuinely there and
|
|
129
|
+
act on it (a receipt → the amount; a form → the fields). Reference real details you observe;
|
|
130
|
+
do NOT invent contents. Non-image files are stored but not shown inline — work from the
|
|
131
|
+
filename and the operator's words.
|
|
132
|
+
|
|
115
133
|
More you can attach (use when they genuinely help — don't decorate):
|
|
116
|
-
- ATTACHMENTS: images render inline, files as a download link. Pass {type, url,
|
|
117
|
-
send_update or show_document — only URLs you already host (e.g. one a backend
|
|
134
|
+
- ATTACHMENTS (outbound): images render inline, files as a download link. Pass {type, url,
|
|
135
|
+
filename?} on send_update or show_document — only URLs you already host (e.g. one a backend
|
|
136
|
+
tool returned, or a stored inbound file).
|
|
118
137
|
- PREVIEW LINK: a single tappable link card under a message (previewUrl) — a draft/dashboard to
|
|
119
138
|
open. Presentation only, no response. Pair it with a request_* when they should look THEN decide.
|
|
120
139
|
- INTERACTION METADATA: attach metadata to any request_* and it rides back to you verbatim in the
|
|
@@ -128,6 +147,11 @@ Be efficient — every assistant turn is a slow round-trip, so do MORE per turn:
|
|
|
128
147
|
- You MUST end with an ending tool (request_* or finish). When you reach the task that needs the
|
|
129
148
|
human, call its work tools AND the request_* tool in the SAME response — do not tick that task
|
|
130
149
|
and stop. If you trail off without an ending tool the turn dies as a bare "Done."
|
|
150
|
+
- Once you've called plan(), a real DECISION you need from the operator goes through a STRUCTURED
|
|
151
|
+
interaction, not prose: a clean either/or is request_choice; a single value is request_text —
|
|
152
|
+
with keepWorking:true when steps remain after the answer. Do NOT write the question as a plain
|
|
153
|
+
message and stop; that strands an unworked plan and denies the operator the action bar. (A bare
|
|
154
|
+
clarifying question with NO plan can still be a quick_reply done:true.)
|
|
131
155
|
- Never repeat a tool call with identical arguments — reuse the result you already have.
|
|
132
156
|
- In task:"…" use the EXACT task label from your plan() — never invent a new name.`;
|
|
133
157
|
|
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* THE STORE SEAM — your agent's runtime state, on YOUR infrastructure.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three things live here, and all are facets of "the agent owns its own runtime":
|
|
5
5
|
* 1. Conversation state — the real Anthropic message history per vessel (including
|
|
6
6
|
* tool_use / tool_result blocks). This is your agent's memory. Vessels is NOT
|
|
7
7
|
* your memory; it only shows the human what happened.
|
|
8
8
|
* 2. A per-vessel lock — "don't run two turns for one vessel at once." That's a
|
|
9
9
|
* property of YOUR deployment, so it lives here too, never in Vessels.
|
|
10
|
+
* 3. Inbound files — when a human sends a photo/document, Vessels relays it
|
|
11
|
+
* TRANSIENTLY and you must store it on your OWN infra, then hand back a permanent
|
|
12
|
+
* link (see inbound.ts). The bytes live here; the link points back at your agent.
|
|
10
13
|
*
|
|
11
14
|
* Durability is an UPGRADE, not a prerequisite:
|
|
12
15
|
* • MemoryStore (default) — zero infra. Correct for a single long-lived process.
|
|
13
|
-
* State
|
|
14
|
-
* • PostgresStore — set DATABASE_URL and you get durable state
|
|
15
|
-
* lock.
|
|
16
|
-
* to run. Horizontally scaled? This is the lock that keeps turns serialised.
|
|
16
|
+
* State + files live in RAM and reset on restart; the lock is an in-process mutex.
|
|
17
|
+
* • PostgresStore — set DATABASE_URL and you get durable state, files, and a
|
|
18
|
+
* cross-process lock. Self-provisions (CREATE TABLE IF NOT EXISTS on init).
|
|
17
19
|
*
|
|
18
|
-
* Swap in Redis/Dynamo/your-DB by implementing the same `AgentStore` interface.
|
|
20
|
+
* Swap in Redis/Dynamo/S3/your-DB by implementing the same `AgentStore` interface.
|
|
21
|
+
* In production you'd typically store inbound files in object storage (S3/R2/GCS) and
|
|
22
|
+
* return a CDN URL from `putInboundFile` — here we serve them off the agent itself.
|
|
19
23
|
*/
|
|
20
24
|
import type { MessageParam } from '@anthropic-ai/sdk/resources/messages';
|
|
21
25
|
|
|
26
|
+
/** A stored inbound file, served back by the agent's own `GET /files/:id` route. */
|
|
27
|
+
export interface StoredFile {
|
|
28
|
+
bytes: Uint8Array;
|
|
29
|
+
contentType: string;
|
|
30
|
+
filename?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The agent's externally-reachable base URL — where resolved inbound files are served
|
|
34
|
+
* from. In local dev this is your tunnel (same host as your webhook). */
|
|
35
|
+
export function publicBaseUrl(): string {
|
|
36
|
+
return (process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 3000}`).replace(/\/$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
export interface AgentStore {
|
|
23
40
|
/** The agent's conversation history for this vessel (empty array if new). */
|
|
24
41
|
loadState(vessel: string): Promise<MessageParam[]>;
|
|
@@ -28,6 +45,11 @@ export interface AgentStore {
|
|
|
28
45
|
acquireLock(vessel: string, ttlSeconds: number): Promise<boolean>;
|
|
29
46
|
/** Release the per-vessel lock. */
|
|
30
47
|
releaseLock(vessel: string): Promise<void>;
|
|
48
|
+
/** Store an inbound file on YOUR infra; returns a permanent, publicly-fetchable URL.
|
|
49
|
+
* (Swap the body for S3/R2/GCS in production and return the CDN URL.) */
|
|
50
|
+
putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string>;
|
|
51
|
+
/** Read a stored inbound file back — the `GET /files/:id` route serves it to the app. */
|
|
52
|
+
getInboundFile(fileId: string): Promise<StoredFile | null>;
|
|
31
53
|
/** Optional one-time setup (e.g. create tables). Called once at boot. */
|
|
32
54
|
init?(): Promise<void>;
|
|
33
55
|
}
|
|
@@ -37,6 +59,7 @@ export interface AgentStore {
|
|
|
37
59
|
export class MemoryStore implements AgentStore {
|
|
38
60
|
private state = new Map<string, MessageParam[]>();
|
|
39
61
|
private locks = new Map<string, number>(); // vessel → expiry (ms epoch)
|
|
62
|
+
private files = new Map<string, StoredFile>();
|
|
40
63
|
|
|
41
64
|
async loadState(vessel: string): Promise<MessageParam[]> {
|
|
42
65
|
return this.state.get(vessel) ?? [];
|
|
@@ -57,6 +80,15 @@ export class MemoryStore implements AgentStore {
|
|
|
57
80
|
async releaseLock(vessel: string): Promise<void> {
|
|
58
81
|
this.locks.delete(vessel);
|
|
59
82
|
}
|
|
83
|
+
|
|
84
|
+
async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
|
|
85
|
+
this.files.set(file.fileId, { bytes: file.bytes, contentType: file.contentType, filename: file.filename });
|
|
86
|
+
return `${publicBaseUrl()}/files/${file.fileId}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getInboundFile(fileId: string): Promise<StoredFile | null> {
|
|
90
|
+
return this.files.get(fileId) ?? null;
|
|
91
|
+
}
|
|
60
92
|
}
|
|
61
93
|
|
|
62
94
|
// ─── PostgresStore — durable, self-provisioning ─────────────────────────────────
|
|
@@ -79,6 +111,13 @@ export class PostgresStore implements AgentStore {
|
|
|
79
111
|
vessel TEXT PRIMARY KEY,
|
|
80
112
|
expires_at TIMESTAMPTZ NOT NULL
|
|
81
113
|
);
|
|
114
|
+
CREATE TABLE IF NOT EXISTS agent_files (
|
|
115
|
+
file_id TEXT PRIMARY KEY,
|
|
116
|
+
bytes BYTEA NOT NULL,
|
|
117
|
+
content_type TEXT NOT NULL,
|
|
118
|
+
filename TEXT,
|
|
119
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
120
|
+
);
|
|
82
121
|
`);
|
|
83
122
|
}
|
|
84
123
|
|
|
@@ -121,6 +160,25 @@ export class PostgresStore implements AgentStore {
|
|
|
121
160
|
async releaseLock(vessel: string): Promise<void> {
|
|
122
161
|
await this.db.query('DELETE FROM agent_locks WHERE vessel = $1', [vessel]);
|
|
123
162
|
}
|
|
163
|
+
|
|
164
|
+
async putInboundFile(file: { fileId: string; bytes: Uint8Array; contentType: string; filename?: string }): Promise<string> {
|
|
165
|
+
await this.db.query(
|
|
166
|
+
`INSERT INTO agent_files (file_id, bytes, content_type, filename)
|
|
167
|
+
VALUES ($1, $2, $3, $4)
|
|
168
|
+
ON CONFLICT (file_id) DO UPDATE SET bytes = EXCLUDED.bytes, content_type = EXCLUDED.content_type, filename = EXCLUDED.filename`,
|
|
169
|
+
[file.fileId, Buffer.from(file.bytes), file.contentType, file.filename ?? null]
|
|
170
|
+
);
|
|
171
|
+
return `${publicBaseUrl()}/files/${file.fileId}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async getInboundFile(fileId: string): Promise<StoredFile | null> {
|
|
175
|
+
const { rows } = await this.db.query<{ bytes: Buffer; content_type: string; filename: string | null }>(
|
|
176
|
+
'SELECT bytes, content_type, filename FROM agent_files WHERE file_id = $1',
|
|
177
|
+
[fileId]
|
|
178
|
+
);
|
|
179
|
+
const r = rows[0];
|
|
180
|
+
return r ? { bytes: new Uint8Array(r.bytes), contentType: r.content_type, filename: r.filename ?? undefined } : null;
|
|
181
|
+
}
|
|
124
182
|
}
|
|
125
183
|
|
|
126
184
|
/**
|
|
@@ -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
|
}
|