vessels 0.6.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 +134 -27
- package/package.json +29 -23
- package/template/agent/README.md +111 -0
- package/template/agent/_env.example +23 -0
- package/template/agent/_gitignore +4 -0
- package/template/agent/package.json +24 -0
- package/template/agent/src/agent.ts +564 -0
- package/template/agent/src/index.ts +131 -0
- package/template/agent/src/protocol.ts +148 -0
- package/template/agent/src/role.ts +21 -0
- package/template/agent/src/store.ts +185 -0
- package/template/agent/src/tools.ts +90 -0
- package/template/agent/src/vessels-tools.ts +545 -0
- package/template/agent/tsconfig.json +17 -0
|
@@ -0,0 +1,564 @@
|
|
|
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, ResumeMarker } 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
|
+
// 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);
|
|
163
|
+
|
|
164
|
+
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY, timeout: 45_000, maxRetries: 1 });
|
|
165
|
+
const systemPrompt = `${ROLE}\n\n${VESSELS_PROTOCOL}${nameVessel ? NAME_RULE : ''}`;
|
|
166
|
+
|
|
167
|
+
// ── The working card opens LAZILY — only once the agent commits to real work (a
|
|
168
|
+
// plan/step/backend tool). A turn that just needs a quick message back never opens one.
|
|
169
|
+
const opening = openingMessage ?? 'Working on it…';
|
|
170
|
+
let activityId: string | null = null;
|
|
171
|
+
let cardOpened = false;
|
|
172
|
+
let acknowledged = false; // the agent already sent its "on it" bubble → card needs no message
|
|
173
|
+
let pendingTitle = vesselTitle; // rides whichever push lands first
|
|
174
|
+
let pendingPinCard: ReturnType<typeof sanitizeCard> | undefined;
|
|
175
|
+
let pendingLabels: string[] | undefined;
|
|
176
|
+
const ensureWorkingCard = async (): Promise<void> => {
|
|
177
|
+
if (cardOpened) return;
|
|
178
|
+
cardOpened = true;
|
|
179
|
+
const r = await safePush(vessels, {
|
|
180
|
+
vessel,
|
|
181
|
+
...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
|
|
182
|
+
...(pendingPinCard ? { pinCard: pendingPinCard } : {}),
|
|
183
|
+
...(pendingLabels ? { labels: pendingLabels } : {}),
|
|
184
|
+
...(acknowledged ? {} : { message: opening }),
|
|
185
|
+
agentActivity: { type: 'thinking', label: 'Getting started' },
|
|
186
|
+
idempotencyKey: `${idempotencyKeyBase}:work`,
|
|
187
|
+
});
|
|
188
|
+
pendingTitle = undefined;
|
|
189
|
+
pendingPinCard = undefined;
|
|
190
|
+
pendingLabels = undefined;
|
|
191
|
+
activityId = r.messageId ?? null;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Authoritative todo list for the working card; we PATCH the full list each update.
|
|
195
|
+
type Todo = { label: string; status: AgentTodoStatus };
|
|
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
|
+
}
|
|
207
|
+
const patchActivity = async (body: Record<string, unknown>) => {
|
|
208
|
+
if (activityId) await safePatch(vessels, activityId, { agentActivity: body });
|
|
209
|
+
};
|
|
210
|
+
// Tick the plan to `task`, marking the prior in-progress task done.
|
|
211
|
+
const advanceTodo = async (rawTask: unknown): Promise<boolean> => {
|
|
212
|
+
const task = String(rawTask ?? '').trim();
|
|
213
|
+
const match = task ? todos.find((t) => t.label.toLowerCase() === task.toLowerCase()) : undefined;
|
|
214
|
+
if (!match) return false;
|
|
215
|
+
todos = todos.map((t) => {
|
|
216
|
+
if (t.label.toLowerCase() === match.label.toLowerCase()) return { ...t, status: 'in_progress' as AgentTodoStatus };
|
|
217
|
+
if (t.status === 'in_progress') return { ...t, status: 'done' as AgentTodoStatus };
|
|
218
|
+
return t;
|
|
219
|
+
});
|
|
220
|
+
await patchActivity({ todos });
|
|
221
|
+
return true;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Live token stream → fills the working card's ephemeral monospace block as the model
|
|
225
|
+
// generates, cleared when the turn seals. Throttled, replace-semantics, tail-trimmed.
|
|
226
|
+
const STREAM_THROTTLE_MS = 150;
|
|
227
|
+
const STREAM_WINDOW = 4000;
|
|
228
|
+
let streamBuf = '';
|
|
229
|
+
let streamLast: string | null = null;
|
|
230
|
+
let streamTimer: ReturnType<typeof setTimeout> | null = null;
|
|
231
|
+
let streamPending: Promise<unknown> = Promise.resolve();
|
|
232
|
+
const flushStream = (): Promise<unknown> => {
|
|
233
|
+
if (!activityId) return streamPending;
|
|
234
|
+
const text = streamBuf.length > STREAM_WINDOW ? streamBuf.slice(-STREAM_WINDOW) : streamBuf;
|
|
235
|
+
if (text === streamLast) return streamPending;
|
|
236
|
+
streamLast = text;
|
|
237
|
+
streamPending = vessels.editMessage(activityId, { tokenStream: text }).catch(() => {
|
|
238
|
+
streamLast = null;
|
|
239
|
+
});
|
|
240
|
+
return streamPending;
|
|
241
|
+
};
|
|
242
|
+
const writeStream = (delta: string) => {
|
|
243
|
+
if (!activityId || !delta) return;
|
|
244
|
+
streamBuf += delta;
|
|
245
|
+
if (!streamTimer) {
|
|
246
|
+
streamTimer = setTimeout(() => {
|
|
247
|
+
streamTimer = null;
|
|
248
|
+
void flushStream();
|
|
249
|
+
}, STREAM_THROTTLE_MS);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const stopStream = async () => {
|
|
253
|
+
if (streamTimer) {
|
|
254
|
+
clearTimeout(streamTimer);
|
|
255
|
+
streamTimer = null;
|
|
256
|
+
}
|
|
257
|
+
await streamPending.catch(() => {});
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// pushes made this turn (product messages — the human-facing output)
|
|
261
|
+
type PendingPush = {
|
|
262
|
+
message: string;
|
|
263
|
+
kind?: 'bubble' | 'surface';
|
|
264
|
+
title?: string;
|
|
265
|
+
card?: unknown;
|
|
266
|
+
pinCard?: unknown;
|
|
267
|
+
labels?: string[];
|
|
268
|
+
interaction?: Record<string, unknown> | null;
|
|
269
|
+
suggestions?: string[];
|
|
270
|
+
previewUrl?: string;
|
|
271
|
+
attachments?: Array<{ type: 'image' | 'file'; url: string; filename?: string }>;
|
|
272
|
+
};
|
|
273
|
+
const pushes: PendingPush[] = [];
|
|
274
|
+
|
|
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;
|
|
279
|
+
const recordEnding = (name: string, input: Record<string, unknown>) => {
|
|
280
|
+
const msg = String(input.message ?? '');
|
|
281
|
+
const endPin = sanitizeCard(input.pinCard);
|
|
282
|
+
const endLabels = cleanLabels(input.labels);
|
|
283
|
+
const endTitle = cleanTitle(input.vesselTitle);
|
|
284
|
+
if (endTitle && !pendingTitle) pendingTitle = endTitle;
|
|
285
|
+
if (name === 'finish') {
|
|
286
|
+
pushes.push({ message: msg || 'All done.', pinCard: endPin, labels: endLabels });
|
|
287
|
+
} else {
|
|
288
|
+
const interaction = buildInteraction(name, input);
|
|
289
|
+
if (input.keepWorking === true) pauseForInput = true;
|
|
290
|
+
pushes.push({
|
|
291
|
+
message: msg || String(input.prompt ?? 'Please respond.'),
|
|
292
|
+
kind: 'surface',
|
|
293
|
+
title: cleanTitle(input.title),
|
|
294
|
+
card: sanitizeCard(input.card, input),
|
|
295
|
+
pinCard: endPin,
|
|
296
|
+
labels: endLabels,
|
|
297
|
+
interaction,
|
|
298
|
+
previewUrl: cleanPreviewUrl(input.previewUrl),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
ended = true;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const turnStart = Date.now();
|
|
305
|
+
let outOfTime = false;
|
|
306
|
+
try {
|
|
307
|
+
for (let step = 0; step < MAX_TURN_STEPS; step++) {
|
|
308
|
+
if (Date.now() - turnStart > TURN_BUDGET_MS) {
|
|
309
|
+
outOfTime = true;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
log('model_call', { step });
|
|
314
|
+
const ms = anthropic.messages.stream({
|
|
315
|
+
model: MODEL,
|
|
316
|
+
max_tokens: MAX_TOKENS,
|
|
317
|
+
thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET },
|
|
318
|
+
system: systemPrompt,
|
|
319
|
+
tools: ALL_TOOLS,
|
|
320
|
+
messages,
|
|
321
|
+
});
|
|
322
|
+
ms.on('thinking', (delta) => writeStream(delta));
|
|
323
|
+
ms.on('text', (delta) => writeStream(delta));
|
|
324
|
+
ms.on('error', () => {});
|
|
325
|
+
const resp = await ms.finalMessage();
|
|
326
|
+
|
|
327
|
+
messages.push({ role: 'assistant', content: resp.content });
|
|
328
|
+
|
|
329
|
+
const toolUses = resp.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
330
|
+
if (DEBUG) {
|
|
331
|
+
const thinking = resp.content.filter((b): b is ThinkingBlock => b.type === 'thinking').map((b) => b.thinking).join('\n');
|
|
332
|
+
log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name), thinking: thinking.slice(0, 200) });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// No tool call — treat any plain text as a final message and stop.
|
|
336
|
+
if (toolUses.length === 0) {
|
|
337
|
+
const text = resp.content.filter((b): b is TextBlock => b.type === 'text').map((b) => b.text).join('\n').trim();
|
|
338
|
+
if (text) pushes.push({ message: text });
|
|
339
|
+
ended = true;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ending = toolUses.find((t) => ENDING_TOOLS.has(t.name));
|
|
344
|
+
const toolResults: ToolResultBlockParam[] = [];
|
|
345
|
+
|
|
346
|
+
// Lift vesselTitle / triage off THIS turn's plan() BEFORE the lead push, so they ride
|
|
347
|
+
// the first push that lands (no extra model round-trip).
|
|
348
|
+
const planTool = toolUses.find((t) => t.name === 'plan');
|
|
349
|
+
if (planTool) {
|
|
350
|
+
const pIn = (planTool.input ?? {}) as Record<string, unknown>;
|
|
351
|
+
if (!pendingTitle) {
|
|
352
|
+
const named = cleanTitle(pIn.vesselTitle);
|
|
353
|
+
if (named) pendingTitle = named;
|
|
354
|
+
}
|
|
355
|
+
if (!pendingPinCard) pendingPinCard = sanitizeCard(pIn.pinCard);
|
|
356
|
+
if (!pendingLabels) pendingLabels = cleanLabels(pIn.labels);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The lead reply: quick_reply opens every turn. Pushed FIRST, before the card. The turn
|
|
360
|
+
// ends here ONLY on an EXPLICIT done:true — never inferred from the absence of other tools.
|
|
361
|
+
const qrTool = toolUses.find((t) => t.name === 'quick_reply');
|
|
362
|
+
if (qrTool) {
|
|
363
|
+
const qrIn = (qrTool.input ?? {}) as Record<string, unknown>;
|
|
364
|
+
const qrMsg = String(qrIn.message ?? '').trim();
|
|
365
|
+
const qrDone = qrIn.done === true;
|
|
366
|
+
const qrTitle = cleanTitle(qrIn.vesselTitle);
|
|
367
|
+
if (qrTitle && !pendingTitle) pendingTitle = qrTitle;
|
|
368
|
+
if (!qrDone) {
|
|
369
|
+
await safePush(vessels, {
|
|
370
|
+
vessel,
|
|
371
|
+
...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
|
|
372
|
+
message: qrMsg || 'On it.',
|
|
373
|
+
idempotencyKey: `${idempotencyKeyBase}:lead`,
|
|
374
|
+
});
|
|
375
|
+
pendingTitle = undefined;
|
|
376
|
+
acknowledged = true;
|
|
377
|
+
} else {
|
|
378
|
+
pushes.push({ message: qrMsg || 'Could you clarify?' });
|
|
379
|
+
ended = true;
|
|
380
|
+
}
|
|
381
|
+
toolResults.push({ type: 'tool_result', tool_use_id: qrTool.id, content: 'sent' });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Real work opens the working card now, lazily. A quick_reply-only / finish-only turn skips this.
|
|
385
|
+
if (toolUses.some((t) => !ENDING_TOOLS.has(t.name) && t.name !== 'quick_reply')) await ensureWorkingCard();
|
|
386
|
+
|
|
387
|
+
for (const tu of toolUses) {
|
|
388
|
+
if (ENDING_TOOLS.has(tu.name) || tu.name === 'quick_reply') continue; // handled above / after loop
|
|
389
|
+
const input = (tu.input ?? {}) as Record<string, unknown>;
|
|
390
|
+
if (tu.name === 'plan') {
|
|
391
|
+
const labels = Array.isArray(input.todos) ? (input.todos as unknown[]).map(String) : [];
|
|
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
|
+
});
|
|
398
|
+
await patchActivity({ todos });
|
|
399
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'plan set' });
|
|
400
|
+
} else if (tu.name === 'step') {
|
|
401
|
+
await advanceTodo(input.task);
|
|
402
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'on it' });
|
|
403
|
+
} else if (tu.name === 'send_update') {
|
|
404
|
+
if (input.task) await advanceTodo(input.task);
|
|
405
|
+
const msg = String(input.message ?? '');
|
|
406
|
+
if (msg)
|
|
407
|
+
pushes.push({
|
|
408
|
+
message: msg,
|
|
409
|
+
card: sanitizeCard(input.card, input),
|
|
410
|
+
suggestions: cleanSuggestions(input.suggestions),
|
|
411
|
+
attachments: cleanAttachments(input.attachments),
|
|
412
|
+
previewUrl: cleanPreviewUrl(input.previewUrl),
|
|
413
|
+
});
|
|
414
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'posted' });
|
|
415
|
+
} else if (tu.name === 'show_document') {
|
|
416
|
+
const title = cleanTitle(input.title);
|
|
417
|
+
const body = String(input.body ?? '').trim();
|
|
418
|
+
const docPin = sanitizeCard(input.pinCard);
|
|
419
|
+
const docLabels = cleanLabels(input.labels);
|
|
420
|
+
if (body) {
|
|
421
|
+
pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input), pinCard: docPin, labels: docLabels, attachments: cleanAttachments(input.attachments) });
|
|
422
|
+
} else if (docPin || docLabels) {
|
|
423
|
+
if (docPin) pendingPinCard = docPin;
|
|
424
|
+
if (docLabels) pendingLabels = docLabels;
|
|
425
|
+
}
|
|
426
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'shown' });
|
|
427
|
+
} else if (backendByName.has(tu.name)) {
|
|
428
|
+
// YOUR tool. Tick the plan (task field), run the handler, auto-narrate a step.
|
|
429
|
+
if (input.task) await advanceTodo(input.task);
|
|
430
|
+
const { task: _task, ...callInput } = input;
|
|
431
|
+
const tool = backendByName.get(tu.name)!;
|
|
432
|
+
let result: unknown;
|
|
433
|
+
try {
|
|
434
|
+
result = await tool.handler(callInput);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
// A throwing tool must not crash the turn — return the failure so the model reacts.
|
|
437
|
+
result = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
438
|
+
log('backend tool threw', tu.name, e);
|
|
439
|
+
}
|
|
440
|
+
const narr = tool.narrate ? tool.narrate(callInput, result) : defaultNarrate(tu.name);
|
|
441
|
+
await patchActivity({ type: narr.type satisfies AgentActivityType, label: narr.label });
|
|
442
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: JSON.stringify(result) });
|
|
443
|
+
} else {
|
|
444
|
+
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: `Unknown tool: ${tu.name}`, is_error: true });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Close out EVERY tool_use with a result so the persisted history stays well-formed
|
|
449
|
+
// (an ending tool gets a synthetic ack — we break before its real result would matter).
|
|
450
|
+
const answered = new Set(toolResults.map((r) => r.tool_use_id));
|
|
451
|
+
for (const tu of toolUses) if (!answered.has(tu.id)) toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'acknowledged' });
|
|
452
|
+
messages.push({ role: 'user', content: toolResults });
|
|
453
|
+
|
|
454
|
+
if (ending) {
|
|
455
|
+
recordEnding(ending.name, (ending.input ?? {}) as Record<string, unknown>);
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
if (ended) break; // quick_reply({done:true}) settled the turn
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Ran out of steps or budget without an ending tool — force ONE closing turn so an intended
|
|
462
|
+
// question/decision is never silently downgraded to "Done."
|
|
463
|
+
if (!ended) {
|
|
464
|
+
log('forced_ending', { reason: outOfTime ? 'budget' : 'out_of_steps' });
|
|
465
|
+
const stop = outOfTime
|
|
466
|
+
? '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.'
|
|
467
|
+
: '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.';
|
|
468
|
+
try {
|
|
469
|
+
const fr = await anthropic.messages.create({
|
|
470
|
+
model: MODEL,
|
|
471
|
+
max_tokens: 768,
|
|
472
|
+
system: `${systemPrompt}\n\n${stop}`,
|
|
473
|
+
tools: ALL_TOOLS,
|
|
474
|
+
tool_choice: { type: 'any' },
|
|
475
|
+
messages,
|
|
476
|
+
});
|
|
477
|
+
const forced = fr.content.find((b): b is ToolUseBlock => b.type === 'tool_use' && ENDING_TOOLS.has(b.name));
|
|
478
|
+
if (forced) {
|
|
479
|
+
recordEnding(forced.name, (forced.input ?? {}) as Record<string, unknown>);
|
|
480
|
+
// Keep history well-formed: record the forced assistant turn + a synthetic result.
|
|
481
|
+
messages.push({ role: 'assistant', content: fr.content });
|
|
482
|
+
const uses = fr.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
483
|
+
messages.push({ role: 'user', content: uses.map((u) => ({ type: 'tool_result' as const, tool_use_id: u.id, content: 'acknowledged' })) });
|
|
484
|
+
}
|
|
485
|
+
} catch (e) {
|
|
486
|
+
log('forced_ending failed', e instanceof Error ? e.message : e);
|
|
487
|
+
}
|
|
488
|
+
if (!ended) {
|
|
489
|
+
pushes.push({ message: "Here's where I've got to — I'll pause here." });
|
|
490
|
+
ended = true;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} catch (err) {
|
|
494
|
+
log('turn error', err);
|
|
495
|
+
pushes.push({ message: 'I hit a snag and had to stop early.' });
|
|
496
|
+
} finally {
|
|
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.
|
|
500
|
+
try {
|
|
501
|
+
await stopStream();
|
|
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);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
log('seal failed', e);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (pushes.length === 0) pushes.push({ message: 'Done.' });
|
|
515
|
+
|
|
516
|
+
// Deliver the human-facing pushes. State is saved separately (below), so a failed push
|
|
517
|
+
// never loses conversation context — it only drops a message.
|
|
518
|
+
for (let i = 0; i < pushes.length; i++) {
|
|
519
|
+
const p = pushes[i];
|
|
520
|
+
const isLast = i === pushes.length - 1;
|
|
521
|
+
const titleForThisPush = i === 0 && pendingTitle ? pendingTitle : undefined;
|
|
522
|
+
if (titleForThisPush) pendingTitle = undefined;
|
|
523
|
+
const pinForThisPush = p.pinCard ?? (i === 0 ? pendingPinCard : undefined);
|
|
524
|
+
const labelsForThisPush = p.labels ?? (i === 0 ? pendingLabels : undefined);
|
|
525
|
+
if (i === 0) {
|
|
526
|
+
pendingPinCard = undefined;
|
|
527
|
+
pendingLabels = undefined;
|
|
528
|
+
}
|
|
529
|
+
const r = await safePush(vessels, {
|
|
530
|
+
vessel,
|
|
531
|
+
...(titleForThisPush ? { vesselTitle: titleForThisPush } : {}),
|
|
532
|
+
message: p.message,
|
|
533
|
+
kind: p.kind,
|
|
534
|
+
title: p.title,
|
|
535
|
+
card: p.card as PushOptions['card'],
|
|
536
|
+
...(pinForThisPush ? { pinCard: pinForThisPush as PushOptions['pinCard'] } : {}),
|
|
537
|
+
...(labelsForThisPush ? { labels: labelsForThisPush } : {}),
|
|
538
|
+
interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
|
|
539
|
+
suggestions: p.suggestions,
|
|
540
|
+
previewUrl: p.previewUrl,
|
|
541
|
+
attachments: p.attachments,
|
|
542
|
+
idempotencyKey: `${idempotencyKeyBase}:${i}`,
|
|
543
|
+
});
|
|
544
|
+
// A model-malformed optional field can still get the push rejected. The last push is the
|
|
545
|
+
// real answer/interaction — retry it stripped to the essentials the agent fully controls.
|
|
546
|
+
if (!r.messageId && isLast) {
|
|
547
|
+
await safePush(vessels, {
|
|
548
|
+
vessel,
|
|
549
|
+
message: p.message,
|
|
550
|
+
interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
|
|
551
|
+
idempotencyKey: `${idempotencyKeyBase}:${i}:retry`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Persist the conversation BEFORE releasing the lock, so a waiting turn threads on it.
|
|
557
|
+
try {
|
|
558
|
+
await store.saveState(vessel, trimHistory(messages));
|
|
559
|
+
} catch (e) {
|
|
560
|
+
log('saveState failed', e);
|
|
561
|
+
}
|
|
562
|
+
await store.releaseLock(vessel);
|
|
563
|
+
}
|
|
564
|
+
}
|