vessels 0.6.0 → 0.7.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.
@@ -0,0 +1,533 @@
1
+ /**
2
+ * THE ENGINE — one Vessels turn.
3
+ *
4
+ * This is the reusable, domain-free heart of the template: a Claude tool loop that
5
+ * leads with a reply, opens a live working card, drives your backend tools while
6
+ * ticking a plan, seals the card, and ends with exactly one finishing tool (a
7
+ * message or a human interaction). It talks to Vessels ONLY through the public SDK,
8
+ * exactly as any third-party agent would.
9
+ *
10
+ * What's domain-specific is injected from elsewhere and never appears here:
11
+ * • `role.ts` — who the agent is.
12
+ * • `tools.ts` — what it can do.
13
+ * • `protocol.ts` — how it talks to Vessels (the generic system prompt).
14
+ * • `store.ts` — where its state and lock live (YOUR infra, not Vessels).
15
+ *
16
+ * You usually don't edit this file. The pieces worth knowing are flagged inline.
17
+ */
18
+ import Anthropic from '@anthropic-ai/sdk';
19
+ import type { MessageParam, Tool, ToolUseBlock, TextBlock, ThinkingBlock, ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages';
20
+ import { Vessels } from 'vessels-sdk';
21
+ import type { PushOptions, AgentActivityType, AgentTodoStatus } from 'vessels-sdk';
22
+ import { ROLE } from './role.js';
23
+ import { VESSELS_PROTOCOL, NAME_RULE } from './protocol.js';
24
+ import { BACKEND_TOOLS } from './tools.js';
25
+ import {
26
+ CONTROL_TOOLS,
27
+ ENDING_TOOLS,
28
+ TASK_FIELD,
29
+ buildInteraction,
30
+ defaultNarrate,
31
+ sanitizeCard,
32
+ cleanPreviewUrl,
33
+ cleanTitle,
34
+ cleanSuggestions,
35
+ cleanLabels,
36
+ cleanAttachments,
37
+ } from './vessels-tools.js';
38
+ import type { AgentStore } from './store.js';
39
+
40
+ const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
41
+ const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
42
+ const THINKING_BUDGET = 1024; // API minimum; streamed live into the card then discarded
43
+ const MAX_TOKENS = 4096; // think + response combined
44
+ const TURN_BUDGET_MS = 75_000; // safety cap — stop starting model calls past this and finish gracefully
45
+ const MAX_HISTORY = 80; // cap persisted conversation messages (trimmed at a safe boundary)
46
+ const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
47
+
48
+ /** Lightweight trace — set DEBUG=1 to see the turn flow. Swap for your logger. */
49
+ function log(...args: unknown[]): void {
50
+ if (DEBUG) console.log('[agent]', ...args);
51
+ }
52
+
53
+ // ─── Tool wiring: control tools + your backend tools ────────────────────────────
54
+
55
+ const backendByName = new Map(BACKEND_TOOLS.map((t) => [t.definition.name, t]));
56
+
57
+ /** Inject the plan-advancing `task` field into a backend tool's schema (the engine handles it). */
58
+ function withTaskField(def: Tool): Tool {
59
+ const schema = def.input_schema as { properties?: Record<string, unknown>; [k: string]: unknown };
60
+ return {
61
+ ...def,
62
+ input_schema: { ...schema, properties: { ...(schema.properties ?? {}), task: TASK_FIELD } } as Tool['input_schema'],
63
+ };
64
+ }
65
+
66
+ const ALL_TOOLS: Tool[] = [...CONTROL_TOOLS, ...BACKEND_TOOLS.map((t) => withTaskField(t.definition))];
67
+
68
+ // ─── Conversation history helpers (your store holds the real Anthropic messages) ──
69
+
70
+ /** Append the human's new turn, merging into a trailing user turn so roles stay valid. */
71
+ function appendHumanTurn(messages: MessageParam[], humanInput: string): void {
72
+ const last = messages[messages.length - 1];
73
+ if (last && last.role === 'user') {
74
+ // Prior turn closed with a tool_result user turn — add the human text as a block.
75
+ const block = { type: 'text' as const, text: humanInput };
76
+ last.content = Array.isArray(last.content) ? [...last.content, block] : [{ type: 'text' as const, text: String(last.content) }, block];
77
+ } else {
78
+ messages.push({ role: 'user', content: humanInput });
79
+ }
80
+ }
81
+
82
+ /** Cap history at MAX_HISTORY, slicing only at a plain user message so tool_use/result pairs stay intact. */
83
+ function trimHistory(messages: MessageParam[]): MessageParam[] {
84
+ if (messages.length <= MAX_HISTORY) return messages;
85
+ for (let i = messages.length - MAX_HISTORY; i < messages.length; i++) {
86
+ const m = messages[i];
87
+ if (m.role === 'user' && typeof m.content === 'string') return messages.slice(i);
88
+ }
89
+ return messages; // no safe cut point — keep all rather than corrupt the history
90
+ }
91
+
92
+ // ─── The turn ────────────────────────────────────────────────────────────────
93
+
94
+ export interface RunTurnOpts {
95
+ vessels: Vessels;
96
+ store: AgentStore;
97
+ vessel: string; // external_id to push to
98
+ humanInput: string; // the new user turn (message.user / rendered interaction answer / [SYSTEM EVENT])
99
+ idempotencyKeyBase: string; // e.g. the triggering message id — makes pushes retry-safe
100
+ openingMessage?: string; // text for the working card (proactive triggers pass the headline)
101
+ vesselTitle?: string; // set on the first push (creates the vessel) for agent-initiated triggers
102
+ nameVessel?: boolean; // freshly-opened vessel with a placeholder title — agent names it (vessel.created)
103
+ }
104
+
105
+ /** A best-effort push that never throws — a failed push must not crash the turn. */
106
+ async function safePush(vessels: Vessels, args: PushOptions): Promise<{ messageId?: string }> {
107
+ try {
108
+ const r = await vessels.push(args);
109
+ return { messageId: r.messageId };
110
+ } catch (e) {
111
+ log('push failed', e instanceof Error ? e.message : e);
112
+ return {};
113
+ }
114
+ }
115
+
116
+ /** A best-effort PATCH (live activity / token stream) — never throws. */
117
+ async function safePatch(vessels: Vessels, messageId: string, patch: Record<string, unknown>): Promise<void> {
118
+ try {
119
+ await vessels.editMessage(messageId, patch);
120
+ } catch (e) {
121
+ log('patch failed', e instanceof Error ? e.message : e);
122
+ }
123
+ }
124
+
125
+ export async function runTurn(opts: RunTurnOpts): Promise<void> {
126
+ const { vessels, store, vessel, humanInput, idempotencyKeyBase, openingMessage, vesselTitle, nameVessel } = opts;
127
+ log('turn_start', { vessel, humanInput: humanInput.slice(0, 120) });
128
+
129
+ // Per-vessel mutex: serialise turns on this vessel. A blocked turn must NOT be dropped —
130
+ // the event that triggered it (usually a user message) would go unanswered forever. So we
131
+ // WAIT for the lock, polling until the holder finishes, then run normally.
132
+ const LOCK_TTL = 120;
133
+ const LOCK_WAIT_MS = 80_000;
134
+ const LOCK_POLL_MS = 3_000;
135
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
136
+ const waitDeadline = Date.now() + LOCK_WAIT_MS;
137
+ let gotLock = await store.acquireLock(vessel, LOCK_TTL);
138
+ // Contended → a prior turn is still running. Our lead reply fires only AFTER we hold the
139
+ // lock, so push a one-line "still here" bubble NOW so a queued turn is alive on screen.
140
+ if (!gotLock) {
141
+ await safePush(vessels, {
142
+ vessel,
143
+ message: 'Got it — just finishing the previous step, back in a moment…',
144
+ idempotencyKey: `${idempotencyKeyBase}:queued-ack`,
145
+ });
146
+ }
147
+ while (!gotLock && Date.now() < waitDeadline) {
148
+ await sleep(LOCK_POLL_MS);
149
+ gotLock = await store.acquireLock(vessel, LOCK_TTL);
150
+ }
151
+ if (!gotLock) {
152
+ log('gave up waiting for lock');
153
+ return; // the TTL will free it; rare backstop
154
+ }
155
+
156
+ // Recover state AFTER the lock: if we waited for a prior turn, its work is now persisted,
157
+ // so we thread on top of it instead of clobbering it.
158
+ const messages: MessageParam[] = await store.loadState(vessel);
159
+ appendHumanTurn(messages, humanInput);
160
+
161
+ const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
162
+ const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
163
+
164
+ // ── The working card opens LAZILY — only once the agent commits to real work (a
165
+ // plan/step/backend tool). A turn that just needs a quick message back never opens one.
166
+ const opening = openingMessage ?? 'Working on it…';
167
+ let activityId: string | null = null;
168
+ let cardOpened = false;
169
+ let acknowledged = false; // the agent already sent its "on it" bubble → card needs no message
170
+ let pendingTitle = vesselTitle; // rides whichever push lands first
171
+ let pendingPinCard: ReturnType<typeof sanitizeCard> | undefined;
172
+ let pendingLabels: string[] | undefined;
173
+ const ensureWorkingCard = async (): Promise<void> => {
174
+ if (cardOpened) return;
175
+ cardOpened = true;
176
+ const r = await safePush(vessels, {
177
+ vessel,
178
+ ...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
179
+ ...(pendingPinCard ? { pinCard: pendingPinCard } : {}),
180
+ ...(pendingLabels ? { labels: pendingLabels } : {}),
181
+ ...(acknowledged ? {} : { message: opening }),
182
+ agentActivity: { type: 'thinking', label: 'Getting started' },
183
+ idempotencyKey: `${idempotencyKeyBase}:work`,
184
+ });
185
+ pendingTitle = undefined;
186
+ pendingPinCard = undefined;
187
+ pendingLabels = undefined;
188
+ activityId = r.messageId ?? null;
189
+ };
190
+
191
+ // Authoritative todo list for the working card; we PATCH the full list each update.
192
+ type Todo = { label: string; status: AgentTodoStatus };
193
+ let todos: Todo[] = [];
194
+ const patchActivity = async (body: Record<string, unknown>) => {
195
+ if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
196
+ };
197
+ // Tick the plan to `task`, marking the prior in-progress task done.
198
+ const advanceTodo = async (rawTask: unknown): Promise<boolean> => {
199
+ const task = String(rawTask ?? '').trim();
200
+ const match = task ? todos.find((t) => t.label.toLowerCase() === task.toLowerCase()) : undefined;
201
+ if (!match) return false;
202
+ todos = todos.map((t) => {
203
+ if (t.label.toLowerCase() === match.label.toLowerCase()) return { ...t, status: 'in_progress' as AgentTodoStatus };
204
+ if (t.status === 'in_progress') return { ...t, status: 'done' as AgentTodoStatus };
205
+ return t;
206
+ });
207
+ await patchActivity({ todos });
208
+ return true;
209
+ };
210
+
211
+ // Live token stream → fills the working card's ephemeral monospace block as the model
212
+ // generates, cleared when the turn seals. Throttled, replace-semantics, tail-trimmed.
213
+ const STREAM_THROTTLE_MS = 150;
214
+ const STREAM_WINDOW = 4000;
215
+ let streamBuf = '';
216
+ let streamLast: string | null = null;
217
+ let streamTimer: ReturnType<typeof setTimeout> | null = null;
218
+ let streamPending: Promise<unknown> = Promise.resolve();
219
+ const flushStream = (): Promise<unknown> => {
220
+ if (!activityId) return streamPending;
221
+ const text = streamBuf.length > STREAM_WINDOW ? streamBuf.slice(-STREAM_WINDOW) : streamBuf;
222
+ if (text === streamLast) return streamPending;
223
+ streamLast = text;
224
+ streamPending = vessels.editMessage(activityId, { tokenStream: text }).catch(() => {
225
+ streamLast = null;
226
+ });
227
+ return streamPending;
228
+ };
229
+ const writeStream = (delta: string) => {
230
+ if (!activityId || !delta) return;
231
+ streamBuf += delta;
232
+ if (!streamTimer) {
233
+ streamTimer = setTimeout(() => {
234
+ streamTimer = null;
235
+ void flushStream();
236
+ }, STREAM_THROTTLE_MS);
237
+ }
238
+ };
239
+ const stopStream = async () => {
240
+ if (streamTimer) {
241
+ clearTimeout(streamTimer);
242
+ streamTimer = null;
243
+ }
244
+ await streamPending.catch(() => {});
245
+ };
246
+
247
+ // pushes made this turn (product messages — the human-facing output)
248
+ type PendingPush = {
249
+ message: string;
250
+ kind?: 'bubble' | 'surface';
251
+ title?: string;
252
+ card?: unknown;
253
+ pinCard?: unknown;
254
+ labels?: string[];
255
+ interaction?: Record<string, unknown> | null;
256
+ suggestions?: string[];
257
+ previewUrl?: string;
258
+ attachments?: Array<{ type: 'image' | 'file'; url: string; filename?: string }>;
259
+ };
260
+ const pushes: PendingPush[] = [];
261
+
262
+ let ended = false;
263
+ const recordEnding = (name: string, input: Record<string, unknown>) => {
264
+ const msg = String(input.message ?? '');
265
+ const endPin = sanitizeCard(input.pinCard);
266
+ const endLabels = cleanLabels(input.labels);
267
+ const endTitle = cleanTitle(input.vesselTitle);
268
+ if (endTitle && !pendingTitle) pendingTitle = endTitle;
269
+ if (name === 'finish') {
270
+ pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
271
+ } else {
272
+ const interaction = buildInteraction(name, input);
273
+ pushes.push({
274
+ message: msg || String(input.prompt ?? 'Please respond.'),
275
+ kind: 'surface',
276
+ title: cleanTitle(input.title),
277
+ card: sanitizeCard(input.card, input),
278
+ pinCard: endPin,
279
+ labels: endLabels,
280
+ interaction,
281
+ previewUrl: cleanPreviewUrl(input.previewUrl),
282
+ });
283
+ }
284
+ ended = true;
285
+ };
286
+
287
+ const turnStart = Date.now();
288
+ let outOfTime = false;
289
+ try {
290
+ for (let step = 0; step < MAX_TURN_STEPS; step++) {
291
+ if (Date.now() - turnStart > TURN_BUDGET_MS) {
292
+ outOfTime = true;
293
+ break;
294
+ }
295
+
296
+ log('model_call', { step });
297
+ const ms = anthropic.messages.stream({
298
+ model: MODEL,
299
+ max_tokens: MAX_TOKENS,
300
+ thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET },
301
+ system: systemPrompt,
302
+ tools: ALL_TOOLS,
303
+ messages,
304
+ });
305
+ ms.on('thinking', (delta) => writeStream(delta));
306
+ ms.on('text', (delta) => writeStream(delta));
307
+ ms.on('error', () => {});
308
+ const resp = await ms.finalMessage();
309
+
310
+ messages.push({ role: 'assistant', content: resp.content });
311
+
312
+ const toolUses = resp.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
313
+ if (DEBUG) {
314
+ const thinking = resp.content.filter((b): b is ThinkingBlock => b.type === 'thinking').map((b) => b.thinking).join('\n');
315
+ log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name), thinking: thinking.slice(0, 200) });
316
+ }
317
+
318
+ // No tool call — treat any plain text as a final message and stop.
319
+ if (toolUses.length === 0) {
320
+ const text = resp.content.filter((b): b is TextBlock => b.type === 'text').map((b) => b.text).join('\n').trim();
321
+ if (text) pushes.push({ message: text });
322
+ ended = true;
323
+ break;
324
+ }
325
+
326
+ const ending = toolUses.find((t) => ENDING_TOOLS.has(t.name));
327
+ const toolResults: ToolResultBlockParam[] = [];
328
+
329
+ // Lift vesselTitle / triage off THIS turn's plan() BEFORE the lead push, so they ride
330
+ // the first push that lands (no extra model round-trip).
331
+ const planTool = toolUses.find((t) => t.name === 'plan');
332
+ if (planTool) {
333
+ const pIn = (planTool.input ?? {}) as Record<string, unknown>;
334
+ if (!pendingTitle) {
335
+ const named = cleanTitle(pIn.vesselTitle);
336
+ if (named) pendingTitle = named;
337
+ }
338
+ if (!pendingPinCard) pendingPinCard = sanitizeCard(pIn.pinCard);
339
+ if (!pendingLabels) pendingLabels = cleanLabels(pIn.labels);
340
+ }
341
+
342
+ // The lead reply: quick_reply opens every turn. Pushed FIRST, before the card. The turn
343
+ // ends here ONLY on an EXPLICIT done:true — never inferred from the absence of other tools.
344
+ const qrTool = toolUses.find((t) => t.name === 'quick_reply');
345
+ if (qrTool) {
346
+ const qrIn = (qrTool.input ?? {}) as Record<string, unknown>;
347
+ const qrMsg = String(qrIn.message ?? '').trim();
348
+ const qrDone = qrIn.done === true;
349
+ const qrTitle = cleanTitle(qrIn.vesselTitle);
350
+ if (qrTitle && !pendingTitle) pendingTitle = qrTitle;
351
+ if (!qrDone) {
352
+ await safePush(vessels, {
353
+ vessel,
354
+ ...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
355
+ message: qrMsg || 'On it.',
356
+ idempotencyKey: `${idempotencyKeyBase}:lead`,
357
+ });
358
+ pendingTitle = undefined;
359
+ acknowledged = true;
360
+ } else {
361
+ pushes.push({ message: qrMsg || 'Could you clarify?' });
362
+ ended = true;
363
+ }
364
+ toolResults.push({ type: 'tool_result', tool_use_id: qrTool.id, content: 'sent' });
365
+ }
366
+
367
+ // Real work opens the working card now, lazily. A quick_reply-only / finish-only turn skips this.
368
+ if (toolUses.some((t) => !ENDING_TOOLS.has(t.name) && t.name !== 'quick_reply')) await ensureWorkingCard();
369
+
370
+ for (const tu of toolUses) {
371
+ if (ENDING_TOOLS.has(tu.name) || tu.name === 'quick_reply') continue; // handled above / after loop
372
+ const input = (tu.input ?? {}) as Record<string, unknown>;
373
+ if (tu.name === 'plan') {
374
+ const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
375
+ todos = labels.map((label) => ({ label, status: 'pending' as AgentTodoStatus }));
376
+ await patchActivity({ todos });
377
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'plan set' });
378
+ } else if (tu.name === 'step') {
379
+ await advanceTodo(input.task);
380
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'on it' });
381
+ } else if (tu.name === 'send_update') {
382
+ if (input.task) await advanceTodo(input.task);
383
+ const msg = String(input.message ?? '');
384
+ if (msg)
385
+ pushes.push({
386
+ message: msg,
387
+ card: sanitizeCard(input.card, input),
388
+ suggestions: cleanSuggestions(input.suggestions),
389
+ attachments: cleanAttachments(input.attachments),
390
+ previewUrl: cleanPreviewUrl(input.previewUrl),
391
+ });
392
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'posted' });
393
+ } else if (tu.name === 'show_document') {
394
+ const title = cleanTitle(input.title);
395
+ const body = String(input.body ?? '').trim();
396
+ const docPin = sanitizeCard(input.pinCard);
397
+ const docLabels = cleanLabels(input.labels);
398
+ if (body) {
399
+ pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input), pinCard: docPin, labels: docLabels, attachments: cleanAttachments(input.attachments) });
400
+ } else if (docPin || docLabels) {
401
+ if (docPin) pendingPinCard = docPin;
402
+ if (docLabels) pendingLabels = docLabels;
403
+ }
404
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'shown' });
405
+ } else if (backendByName.has(tu.name)) {
406
+ // YOUR tool. Tick the plan (task field), run the handler, auto-narrate a step.
407
+ if (input.task) await advanceTodo(input.task);
408
+ const { task: _task, ...callInput } = input;
409
+ const tool = backendByName.get(tu.name)!;
410
+ let result: unknown;
411
+ try {
412
+ result = await tool.handler(callInput);
413
+ } catch (e) {
414
+ // A throwing tool must not crash the turn — return the failure so the model reacts.
415
+ result = { ok: false, error: e instanceof Error ? e.message : String(e) };
416
+ log('backend tool threw', tu.name, e);
417
+ }
418
+ const narr = tool.narrate ? tool.narrate(callInput, result) : defaultNarrate(tu.name);
419
+ await patchActivity({ type: narr.type satisfies AgentActivityType, label: narr.label });
420
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) });
421
+ } else {
422
+ toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: `Unknown tool: ${tu.name}`, is_error: true });
423
+ }
424
+ }
425
+
426
+ // Close out EVERY tool_use with a result so the persisted history stays well-formed
427
+ // (an ending tool gets a synthetic ack — we break before its real result would matter).
428
+ const answered = new Set(toolResults.map((r) => r.tool_use_id));
429
+ for (const tu of toolUses) if (!answered.has(tu.id)) toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'acknowledged' });
430
+ messages.push({ role: 'user', content: toolResults });
431
+
432
+ if (ending) {
433
+ recordEnding(ending.name, (ending.input ?? {}) as Record<string, unknown>);
434
+ break;
435
+ }
436
+ if (ended) break; // quick_reply({done:true}) settled the turn
437
+ }
438
+
439
+ // Ran out of steps or budget without an ending tool — force ONE closing turn so an intended
440
+ // question/decision is never silently downgraded to "Done."
441
+ if (!ended) {
442
+ log('forced_ending', { reason: outOfTime ? 'budget' : 'out_of_steps' });
443
+ const stop = outOfTime
444
+ ? 'STOP — you are out of time. Close the turn NOW with the SINGLE most important ending tool: if you were about to ask the operator something, raise THAT request_* now; otherwise finish. Call no other tool.'
445
+ : 'STOP — you are out of working steps. Respond with EXACTLY ONE ending tool NOW: request_approval/choice/checklist/text if the human must decide, otherwise finish. Call no other tool.';
446
+ try {
447
+ const fr = await anthropic.messages.create({
448
+ model: MODEL,
449
+ max_tokens: 768,
450
+ system: `${systemPrompt}\n\n${stop}`,
451
+ tools: ALL_TOOLS,
452
+ tool_choice: { type: 'any' },
453
+ messages,
454
+ });
455
+ const forced = fr.content.find((b): b is ToolUseBlock => b.type === 'tool_use' && ENDING_TOOLS.has(b.name));
456
+ if (forced) {
457
+ recordEnding(forced.name, (forced.input ?? {}) as Record<string, unknown>);
458
+ // Keep history well-formed: record the forced assistant turn + a synthetic result.
459
+ messages.push({ role: 'assistant', content: fr.content });
460
+ const uses = fr.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
461
+ messages.push({ role: 'user', content: uses.map((u) => ({ type: 'tool_result' as const, tool_use_id: u.id, content: 'acknowledged' })) });
462
+ }
463
+ } catch (e) {
464
+ log('forced_ending failed', e instanceof Error ? e.message : e);
465
+ }
466
+ if (!ended) {
467
+ pushes.push({ message: "Here's where I've got to — I'll pause here." });
468
+ ended = true;
469
+ }
470
+ }
471
+ } catch (err) {
472
+ log('turn error', err);
473
+ pushes.push({ message: 'I hit a snag and had to stop early.' });
474
+ } finally {
475
+ // Guarantee the seal — the working card this turn opened MUST resolve, even on an error.
476
+ try {
477
+ await stopStream();
478
+ if (activityId) await safePatch(vessels, activityId, { agentActivity: null, tokenStream: null });
479
+ } catch (e) {
480
+ log('seal failed', e);
481
+ }
482
+
483
+ if (pushes.length === 0) pushes.push({ message: 'Done.' });
484
+
485
+ // Deliver the human-facing pushes. State is saved separately (below), so a failed push
486
+ // never loses conversation context — it only drops a message.
487
+ for (let i = 0; i < pushes.length; i++) {
488
+ const p = pushes[i];
489
+ const isLast = i === pushes.length - 1;
490
+ const titleForThisPush = i === 0 && pendingTitle ? pendingTitle : undefined;
491
+ if (titleForThisPush) pendingTitle = undefined;
492
+ const pinForThisPush = p.pinCard ?? (i === 0 ? pendingPinCard : undefined);
493
+ const labelsForThisPush = p.labels ?? (i === 0 ? pendingLabels : undefined);
494
+ if (i === 0) {
495
+ pendingPinCard = undefined;
496
+ pendingLabels = undefined;
497
+ }
498
+ const r = await safePush(vessels, {
499
+ vessel,
500
+ ...(titleForThisPush ? { vesselTitle: titleForThisPush } : {}),
501
+ message: p.message,
502
+ kind: p.kind,
503
+ title: p.title,
504
+ card: p.card as PushOptions['card'],
505
+ ...(pinForThisPush ? { pinCard: pinForThisPush as PushOptions['pinCard'] } : {}),
506
+ ...(labelsForThisPush ? { labels: labelsForThisPush } : {}),
507
+ interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
508
+ suggestions: p.suggestions,
509
+ previewUrl: p.previewUrl,
510
+ attachments: p.attachments,
511
+ idempotencyKey: `${idempotencyKeyBase}:${i}`,
512
+ });
513
+ // A model-malformed optional field can still get the push rejected. The last push is the
514
+ // real answer/interaction — retry it stripped to the essentials the agent fully controls.
515
+ if (!r.messageId && isLast) {
516
+ await safePush(vessels, {
517
+ vessel,
518
+ message: p.message,
519
+ interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
520
+ idempotencyKey: `${idempotencyKeyBase}:${i}:retry`,
521
+ });
522
+ }
523
+ }
524
+
525
+ // Persist the conversation BEFORE releasing the lock, so a waiting turn threads on it.
526
+ try {
527
+ await store.saveState(vessel, trimHistory(messages));
528
+ } catch (e) {
529
+ log('saveState failed', e);
530
+ }
531
+ await store.releaseLock(vessel);
532
+ }
533
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Your agent's webhook server.
3
+ *
4
+ * Vessels POSTs an event here when the human acts (opens a vessel, sends a message,
5
+ * answers an interaction). We verify the signature, ACK 200 immediately (a Claude tool
6
+ * loop far exceeds the ~10s webhook timeout — never run it inline), and run the turn in
7
+ * the background. The engine pushes everything the human sees back through the SDK.
8
+ *
9
+ * This is a zero-dependency node:http server so it runs anywhere with no framework.
10
+ * Prefer Express/Hono/Next? Keep `parseWebhookEvent → ACK → runTurn` and swap the shell.
11
+ *
12
+ * ┌── deployment note ─────────────────────────────────────────────────────────┐
13
+ * │ This long-lived process keeps running after the 200, so background work just │
14
+ * │ finishes. On serverless (Lambda/Vercel/Workers) the process may freeze after │
15
+ * │ the response — there you must await the turn or use the platform's background │
16
+ * │ primitive (e.g. waitUntil) so it isn't killed mid-turn. │
17
+ * └────────────────────────────────────────────────────────────────────────────┘
18
+ */
19
+ import 'dotenv/config';
20
+ import http from 'node:http';
21
+ import { Vessels } from 'vessels-sdk';
22
+ import { createStore, type AgentStore } from './store.js';
23
+ import { runTurn } from './agent.js';
24
+ import { renderInteractionResponse } from './vessels-tools.js';
25
+
26
+ const API_KEY = process.env.VESSELS_API_KEY;
27
+ const WEBHOOK_SECRET = process.env.VESSELS_WEBHOOK_SECRET;
28
+ const PORT = Number(process.env.PORT || 3000);
29
+
30
+ if (!API_KEY || !WEBHOOK_SECRET || !process.env.ANTHROPIC_API_KEY) {
31
+ console.error('Missing env. Set VESSELS_API_KEY, VESSELS_WEBHOOK_SECRET, ANTHROPIC_API_KEY (copy .env.example → .env).');
32
+ process.exit(1);
33
+ }
34
+
35
+ const vessels = new Vessels({ apiKey: API_KEY, baseUrl: process.env.VESSELS_BASE_URL });
36
+ let store: AgentStore;
37
+
38
+ /** Read the raw request body (we need the exact bytes to verify the HMAC signature). */
39
+ function readBody(req: http.IncomingMessage): Promise<string> {
40
+ return new Promise((resolve, reject) => {
41
+ let data = '';
42
+ req.on('data', (c) => (data += c));
43
+ req.on('end', () => resolve(data));
44
+ req.on('error', reject);
45
+ });
46
+ }
47
+
48
+ /** Fire-and-forget: this process is long-lived, so the turn just finishes after we ACK. */
49
+ function runInBackground(work: Promise<unknown>): void {
50
+ void Promise.resolve(work).catch((err) => console.error('[agent] background error', err));
51
+ }
52
+
53
+ const server = http.createServer(async (req, res) => {
54
+ if (req.method === 'GET') {
55
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
56
+ res.end('Vessels agent is alive. POST webhook events here.');
57
+ return;
58
+ }
59
+ if (req.method !== 'POST') {
60
+ res.writeHead(405).end();
61
+ return;
62
+ }
63
+
64
+ const raw = await readBody(req);
65
+ const signature = req.headers['x-vessels-signature'];
66
+ const event = await vessels.parseWebhookEvent(raw, Array.isArray(signature) ? signature[0] : signature ?? '', WEBHOOK_SECRET);
67
+
68
+ if (!event) {
69
+ res.writeHead(401, { 'Content-Type': 'application/json' });
70
+ res.end(JSON.stringify({ ok: false, error: 'Invalid signature or unknown event' }));
71
+ return;
72
+ }
73
+
74
+ const vessel = event.vessel.externalId ?? event.vessel.id;
75
+
76
+ // Build the turn for this event. ACK first, then run it in the background.
77
+ let work: Promise<void> | null = null;
78
+
79
+ if (event.type === 'vessel.created') {
80
+ // The human opened a new vessel from the app — it has a placeholder title; the agent
81
+ // names it on its first plan(). `message.content` is the first thing they typed.
82
+ // `vessel.type` is the type they chose (Ticket / Enquiry / …) — yours to interpret; pass
83
+ // it in so the agent can route on it. Enable types with `vessels types enable/add`.
84
+ const first = (event.message.content ?? '').trim();
85
+ const typeNote = event.vessel.type ? `[New ${event.vessel.type} vessel] ` : '';
86
+ work = runTurn({
87
+ vessels,
88
+ store,
89
+ vessel,
90
+ humanInput: `${typeNote}${first || '(the operator opened a new vessel)'}`,
91
+ nameVessel: true,
92
+ idempotencyKeyBase: `vc:${event.message.id}`,
93
+ });
94
+ } else if (event.type === 'message.user') {
95
+ const content = (event.message.content ?? '').trim();
96
+ if (content) {
97
+ // If this message expired a live interaction, tell the agent so it reacts to what they
98
+ // actually said instead of waiting on the now-dead card.
99
+ const sup = event.supersededInteraction;
100
+ const humanInput = sup
101
+ ? `[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
+ : content;
103
+ work = runTurn({ vessels, store, vessel, humanInput, idempotencyKeyBase: `mu:${event.message.id}` });
104
+ }
105
+ } else if (event.type === 'interaction.response') {
106
+ const prompt = (event.originMessage?.interaction?.prompt as string | undefined) ?? undefined;
107
+ work = runTurn({
108
+ vessels,
109
+ store,
110
+ vessel,
111
+ humanInput: renderInteractionResponse(event.interactionType, event.response, prompt),
112
+ idempotencyKeyBase: `ir:${event.id}`,
113
+ });
114
+ }
115
+ // event.type === 'message.cancelled' → nothing long-running to abort here; just ACK.
116
+
117
+ res.writeHead(200, { 'Content-Type': 'application/json' });
118
+ res.end(JSON.stringify({ ok: true }));
119
+ if (work) runInBackground(work);
120
+ });
121
+
122
+ createStore()
123
+ .then((s) => {
124
+ store = s;
125
+ server.listen(PORT, () => console.log(`Vessels agent listening on :${PORT}`));
126
+ })
127
+ .catch((err) => {
128
+ console.error('[agent] failed to start', err);
129
+ process.exit(1);
130
+ });