privateer-agent 0.1.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/LICENSE +21 -0
- package/README.md +474 -0
- package/bin/privateer.mjs +11 -0
- package/package.json +74 -0
- package/src/agents/loader.ts +49 -0
- package/src/auth/privateer.ts +393 -0
- package/src/commands/custom.ts +75 -0
- package/src/commands/registry.ts +499 -0
- package/src/components/AgentGroupView.tsx +104 -0
- package/src/components/App.tsx +1376 -0
- package/src/components/ApprovalPrompt.tsx +38 -0
- package/src/components/Banner.tsx +58 -0
- package/src/components/Markdown.tsx +183 -0
- package/src/components/ModeHint.tsx +40 -0
- package/src/components/ModelPicker.tsx +269 -0
- package/src/components/Onboarding.tsx +203 -0
- package/src/components/PlanConfirm.tsx +37 -0
- package/src/components/PrivateerLogin.tsx +109 -0
- package/src/components/PromptInput.tsx +602 -0
- package/src/components/RewindPicker.tsx +69 -0
- package/src/components/Root.tsx +95 -0
- package/src/components/SessionPicker.tsx +64 -0
- package/src/components/StatusBar.tsx +121 -0
- package/src/components/TodoPanel.tsx +36 -0
- package/src/components/ToolCallView.tsx +109 -0
- package/src/components/Transcript.tsx +203 -0
- package/src/components/figures.ts +13 -0
- package/src/components/promptModel.ts +73 -0
- package/src/components/spinnerVerbs.ts +46 -0
- package/src/components/theme.ts +55 -0
- package/src/components/types.ts +34 -0
- package/src/components/useTeeShield.ts +104 -0
- package/src/components/useTerminalWidth.ts +24 -0
- package/src/components/useZdrShield.ts +126 -0
- package/src/config/load.ts +115 -0
- package/src/config/paths.ts +61 -0
- package/src/config/schema.ts +94 -0
- package/src/context/outputStyles.ts +42 -0
- package/src/context/projectInfo.ts +59 -0
- package/src/context/systemPrompt.ts +167 -0
- package/src/engine/QueryEngine.ts +399 -0
- package/src/engine/errors.ts +197 -0
- package/src/engine/events.ts +74 -0
- package/src/engine/router.ts +165 -0
- package/src/hooks/engine.ts +155 -0
- package/src/main.tsx +167 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/oauth.ts +245 -0
- package/src/memory/auto.ts +146 -0
- package/src/memory/checkpoints.ts +227 -0
- package/src/memory/store.ts +127 -0
- package/src/permissions/danger.ts +56 -0
- package/src/permissions/gate.ts +38 -0
- package/src/permissions/mode.ts +39 -0
- package/src/permissions/protected.ts +29 -0
- package/src/permissions/uiGate.ts +73 -0
- package/src/providers/attestation.ts +149 -0
- package/src/providers/capabilities.ts +104 -0
- package/src/providers/catalog.ts +66 -0
- package/src/providers/models.ts +183 -0
- package/src/providers/registry.ts +71 -0
- package/src/providers/resolve.ts +78 -0
- package/src/remote/relayClient.ts +283 -0
- package/src/session.ts +264 -0
- package/src/tools/bash.ts +98 -0
- package/src/tools/context.ts +114 -0
- package/src/tools/edit.ts +67 -0
- package/src/tools/exec.ts +60 -0
- package/src/tools/glob.ts +39 -0
- package/src/tools/grep.ts +86 -0
- package/src/tools/index.ts +69 -0
- package/src/tools/memory.ts +53 -0
- package/src/tools/processRegistry.ts +77 -0
- package/src/tools/read.ts +42 -0
- package/src/tools/saveAttachment.ts +53 -0
- package/src/tools/task.ts +52 -0
- package/src/tools/todo.ts +36 -0
- package/src/tools/todoStore.ts +31 -0
- package/src/tools/walk.ts +44 -0
- package/src/tools/web.ts +145 -0
- package/src/tools/write.ts +40 -0
- package/src/util/attachmentStore.ts +72 -0
- package/src/util/images.ts +343 -0
- package/src/util/limit.ts +32 -0
- package/src/util/redact.ts +44 -0
- package/src/version.ts +13 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import {
|
|
2
|
+
streamText,
|
|
3
|
+
generateText,
|
|
4
|
+
generateObject,
|
|
5
|
+
stepCountIs,
|
|
6
|
+
type ModelMessage,
|
|
7
|
+
type ToolSet,
|
|
8
|
+
} from "ai";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { type EngineEvent, type UsageTotals, emptyUsage, addUsage } from "./events.ts";
|
|
11
|
+
import { type RouteSet, selectRoute, requiredModalities } from "./router.ts";
|
|
12
|
+
import { redactText } from "../util/redact.ts";
|
|
13
|
+
import { describeError, type DescribedError } from "./errors.ts";
|
|
14
|
+
|
|
15
|
+
// Structured shape for compaction so the summary preserves the parts that matter for
|
|
16
|
+
// continuing the work, rather than a free-form blob.
|
|
17
|
+
const CompactionSchema = z.object({
|
|
18
|
+
goals: z.string().describe("The user's overall goals for this session."),
|
|
19
|
+
decisions: z.array(z.string()).describe("Key decisions, approaches, and findings so far."),
|
|
20
|
+
filesTouched: z.array(z.string()).describe("File paths created or modified, each with a short note."),
|
|
21
|
+
openThreads: z.array(z.string()).describe("Unfinished tasks, next steps, and open questions."),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function formatCompaction(o: z.infer<typeof CompactionSchema>): string {
|
|
25
|
+
const list = (items: string[]) => (items.length ? items.map((i) => `- ${i}`).join("\n") : "- (none)");
|
|
26
|
+
return [
|
|
27
|
+
`Goals: ${o.goals}`,
|
|
28
|
+
`Decisions:\n${list(o.decisions)}`,
|
|
29
|
+
`Files touched:\n${list(o.filesTouched)}`,
|
|
30
|
+
`Open threads:\n${list(o.openThreads)}`,
|
|
31
|
+
].join("\n\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface QueryEngineOptions {
|
|
35
|
+
// The model routes for this session. `routes.default` is always used unless a
|
|
36
|
+
// turn's data/shape selects a specialized route (see src/engine/router.ts).
|
|
37
|
+
// Per-route flags (cacheControl/thinkingBudget) travel on each Route. Compaction
|
|
38
|
+
// always runs on the default route.
|
|
39
|
+
routes: RouteSet;
|
|
40
|
+
system: string;
|
|
41
|
+
tools: ToolSet;
|
|
42
|
+
maxSteps: number;
|
|
43
|
+
// Approx token budget; when the estimated context exceeds budget*ratio before a
|
|
44
|
+
// turn, older history is summarized away. 0/undefined disables auto-compaction.
|
|
45
|
+
contextBudget?: number;
|
|
46
|
+
compactRatio?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Number of most-recent messages kept verbatim when compacting.
|
|
50
|
+
const KEEP_RECENT = 6;
|
|
51
|
+
|
|
52
|
+
// How many times to auto-retry a turn that failed transiently (rate limit, 5xx,
|
|
53
|
+
// network) before any output streamed. Fatal errors (auth/billing/data-policy/bad
|
|
54
|
+
// model) are never retried — describeError leaves their `retryable` flag unset.
|
|
55
|
+
const MAX_RETRIES = 3;
|
|
56
|
+
|
|
57
|
+
// The agent loop. Each `send` streams one user turn through the model, letting the
|
|
58
|
+
// AI SDK run the multi-step tool loop internally (executing our tools' execute()),
|
|
59
|
+
// while we translate the raw stream into normalized EngineEvents and accumulate usage.
|
|
60
|
+
// History persists on the instance so follow-up turns keep context. A turn can be
|
|
61
|
+
// interrupted via an AbortSignal; partial output is still persisted to history.
|
|
62
|
+
export class QueryEngine {
|
|
63
|
+
readonly messages: ModelMessage[] = [];
|
|
64
|
+
usage: UsageTotals = emptyUsage();
|
|
65
|
+
|
|
66
|
+
constructor(private readonly opts: QueryEngineOptions) {}
|
|
67
|
+
|
|
68
|
+
// Current context-window occupancy: estimated tokens in history over the budget
|
|
69
|
+
// that triggers compaction. Drives the Claude-Code-style "% of context" readout.
|
|
70
|
+
// `budget` is 0 when auto-compaction is disabled.
|
|
71
|
+
contextUsage(): { used: number; budget: number } {
|
|
72
|
+
return { used: estimateTokens(this.messages), budget: this.opts.contextBudget ?? 0 };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async *send(
|
|
76
|
+
userText: string,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
attachments?: { data: string; mediaType: string; modality?: string }[],
|
|
79
|
+
): AsyncGenerator<EngineEvent, void, void> {
|
|
80
|
+
// Auto-compact before the turn if the context has grown past the budget.
|
|
81
|
+
if (this.shouldCompact()) {
|
|
82
|
+
const res = await this.compact();
|
|
83
|
+
if (res) yield { type: "compacted", before: res.before, after: res.after };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (attachments && attachments.length > 0) {
|
|
87
|
+
this.messages.push({
|
|
88
|
+
role: "user",
|
|
89
|
+
content: [
|
|
90
|
+
{ type: "text", text: userText },
|
|
91
|
+
// Images go as image parts; documents/audio/video as generic file parts.
|
|
92
|
+
...attachments.map((a) =>
|
|
93
|
+
a.modality === "image" || a.mediaType.startsWith("image/")
|
|
94
|
+
? ({ type: "image" as const, image: a.data, mediaType: a.mediaType })
|
|
95
|
+
: ({ type: "file" as const, data: a.data, mediaType: a.mediaType })),
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
this.messages.push({ role: "user", content: userText });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pick the model for this turn from its data/shape. Modality requirements are
|
|
103
|
+
// sticky over the whole conversation so attachment history never gets replayed to
|
|
104
|
+
// a model that can't accept it.
|
|
105
|
+
const sel = selectRoute(this.opts.routes, {
|
|
106
|
+
modalities: requiredModalities(this.messages),
|
|
107
|
+
estTokens: estimateTokens(this.messages),
|
|
108
|
+
promptChars: userText.length,
|
|
109
|
+
});
|
|
110
|
+
if (sel.name !== "default" || (sel.missing && sel.missing.length > 0)) {
|
|
111
|
+
yield {
|
|
112
|
+
type: "routed",
|
|
113
|
+
route: sel.name,
|
|
114
|
+
label: sel.route.label,
|
|
115
|
+
reason: sel.reason,
|
|
116
|
+
missing: sel.missing,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const route = sel.route;
|
|
120
|
+
|
|
121
|
+
let result;
|
|
122
|
+
try {
|
|
123
|
+
result = streamText({
|
|
124
|
+
model: route.model,
|
|
125
|
+
system: this.opts.system,
|
|
126
|
+
messages: route.cacheControl ? withCacheBreakpoints(this.messages) : this.messages,
|
|
127
|
+
tools: this.opts.tools,
|
|
128
|
+
stopWhen: stepCountIs(this.opts.maxSteps),
|
|
129
|
+
abortSignal: signal,
|
|
130
|
+
// Re-place the rolling cache breakpoint on every internal tool-loop step.
|
|
131
|
+
// streamText runs the multi-step loop itself, appending tool-call/result
|
|
132
|
+
// messages between API calls; without this the breakpoint stays on the
|
|
133
|
+
// pre-loop tail, so each step's accumulating tool output is re-sent at full
|
|
134
|
+
// price. Marking the new last message each step caches the prefix the
|
|
135
|
+
// previous step already sent. (No-op for non-Anthropic routes.)
|
|
136
|
+
prepareStep: route.cacheControl
|
|
137
|
+
? ({ messages }) => ({ messages: withCacheBreakpoints(messages) })
|
|
138
|
+
: undefined,
|
|
139
|
+
providerOptions: route.thinkingBudget
|
|
140
|
+
? { anthropic: { thinking: { type: "enabled", budgetTokens: route.thinkingBudget } } }
|
|
141
|
+
: undefined,
|
|
142
|
+
});
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const d = describeError(err);
|
|
145
|
+
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The AI SDK exposes several derived promises that reject lazily when the
|
|
150
|
+
// stream errors. We await some below in their own try/catch, but any we
|
|
151
|
+
// never touch would surface as an unhandled rejection — which Node dumps,
|
|
152
|
+
// unredacted, to the terminal (scrambling the TUI and leaking the request
|
|
153
|
+
// body). Attach no-op catches so a stream error stays inside our channel.
|
|
154
|
+
for (const key of ["text", "steps", "warnings", "sources", "files", "reasoning"] as const) {
|
|
155
|
+
const p = (result as unknown as Record<string, unknown>)[key];
|
|
156
|
+
if (p && typeof (p as Promise<unknown>).then === "function") {
|
|
157
|
+
(p as Promise<unknown>).catch(() => {});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let assistantText = "";
|
|
162
|
+
let aborted = false;
|
|
163
|
+
// Track usage as steps finish so the UI can tick the token count up live,
|
|
164
|
+
// instead of jumping only when the whole turn ends. `totalUsage` reconciles
|
|
165
|
+
// the authoritative number at finish.
|
|
166
|
+
const baseline = this.usage;
|
|
167
|
+
let stepsUsage = emptyUsage();
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
for await (const part of result.fullStream) {
|
|
171
|
+
switch (part.type) {
|
|
172
|
+
case "text-delta":
|
|
173
|
+
if (part.text) {
|
|
174
|
+
assistantText += part.text;
|
|
175
|
+
yield { type: "text", text: part.text };
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
case "reasoning-delta":
|
|
179
|
+
if (part.text) yield { type: "reasoning", text: part.text };
|
|
180
|
+
break;
|
|
181
|
+
case "tool-call":
|
|
182
|
+
yield { type: "tool-call", id: part.toolCallId, name: part.toolName, input: part.input };
|
|
183
|
+
break;
|
|
184
|
+
case "tool-result":
|
|
185
|
+
yield {
|
|
186
|
+
type: "tool-result",
|
|
187
|
+
id: part.toolCallId,
|
|
188
|
+
name: part.toolName,
|
|
189
|
+
output: (part as { output: unknown }).output,
|
|
190
|
+
};
|
|
191
|
+
break;
|
|
192
|
+
case "tool-error":
|
|
193
|
+
yield {
|
|
194
|
+
type: "tool-error",
|
|
195
|
+
id: part.toolCallId,
|
|
196
|
+
name: part.toolName,
|
|
197
|
+
error: errMsg((part as { error: unknown }).error),
|
|
198
|
+
};
|
|
199
|
+
break;
|
|
200
|
+
case "finish-step": {
|
|
201
|
+
const u = (part as { usage?: Partial<UsageTotals> }).usage;
|
|
202
|
+
if (u) {
|
|
203
|
+
stepsUsage = addUsage(stepsUsage, {
|
|
204
|
+
inputTokens: u.inputTokens ?? 0,
|
|
205
|
+
outputTokens: u.outputTokens ?? 0,
|
|
206
|
+
totalTokens: u.totalTokens ?? 0,
|
|
207
|
+
cachedInputTokens: u.cachedInputTokens ?? 0,
|
|
208
|
+
});
|
|
209
|
+
this.usage = addUsage(baseline, stepsUsage);
|
|
210
|
+
yield { type: "usage", usage: this.usage, turn: stepsUsage };
|
|
211
|
+
}
|
|
212
|
+
yield { type: "step-finish" };
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
case "abort":
|
|
216
|
+
aborted = true;
|
|
217
|
+
break;
|
|
218
|
+
case "error": {
|
|
219
|
+
const d = describeError(part.error);
|
|
220
|
+
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (signal?.aborted || isAbortError(err)) {
|
|
227
|
+
aborted = true;
|
|
228
|
+
} else {
|
|
229
|
+
const d = describeError(err);
|
|
230
|
+
yield { type: "error", error: d.message, hint: d.hint, retryable: d.retryable };
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Persist the model's response so the next turn keeps context. On a clean finish
|
|
236
|
+
// we use the SDK's structured messages; on an interrupt those may be unavailable,
|
|
237
|
+
// so we fall back to a synthetic assistant message from the text we streamed.
|
|
238
|
+
let persisted = false;
|
|
239
|
+
try {
|
|
240
|
+
const response = await result.response;
|
|
241
|
+
if (response?.messages?.length) {
|
|
242
|
+
this.messages.push(...response.messages);
|
|
243
|
+
persisted = true;
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
/* aborted/errored before a response was assembled */
|
|
247
|
+
}
|
|
248
|
+
if (!persisted && assistantText.trim()) {
|
|
249
|
+
this.messages.push({ role: "assistant", content: assistantText });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (aborted) {
|
|
253
|
+
yield { type: "aborted" };
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const turnUsage = await result.totalUsage.catch(() => ({}) as Record<string, number>);
|
|
258
|
+
const usage: UsageTotals = {
|
|
259
|
+
inputTokens: turnUsage.inputTokens ?? 0,
|
|
260
|
+
outputTokens: turnUsage.outputTokens ?? 0,
|
|
261
|
+
totalTokens: turnUsage.totalTokens ?? 0,
|
|
262
|
+
cachedInputTokens: turnUsage.cachedInputTokens ?? 0,
|
|
263
|
+
};
|
|
264
|
+
// Reconcile against the authoritative turn total. We already folded per-step
|
|
265
|
+
// usage into this.usage live; rebase on the baseline so we don't double-count.
|
|
266
|
+
// Fall back to the accumulated step usage if the provider omitted totalUsage.
|
|
267
|
+
this.usage = addUsage(baseline, usage.totalTokens > 0 ? usage : stepsUsage);
|
|
268
|
+
|
|
269
|
+
const finishReason = await result.finishReason.catch(() => "unknown");
|
|
270
|
+
yield { type: "finish", usage, finishReason };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private shouldCompact(): boolean {
|
|
274
|
+
const budget = this.opts.contextBudget;
|
|
275
|
+
if (!budget) return false;
|
|
276
|
+
const ratio = this.opts.compactRatio ?? 0.8;
|
|
277
|
+
return this.messages.length > KEEP_RECENT && estimateTokens(this.messages) > budget * ratio;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Summarize older history into a single briefing message, keeping the most recent
|
|
281
|
+
// messages verbatim. Uses a schema-guided summary (goals / decisions / files /
|
|
282
|
+
// open threads) so the structure survives, falling back to a plain-text summary if
|
|
283
|
+
// structured output fails. The cut always lands on a `user` message so tool-call /
|
|
284
|
+
// result pairs are never orphaned. Returns before/after token estimates, or null
|
|
285
|
+
// when there's nothing worth compacting. Best-effort: failures leave history intact.
|
|
286
|
+
async compact(): Promise<{ before: number; after: number } | null> {
|
|
287
|
+
const before = estimateTokens(this.messages);
|
|
288
|
+
const cut = safeCutIndex(this.messages, KEEP_RECENT);
|
|
289
|
+
if (cut <= 0) return null;
|
|
290
|
+
|
|
291
|
+
const older = this.messages.slice(0, cut);
|
|
292
|
+
const recent = this.messages.slice(cut);
|
|
293
|
+
const transcript = older.map((m) => `${m.role}: ${renderContent(m.content)}`).join("\n\n");
|
|
294
|
+
const instruction =
|
|
295
|
+
`Summarize the earlier part of this coding session so the work can continue without the ` +
|
|
296
|
+
`full history. Be specific and terse.\n\n---\n${transcript}`;
|
|
297
|
+
|
|
298
|
+
let summary: string;
|
|
299
|
+
try {
|
|
300
|
+
const { object } = await generateObject({
|
|
301
|
+
model: this.opts.routes.default.model,
|
|
302
|
+
schema: CompactionSchema,
|
|
303
|
+
prompt: instruction,
|
|
304
|
+
});
|
|
305
|
+
summary = formatCompaction(object);
|
|
306
|
+
} catch {
|
|
307
|
+
// Some models/providers handle structured output poorly — fall back to text.
|
|
308
|
+
try {
|
|
309
|
+
const { text } = await generateText({ model: this.opts.routes.default.model, prompt: instruction });
|
|
310
|
+
summary = text.trim();
|
|
311
|
+
} catch {
|
|
312
|
+
return null; // leave history untouched on failure
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (!summary) return null;
|
|
316
|
+
|
|
317
|
+
this.messages.length = 0;
|
|
318
|
+
this.messages.push({ role: "user", content: `[Summary of earlier conversation]\n${summary}` });
|
|
319
|
+
this.messages.push(...recent);
|
|
320
|
+
|
|
321
|
+
return { before, after: estimateTokens(this.messages) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Cheap heuristic token estimate (~4 chars/token) over serialized message content.
|
|
326
|
+
export function estimateTokens(messages: ModelMessage[]): number {
|
|
327
|
+
let chars = 0;
|
|
328
|
+
for (const m of messages) chars += renderContent(m.content).length + m.role.length;
|
|
329
|
+
return Math.ceil(chars / 4);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Choose a cut so the kept tail starts on a `user` message — never orphaning a tool
|
|
333
|
+
// result from its tool-call. Returns 0 when there's nothing safe to drop.
|
|
334
|
+
function safeCutIndex(messages: ModelMessage[], minKeep: number): number {
|
|
335
|
+
let cut = messages.length - minKeep;
|
|
336
|
+
if (cut <= 0) return 0;
|
|
337
|
+
while (cut < messages.length && messages[cut].role !== "user") cut++;
|
|
338
|
+
return cut >= messages.length ? 0 : cut;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderContent(content: unknown): string {
|
|
342
|
+
if (typeof content === "string") return content;
|
|
343
|
+
if (Array.isArray(content)) {
|
|
344
|
+
return content
|
|
345
|
+
.map((p) => {
|
|
346
|
+
const part = p as { type?: string; text?: string; toolName?: string };
|
|
347
|
+
if (part.type === "text" && part.text) return part.text;
|
|
348
|
+
if (part.type === "tool-call") return `[tool-call ${part.toolName ?? ""}]`;
|
|
349
|
+
if (part.type === "tool-result") return `[tool-result ${part.toolName ?? ""}]`;
|
|
350
|
+
return `[${part.type ?? "part"}]`;
|
|
351
|
+
})
|
|
352
|
+
.join(" ");
|
|
353
|
+
}
|
|
354
|
+
return "";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Attach Anthropic ephemeral cache breakpoints. Anthropic caches the longest prefix
|
|
358
|
+
// ending at a breakpoint, so we mark the first message (stable base: system + tools +
|
|
359
|
+
// first turn) and the last message (rolling: grows with the conversation). Returns a
|
|
360
|
+
// shallow copy so the stored history stays free of provider-specific annotations.
|
|
361
|
+
const CACHE = { anthropic: { cacheControl: { type: "ephemeral" } } } as const;
|
|
362
|
+
|
|
363
|
+
function withCacheBreakpoints(messages: ModelMessage[]): ModelMessage[] {
|
|
364
|
+
if (messages.length === 0) return messages;
|
|
365
|
+
const out = messages.slice();
|
|
366
|
+
markBreakpoint(out, 0);
|
|
367
|
+
if (out.length > 1) markBreakpoint(out, out.length - 1);
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function markBreakpoint(messages: ModelMessage[], i: number): void {
|
|
372
|
+
const msg = messages[i] as { role: string; content: unknown };
|
|
373
|
+
const parts =
|
|
374
|
+
typeof msg.content === "string"
|
|
375
|
+
? [{ type: "text", text: msg.content }]
|
|
376
|
+
: (msg.content as unknown[]).slice();
|
|
377
|
+
if (parts.length === 0) return;
|
|
378
|
+
const last = parts.length - 1;
|
|
379
|
+
parts[last] = { ...(parts[last] as object), providerOptions: CACHE };
|
|
380
|
+
messages[i] = { ...msg, content: parts } as unknown as ModelMessage;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isAbortError(err: unknown): boolean {
|
|
384
|
+
return err instanceof Error && (err.name === "AbortError" || /abort/i.test(err.message));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function errMsg(err: unknown): string {
|
|
388
|
+
return redactText(rawErrMsg(err));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function rawErrMsg(err: unknown): string {
|
|
392
|
+
if (err instanceof Error) return err.message;
|
|
393
|
+
if (typeof err === "string") return err;
|
|
394
|
+
try {
|
|
395
|
+
return JSON.stringify(err);
|
|
396
|
+
} catch {
|
|
397
|
+
return String(err);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Turn provider / AI-SDK errors into something a human can act on. These errors
|
|
2
|
+
// (e.g. `AI_APICallError`) carry structured fields well beyond `.message` —
|
|
3
|
+
// statusCode, responseBody, the request's model — but Node's default printer
|
|
4
|
+
// would dump the whole object (request messages and all) to the terminal,
|
|
5
|
+
// unredacted. We read the useful fields defensively and emit a short message
|
|
6
|
+
// plus an actionable hint, both run through secret redaction.
|
|
7
|
+
|
|
8
|
+
import { redactText } from "../util/redact.ts";
|
|
9
|
+
|
|
10
|
+
export interface DescribedError {
|
|
11
|
+
message: string; // short, user-facing, redacted
|
|
12
|
+
hint?: string; // actionable next step, rendered dim below the message
|
|
13
|
+
retryable?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ErrorFacts {
|
|
17
|
+
statusCode?: number;
|
|
18
|
+
providerMessage?: string;
|
|
19
|
+
code?: string; // provider's machine-readable error code, e.g. "DAILY_CAP_HIT"
|
|
20
|
+
model?: string;
|
|
21
|
+
provider?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const HOST_LABELS: Record<string, string> = {
|
|
25
|
+
"openrouter.ai": "OpenRouter",
|
|
26
|
+
"api.anthropic.com": "Anthropic",
|
|
27
|
+
"api.openai.com": "OpenAI",
|
|
28
|
+
"cloud-api.near.ai": "NEAR AI",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// The AI SDK wraps the real provider error: a retry sequence that exhausts its
|
|
32
|
+
// attempts throws AI_RetryError, whose `.lastError` is the APICallError that
|
|
33
|
+
// actually carries statusCode / responseBody / requestBodyValues. Without peeling
|
|
34
|
+
// that off we'd read undefined for every field and fall back to the wrapper's bare
|
|
35
|
+
// "Too Many Requests". Follow `.lastError` (and a `.cause` that looks like a
|
|
36
|
+
// richer error) until we reach the one with the useful fields.
|
|
37
|
+
function unwrap(err: unknown): unknown {
|
|
38
|
+
let cur = err;
|
|
39
|
+
for (let i = 0; i < 5; i++) {
|
|
40
|
+
if (!cur || typeof cur !== "object") break;
|
|
41
|
+
const e = cur as Record<string, unknown>;
|
|
42
|
+
const richer = e.statusCode == null && e.responseBody == null;
|
|
43
|
+
const inner = e.lastError ?? (richer ? e.cause : undefined);
|
|
44
|
+
if (!inner || inner === cur) break;
|
|
45
|
+
cur = inner;
|
|
46
|
+
}
|
|
47
|
+
return cur;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Pull structured fields off an unknown error without trusting any one shape.
|
|
51
|
+
function extract(err: unknown): ErrorFacts {
|
|
52
|
+
const e = (err ?? {}) as Record<string, unknown>;
|
|
53
|
+
const statusCode = typeof e.statusCode === "number" ? e.statusCode : undefined;
|
|
54
|
+
|
|
55
|
+
// The provider's own message + machine code, preferred over the SDK's wrapper
|
|
56
|
+
// text. Providers disagree on shape: OpenAI/OpenRouter nest under `error`, while
|
|
57
|
+
// the Privateer account backend returns a flat `{ message, code }` (e.g. a daily
|
|
58
|
+
// usage cap). Read both shapes; keep the first message/code we find.
|
|
59
|
+
let providerMessage: string | undefined;
|
|
60
|
+
let code: string | undefined;
|
|
61
|
+
const readBody = (body: unknown) => {
|
|
62
|
+
const b = body as
|
|
63
|
+
| { error?: { message?: unknown; code?: unknown }; message?: unknown; code?: unknown }
|
|
64
|
+
| undefined;
|
|
65
|
+
if (!b || typeof b !== "object") return;
|
|
66
|
+
const msg = b.error?.message ?? b.message;
|
|
67
|
+
if (providerMessage == null && typeof msg === "string") providerMessage = msg;
|
|
68
|
+
const c = b.error?.code ?? b.code;
|
|
69
|
+
if (code == null && typeof c === "string") code = c;
|
|
70
|
+
};
|
|
71
|
+
readBody(e.data);
|
|
72
|
+
if (typeof e.responseBody === "string") {
|
|
73
|
+
try {
|
|
74
|
+
readBody(JSON.parse(e.responseBody));
|
|
75
|
+
} catch {
|
|
76
|
+
/* responseBody wasn't JSON — fall back to the wrapper message */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const reqBody = e.requestBodyValues as { model?: unknown } | undefined;
|
|
81
|
+
const model = typeof reqBody?.model === "string" ? reqBody.model : undefined;
|
|
82
|
+
|
|
83
|
+
let provider: string | undefined;
|
|
84
|
+
if (typeof e.url === "string") {
|
|
85
|
+
try {
|
|
86
|
+
provider = HOST_LABELS[new URL(e.url).host];
|
|
87
|
+
} catch {
|
|
88
|
+
/* not a URL */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { statusCode, providerMessage, code, model, provider };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Machine codes the Privateer backend returns for a hard account cap (daily /
|
|
96
|
+
// monthly message or token limit, or an empty balance). Exported so the provider
|
|
97
|
+
// fetch wrapper can recognise the same condition and rewrite the 429 to a
|
|
98
|
+
// non-retryable status — otherwise the AI SDK burns its full retry budget on a
|
|
99
|
+
// limit that won't clear by retrying. Keep this the single source of truth.
|
|
100
|
+
const CAP_CODE = /CAP|QUOTA|LIMIT_REACHED|INSUFFICIENT|TOP_?UP/i;
|
|
101
|
+
|
|
102
|
+
export function isAccountCapCode(code: string | null | undefined): boolean {
|
|
103
|
+
return typeof code === "string" && CAP_CODE.test(code);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function rawMessage(err: unknown): string {
|
|
107
|
+
if (err instanceof Error) return err.message;
|
|
108
|
+
if (typeof err === "string") return err;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.stringify(err);
|
|
111
|
+
} catch {
|
|
112
|
+
return String(err);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Map a provider error to a friendly message + hint. Falls back to the raw
|
|
117
|
+
// (redacted) message for anything we don't recognize, so nothing is swallowed.
|
|
118
|
+
export function describeError(err: unknown): DescribedError {
|
|
119
|
+
const inner = unwrap(err);
|
|
120
|
+
const facts = extract(inner);
|
|
121
|
+
const status = facts.statusCode;
|
|
122
|
+
const text = facts.providerMessage ?? rawMessage(inner);
|
|
123
|
+
const forModel = facts.model ? ` for ${facts.model}` : "";
|
|
124
|
+
const forProvider = facts.provider ? ` for ${facts.provider}` : "";
|
|
125
|
+
|
|
126
|
+
const out = (d: DescribedError): DescribedError => ({
|
|
127
|
+
message: redactText(d.message),
|
|
128
|
+
hint: d.hint ? redactText(d.hint) : undefined,
|
|
129
|
+
retryable: d.retryable,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// OpenRouter: the account's data-policy / guardrail settings exclude every
|
|
133
|
+
// provider that could serve this model. The phrasing is distinctive, so match on
|
|
134
|
+
// it regardless of status — OpenRouter returns this as a 404 *or* a 403, and the
|
|
135
|
+
// 403 must not fall through to the generic "authentication failed" branch below.
|
|
136
|
+
// This is a "you must act" error: never retried (no `retryable` flag).
|
|
137
|
+
if (/data[- ]?policy|guardrail|no endpoints?\b/i.test(text)) {
|
|
138
|
+
return out({
|
|
139
|
+
message: `No provider endpoint matches your data-policy settings${forModel}.`,
|
|
140
|
+
hint: "Enable providers at https://openrouter.ai/settings/privacy, or pick a different model with /model.",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// Privateer account usage cap (daily/monthly message or token limit). The
|
|
144
|
+
// backend returns this as a 429 with a machine `code` like DAILY_CAP_HIT and a
|
|
145
|
+
// ready-to-show message ("Daily message limit of 25 reached. Upgrade or top up
|
|
146
|
+
// to continue."). Unlike a transient rate limit, retrying never helps — the user
|
|
147
|
+
// must upgrade, top up, or switch providers — so surface the backend's own
|
|
148
|
+
// message verbatim and do NOT mark it retryable (which would invite a retry).
|
|
149
|
+
const capText = /limit of .* reached|upgrade or top ?up|usage limit reached/i.test(text);
|
|
150
|
+
if (isAccountCapCode(facts.code) || (status === 429 && capText)) {
|
|
151
|
+
return out({
|
|
152
|
+
message: facts.providerMessage ?? text,
|
|
153
|
+
hint: "Upgrade or top up your Privateer account, or run /provider to use your own API key.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (status === 401 || status === 403) {
|
|
157
|
+
return out({
|
|
158
|
+
message: `Authentication failed${forProvider} (${status}).`,
|
|
159
|
+
hint: "Check the API key — run /provider, or set the provider's API key env var.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (status === 402) {
|
|
163
|
+
return out({
|
|
164
|
+
message: `Request rejected for billing reasons${forProvider} (402).`,
|
|
165
|
+
hint: "Check your account credits or billing.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (status === 404) {
|
|
169
|
+
return out({
|
|
170
|
+
message: `Model not found${forModel} (404).`,
|
|
171
|
+
hint: "Check the model id — run /model to switch.",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
if (status === 429) {
|
|
175
|
+
return out({
|
|
176
|
+
message: `Rate limited${forProvider} (429).`,
|
|
177
|
+
hint: "Wait a moment and try again.",
|
|
178
|
+
retryable: true,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (status != null && status >= 500) {
|
|
182
|
+
return out({
|
|
183
|
+
message: `Provider error${forProvider} (${status}).`,
|
|
184
|
+
hint: "Usually transient — retry shortly.",
|
|
185
|
+
retryable: true,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (/fetch failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network/i.test(text)) {
|
|
189
|
+
return out({
|
|
190
|
+
message: "Network error reaching the provider.",
|
|
191
|
+
hint: "Check your connection and try again.",
|
|
192
|
+
retryable: true,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return out({ message: text });
|
|
197
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { RouteName, Modality } from "./router.ts";
|
|
2
|
+
|
|
3
|
+
// Normalized events the engine emits while streaming a turn. The UI (and the
|
|
4
|
+
// headless print path) consume these without knowing anything about the provider
|
|
5
|
+
// or the AI SDK's internal stream-part shapes.
|
|
6
|
+
|
|
7
|
+
export interface UsageTotals {
|
|
8
|
+
inputTokens: number;
|
|
9
|
+
outputTokens: number;
|
|
10
|
+
totalTokens: number;
|
|
11
|
+
// Cache-read input tokens (a subset of input, billed at a fraction of the base
|
|
12
|
+
// rate). Providers differ on whether `inputTokens` already includes these — see
|
|
13
|
+
// `effectiveTokens`, which handles both conventions.
|
|
14
|
+
cachedInputTokens: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EngineEvent =
|
|
18
|
+
| { type: "text"; text: string }
|
|
19
|
+
| { type: "reasoning"; text: string }
|
|
20
|
+
| { type: "tool-call"; id: string; name: string; input: unknown }
|
|
21
|
+
| { type: "tool-result"; id: string; name: string; output: unknown }
|
|
22
|
+
| { type: "tool-error"; id: string; name: string; error: string }
|
|
23
|
+
| { type: "step-finish" }
|
|
24
|
+
// `usage` is the cumulative session total; `turn` is just this turn's accumulation
|
|
25
|
+
// so far. Both emitted live as steps finish so the UI can show either.
|
|
26
|
+
| { type: "usage"; usage: UsageTotals; turn: UsageTotals }
|
|
27
|
+
| { type: "aborted" }
|
|
28
|
+
| { type: "compacted"; before: number; after: number }
|
|
29
|
+
// The router switched this turn to a non-default model. `missing` lists modalities
|
|
30
|
+
// the chosen model can't accept (set when no configured model fully covers the turn).
|
|
31
|
+
| { type: "routed"; route: RouteName; label: string; reason?: string; missing?: Modality[] }
|
|
32
|
+
| { type: "finish"; usage: UsageTotals; finishReason: string }
|
|
33
|
+
// A transient failure (rate limit, 5xx, network) is being retried automatically
|
|
34
|
+
// before any output streamed. `attempt`/`max` are 1-based for display; `reason` is
|
|
35
|
+
// the redacted error message that triggered the retry.
|
|
36
|
+
| { type: "retrying"; attempt: number; max: number; delayMs: number; reason: string }
|
|
37
|
+
// `error` is the short user-facing message; `hint` is an optional actionable
|
|
38
|
+
// next step rendered dim beneath it. Both are already secret-redacted.
|
|
39
|
+
| { type: "error"; error: string; hint?: string; retryable?: boolean };
|
|
40
|
+
|
|
41
|
+
export const emptyUsage = (): UsageTotals => ({
|
|
42
|
+
inputTokens: 0,
|
|
43
|
+
outputTokens: 0,
|
|
44
|
+
totalTokens: 0,
|
|
45
|
+
cachedInputTokens: 0,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function addUsage(a: UsageTotals, b: Partial<UsageTotals>): UsageTotals {
|
|
49
|
+
return {
|
|
50
|
+
inputTokens: (a.inputTokens ?? 0) + (b.inputTokens ?? 0),
|
|
51
|
+
outputTokens: (a.outputTokens ?? 0) + (b.outputTokens ?? 0),
|
|
52
|
+
totalTokens: (a.totalTokens ?? 0) + (b.totalTokens ?? 0),
|
|
53
|
+
cachedInputTokens: (a.cachedInputTokens ?? 0) + (b.cachedInputTokens ?? 0),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Anthropic bills a cache read at ~10% of the base input rate. Re-price the cached
|
|
58
|
+
// portion at that rate to estimate what was actually billed, rather than the raw
|
|
59
|
+
// `totalTokens` which counts every re-sent cached token at full weight.
|
|
60
|
+
const CACHE_READ_RATE = 0.1;
|
|
61
|
+
|
|
62
|
+
// Effective (billed-weighted) token estimate. Providers disagree on whether
|
|
63
|
+
// `inputTokens` already includes `cachedInputTokens`: OpenRouter's `prompt_tokens`
|
|
64
|
+
// includes them (cached ≤ input), Anthropic's `input_tokens` counts only fresh
|
|
65
|
+
// tokens (cached reported separately, so cached may exceed input). We detect which
|
|
66
|
+
// convention applies and discount the cached portion either way. With no cache
|
|
67
|
+
// hits this collapses to `inputTokens + outputTokens` (== totalTokens).
|
|
68
|
+
export function effectiveTokens(u: UsageTotals): number {
|
|
69
|
+
const cached = u.cachedInputTokens ?? 0;
|
|
70
|
+
const input = u.inputTokens ?? 0;
|
|
71
|
+
const inputIncludesCached = cached <= input;
|
|
72
|
+
const fullPriceInput = inputIncludesCached ? input - cached : input;
|
|
73
|
+
return Math.round(fullPriceInput + cached * CACHE_READ_RATE + (u.outputTokens ?? 0));
|
|
74
|
+
}
|