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,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote-access relay client (Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* When the user enables /remote-access, the running TUI opens an outbound
|
|
5
|
+
* WebSocket to the Privateer server's relay. The app ("controller") can then
|
|
6
|
+
* drive THIS terminal ("agent"): it sends prompts down, and we stream the
|
|
7
|
+
* engine's events + tool-approval requests back up. Tool execution still runs
|
|
8
|
+
* locally and stays gated — a remote-driven turn relays every would-be action
|
|
9
|
+
* to the app for Allow/Deny (see uiGate.ts getRemote).
|
|
10
|
+
*
|
|
11
|
+
* The socket is authenticated with a single-use, short-TTL ticket minted over
|
|
12
|
+
* the authenticated REST channel (RN can't set WS headers and a JWT in the URL
|
|
13
|
+
* would leak). We never carry the JWT into the WS URL.
|
|
14
|
+
*
|
|
15
|
+
* Framework-agnostic: nothing here imports React. The App owns an instance via
|
|
16
|
+
* a ref and wires the callbacks to its turn loop.
|
|
17
|
+
*/
|
|
18
|
+
import WebSocket from "ws";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { apiRequest, serverBaseUrl } from "../auth/privateer.ts";
|
|
21
|
+
import type { EngineEvent } from "../engine/events.ts";
|
|
22
|
+
import type { PermissionRequest } from "../permissions/gate.ts";
|
|
23
|
+
|
|
24
|
+
// Display label for THIS running terminal. Deliberately NON-PII: we do NOT send
|
|
25
|
+
// username@hostname or the working-directory name to the server/controller (the
|
|
26
|
+
// server is supposed to learn as little as possible). A short random tag lets the
|
|
27
|
+
// user tell multiple terminals apart; they can rename it in the app.
|
|
28
|
+
function terminalLabel(): string {
|
|
29
|
+
return `terminal-${randomUUID().slice(0, 4)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Best-effort redaction of secret-looking content before it crosses the relay to
|
|
33
|
+
// the controller/server. This is a SAFETY NET, not a guarantee — truncation
|
|
34
|
+
// bounds size, this bounds obvious secret leakage (bearer tokens, API keys, env
|
|
35
|
+
// secrets, PEM private keys). The "output may contain secrets" warning still
|
|
36
|
+
// stands; a determined leak (unusual formats) can slip through.
|
|
37
|
+
function redactSecrets(s: string): string {
|
|
38
|
+
if (!s) return s;
|
|
39
|
+
return s
|
|
40
|
+
.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted private key]")
|
|
41
|
+
.replace(/\b(bearer)\s+[A-Za-z0-9._\-]{12,}/gi, "$1 [redacted]")
|
|
42
|
+
.replace(/\b(sk|rk|pk|ghp|gho|ghs|github_pat|AKIA|ASIA)[-_][A-Za-z0-9]{8,}/g, "[redacted key]")
|
|
43
|
+
.replace(/\b([A-Z0-9_]*(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE)[A-Z0-9_]*)\s*[:=]\s*['"]?[^\s'"]{6,}/gi, "$1=[redacted]");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Redact then clip: redaction runs on full text so a secret near the cut isn't
|
|
47
|
+
// missed, then we bound the wire size.
|
|
48
|
+
function safe(s: string, max: number): string {
|
|
49
|
+
return clip(redactSecrets(s), max);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RelayCallbacks {
|
|
53
|
+
// A prompt arrived from the app — feed it into the turn loop (tagged remote).
|
|
54
|
+
onPrompt: (text: string) => void;
|
|
55
|
+
// The app asked to interrupt the in-flight turn.
|
|
56
|
+
onInterrupt: () => void;
|
|
57
|
+
// The app answered a relayed approval request.
|
|
58
|
+
onApprovalResponse: (id: string, decision: "allow" | "deny") => void;
|
|
59
|
+
// A controller attached — push a transcript snapshot so it can catch up.
|
|
60
|
+
onControllerAttached: () => void;
|
|
61
|
+
// Surface a one-line status/notice in the TUI.
|
|
62
|
+
onStatus?: (text: string) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const RECONNECT_MS = 3000;
|
|
66
|
+
// Coalesce streaming deltas so we don't emit one WS frame per token.
|
|
67
|
+
const TEXT_FLUSH_MS = 60;
|
|
68
|
+
|
|
69
|
+
function clip(s: string, max: number): string {
|
|
70
|
+
if (s.length <= max) return s;
|
|
71
|
+
return s.slice(0, max) + `\n… (${s.length - max} more chars)`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function asText(output: unknown): string {
|
|
75
|
+
return typeof output === "string" ? output : JSON.stringify(output);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A relay-specific projection of an EngineEvent: serializable + size-bounded.
|
|
79
|
+
// The raw event's tool input/output are `unknown` and bash output can be ~30k
|
|
80
|
+
// chars, so we normalize to text and truncate before sending over the wire.
|
|
81
|
+
function projectEvent(ev: EngineEvent): Record<string, unknown> {
|
|
82
|
+
switch (ev.type) {
|
|
83
|
+
case "tool-call":
|
|
84
|
+
return { type: "tool-call", id: ev.id, name: ev.name, input: safe(asText(ev.input), 2000) };
|
|
85
|
+
case "tool-result":
|
|
86
|
+
return { type: "tool-result", id: ev.id, name: ev.name, output: safe(asText(ev.output), 4000) };
|
|
87
|
+
case "tool-error":
|
|
88
|
+
return { type: "tool-error", id: ev.id, name: ev.name, error: safe(ev.error, 2000) };
|
|
89
|
+
case "usage":
|
|
90
|
+
return { type: "usage", usage: ev.usage, turn: ev.turn };
|
|
91
|
+
case "finish":
|
|
92
|
+
return { type: "finish", finishReason: ev.finishReason };
|
|
93
|
+
case "routed":
|
|
94
|
+
return { type: "routed", label: ev.label, reason: ev.reason };
|
|
95
|
+
case "retrying":
|
|
96
|
+
return { type: "retrying", attempt: ev.attempt, max: ev.max, reason: clip(ev.reason, 500) };
|
|
97
|
+
case "error":
|
|
98
|
+
return { type: "error", error: clip(ev.error, 2000), hint: ev.hint };
|
|
99
|
+
case "compacted":
|
|
100
|
+
return { type: "compacted", before: ev.before, after: ev.after };
|
|
101
|
+
case "aborted":
|
|
102
|
+
return { type: "aborted" };
|
|
103
|
+
case "step-finish":
|
|
104
|
+
return { type: "step-finish" };
|
|
105
|
+
default:
|
|
106
|
+
// text/reasoning are coalesced in sendEvent and never reach here.
|
|
107
|
+
return { type: ev.type };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class RelayClient {
|
|
112
|
+
private ws: WebSocket | null = null;
|
|
113
|
+
private closed = false;
|
|
114
|
+
private connecting = false;
|
|
115
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
116
|
+
// Ordered delta buffer (text/reasoning) coalesced into one frame per flush.
|
|
117
|
+
private bufKind: "text" | "reasoning" | null = null;
|
|
118
|
+
private buf = "";
|
|
119
|
+
private flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
// Stable for this process so reconnects keep the same terminal identity.
|
|
121
|
+
private readonly termId = randomUUID();
|
|
122
|
+
private readonly label = terminalLabel();
|
|
123
|
+
|
|
124
|
+
constructor(private readonly cb: RelayCallbacks) {}
|
|
125
|
+
|
|
126
|
+
async start(): Promise<void> {
|
|
127
|
+
this.closed = false;
|
|
128
|
+
await this.connect();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
stop(): void {
|
|
132
|
+
this.closed = true;
|
|
133
|
+
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = undefined; }
|
|
134
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
135
|
+
this.bufKind = null;
|
|
136
|
+
this.buf = "";
|
|
137
|
+
try { this.ws?.close(); } catch (_) { /* ignore */ }
|
|
138
|
+
this.ws = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async connect(): Promise<void> {
|
|
142
|
+
if (this.closed || this.connecting || this.ws) return;
|
|
143
|
+
this.connecting = true;
|
|
144
|
+
try {
|
|
145
|
+
// apiRequest → authedFetch refreshes the JWT on 401, so a single in-flight
|
|
146
|
+
// ticket mint never races a second refresher (we guard with `connecting`).
|
|
147
|
+
const res = await apiRequest("/relay/ticket", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ role: "agent", termId: this.termId, label: this.label }),
|
|
151
|
+
});
|
|
152
|
+
if (!res.ok) throw new Error(`relay ticket HTTP ${res.status}`);
|
|
153
|
+
const { ticket } = (await res.json()) as { ticket: string };
|
|
154
|
+
|
|
155
|
+
const wsUrl =
|
|
156
|
+
serverBaseUrl().replace(/^http/, "ws") + `/relay?ticket=${encodeURIComponent(ticket)}`;
|
|
157
|
+
this.debug(`connecting → ${wsUrl}`);
|
|
158
|
+
const ws = new WebSocket(wsUrl);
|
|
159
|
+
this.ws = ws;
|
|
160
|
+
let opened = false;
|
|
161
|
+
let lastErr = "";
|
|
162
|
+
|
|
163
|
+
ws.on("open", () => {
|
|
164
|
+
opened = true;
|
|
165
|
+
this.cb.onStatus?.("Remote access connected — drive this terminal from the Privateer app.");
|
|
166
|
+
});
|
|
167
|
+
ws.on("message", (data) => this.handle(data));
|
|
168
|
+
ws.on("close", () => {
|
|
169
|
+
if (this.ws === ws) this.ws = null;
|
|
170
|
+
if (!this.closed) {
|
|
171
|
+
this.cb.onStatus?.(
|
|
172
|
+
opened
|
|
173
|
+
? "Remote access disconnected — reconnecting…"
|
|
174
|
+
: `Remote access couldn't connect${lastErr ? ` (${lastErr})` : ""} — retrying…`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
this.scheduleReconnect();
|
|
178
|
+
});
|
|
179
|
+
ws.on("error", (err: Error) => {
|
|
180
|
+
// 'close' fires right after and surfaces the reason; just capture it.
|
|
181
|
+
lastErr = err?.message || String(err);
|
|
182
|
+
this.debug(`ws error: ${lastErr}`);
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// Ticket mint failed (auth/network/route) — surface it; a silent failure
|
|
186
|
+
// looks identical to "connected but ignoring me".
|
|
187
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
188
|
+
this.cb.onStatus?.(`Remote access couldn't reach the relay (${msg}) — retrying…`);
|
|
189
|
+
this.scheduleReconnect();
|
|
190
|
+
} finally {
|
|
191
|
+
this.connecting = false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private debug(msg: string): void {
|
|
196
|
+
if (process.env.PRIVATEER_RELAY_DEBUG) this.cb.onStatus?.(`relay: ${msg}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private scheduleReconnect(): void {
|
|
200
|
+
if (this.closed || this.reconnectTimer) return;
|
|
201
|
+
this.reconnectTimer = setTimeout(() => {
|
|
202
|
+
this.reconnectTimer = undefined;
|
|
203
|
+
void this.connect();
|
|
204
|
+
}, RECONNECT_MS);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private handle(data: WebSocket.RawData): void {
|
|
208
|
+
let frame: { type?: string; text?: string; id?: string; decision?: string };
|
|
209
|
+
try {
|
|
210
|
+
frame = JSON.parse(data.toString());
|
|
211
|
+
} catch (_) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.debug(`recv ${frame.type}`);
|
|
215
|
+
switch (frame.type) {
|
|
216
|
+
case "prompt":
|
|
217
|
+
if (typeof frame.text === "string" && frame.text.trim()) this.cb.onPrompt(frame.text);
|
|
218
|
+
break;
|
|
219
|
+
case "interrupt":
|
|
220
|
+
this.cb.onInterrupt();
|
|
221
|
+
break;
|
|
222
|
+
case "approval_response":
|
|
223
|
+
if (frame.id) this.cb.onApprovalResponse(frame.id, frame.decision === "deny" ? "deny" : "allow");
|
|
224
|
+
break;
|
|
225
|
+
case "controller_attached":
|
|
226
|
+
this.cb.onControllerAttached();
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private rawSend(frame: unknown): void {
|
|
232
|
+
const ws = this.ws;
|
|
233
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
234
|
+
try { ws.send(JSON.stringify(frame)); } catch (_) { /* socket dying */ }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── agent → controller ──────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
sendEvent(ev: EngineEvent): void {
|
|
241
|
+
if (ev.type === "text") return this.bufferDelta("text", ev.text);
|
|
242
|
+
if (ev.type === "reasoning") return this.bufferDelta("reasoning", ev.text);
|
|
243
|
+
this.flushDeltas(); // preserve ordering relative to buffered text
|
|
244
|
+
this.rawSend({ type: "event", event: projectEvent(ev) });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Catch-up history sent to a controller on attach. Structured (not markdown) so
|
|
248
|
+
// the app renders it with the same styling as the live feed. Bounded: last 80
|
|
249
|
+
// entries, each clipped.
|
|
250
|
+
sendSnapshot(entries: { kind: string; text: string }[]): void {
|
|
251
|
+
const trimmed = entries.slice(-80).map((e) => ({ kind: e.kind, text: safe(String(e.text ?? ""), 4000) }));
|
|
252
|
+
this.rawSend({ type: "snapshot", entries: trimmed });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
requestApproval(id: string, req: PermissionRequest): void {
|
|
256
|
+
this.rawSend({
|
|
257
|
+
type: "approval_request",
|
|
258
|
+
id,
|
|
259
|
+
req: { tool: req.tool, kind: req.kind, title: req.title, detail: safe(req.detail, 4000), outside: !!req.outside },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private bufferDelta(kind: "text" | "reasoning", text: string): void {
|
|
264
|
+
if (this.bufKind && this.bufKind !== kind) this.flushDeltas();
|
|
265
|
+
this.bufKind = kind;
|
|
266
|
+
this.buf += text;
|
|
267
|
+
if (!this.flushTimer) {
|
|
268
|
+
this.flushTimer = setTimeout(() => {
|
|
269
|
+
this.flushTimer = undefined;
|
|
270
|
+
this.flushDeltas();
|
|
271
|
+
}, TEXT_FLUSH_MS);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private flushDeltas(): void {
|
|
276
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = undefined; }
|
|
277
|
+
if (this.bufKind && this.buf) {
|
|
278
|
+
this.rawSend({ type: "event", event: { type: this.bufKind, text: this.buf } });
|
|
279
|
+
}
|
|
280
|
+
this.bufKind = null;
|
|
281
|
+
this.buf = "";
|
|
282
|
+
}
|
|
283
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { LanguageModel, ToolSet } from "ai";
|
|
2
|
+
import type { Config } from "./config/schema.ts";
|
|
3
|
+
import { resolveModel, parseModelSpec } from "./providers/resolve.ts";
|
|
4
|
+
import { modelSupports, modalitiesFor, suggestModelFor } from "./providers/capabilities.ts";
|
|
5
|
+
import type { Route, RouteSet, Modality } from "./engine/router.ts";
|
|
6
|
+
import { createTools, createReadOnlyTools, createToolSubset } from "./tools/index.ts";
|
|
7
|
+
import { buildSystemPrompt, buildSubAgentPrompt, buildAgentPrompt } from "./context/systemPrompt.ts";
|
|
8
|
+
import { findOutputStyle } from "./context/outputStyles.ts";
|
|
9
|
+
import { QueryEngine } from "./engine/QueryEngine.ts";
|
|
10
|
+
import { autoApproveGate, type PermissionGate } from "./permissions/gate.ts";
|
|
11
|
+
import type { SubAgentRunner } from "./tools/context.ts";
|
|
12
|
+
import { TodoStore } from "./tools/todoStore.ts";
|
|
13
|
+
import type { CheckpointStore } from "./memory/checkpoints.ts";
|
|
14
|
+
import type { ProcessRegistry } from "./tools/processRegistry.ts";
|
|
15
|
+
import { AttachmentStore } from "./util/attachmentStore.ts";
|
|
16
|
+
import { HookRunner, loadHooks, wrapToolsWithHooks } from "./hooks/engine.ts";
|
|
17
|
+
import { createLimiter } from "./util/limit.ts";
|
|
18
|
+
|
|
19
|
+
export interface SessionOptions {
|
|
20
|
+
config: Config;
|
|
21
|
+
modelSpec: string;
|
|
22
|
+
cwd: string;
|
|
23
|
+
gate?: PermissionGate;
|
|
24
|
+
// Confine file tools to cwd (default true). False lets the agent read/edit anywhere.
|
|
25
|
+
confineToCwd?: boolean;
|
|
26
|
+
// Out-of-cwd directories approved this session; shared with the gate so an approved
|
|
27
|
+
// location stops re-prompting. Pass the same array instance the gate holds.
|
|
28
|
+
allowedOutsideRoots?: string[];
|
|
29
|
+
// Active output style name (persona); resolved against .privateer/output-styles.
|
|
30
|
+
outputStyle?: string;
|
|
31
|
+
// When true, the system prompt instructs the model to plan, not implement.
|
|
32
|
+
planMode?: boolean;
|
|
33
|
+
// Session checkpoint store; write/edit record mutations into it for /rewind.
|
|
34
|
+
checkpoints?: CheckpointStore;
|
|
35
|
+
// Extra tools merged into the toolset (e.g. tools exposed by MCP servers).
|
|
36
|
+
extraTools?: ToolSet;
|
|
37
|
+
// Background-shell registry for bash run_in_background / bash_output / kill_shell.
|
|
38
|
+
processes?: ProcessRegistry;
|
|
39
|
+
// Session attachment store, so dragged/pasted file bytes can be saved via the
|
|
40
|
+
// save_attachment tool. Created here when the caller doesn't supply one.
|
|
41
|
+
attachments?: AttachmentStore;
|
|
42
|
+
// Reports each finished `task` sub-agent's run metrics (tool uses + tokens) by
|
|
43
|
+
// tool-call id, so the TUI can render the grouped agents view. Best-effort.
|
|
44
|
+
onSubAgentMetrics?: (toolCallId: string, m: { toolUses: number; tokens: number }) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Session {
|
|
48
|
+
engine: QueryEngine;
|
|
49
|
+
modelSpec: string;
|
|
50
|
+
provider: string;
|
|
51
|
+
modelId: string;
|
|
52
|
+
cwd: string;
|
|
53
|
+
todos: TodoStore;
|
|
54
|
+
attachments: AttachmentStore;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Assemble a ready-to-run agent session: resolve the model, bind tools to the
|
|
58
|
+
// cwd + permission gate, build the system prompt, and create the engine.
|
|
59
|
+
export function createSession(opts: SessionOptions): Session {
|
|
60
|
+
const resolved = resolveModel(opts.modelSpec, opts.config);
|
|
61
|
+
const gate = opts.gate ?? autoApproveGate;
|
|
62
|
+
const confineToCwd = opts.confineToCwd ?? true;
|
|
63
|
+
const allowedOutsideRoots = opts.allowedOutsideRoots ?? [];
|
|
64
|
+
const todos = new TodoStore();
|
|
65
|
+
const attachments = opts.attachments ?? new AttachmentStore();
|
|
66
|
+
const cache = isAnthropicFamily(resolved.provider, resolved.modelId);
|
|
67
|
+
|
|
68
|
+
// Bound how many sub-agents run at once when the model fans `task` calls out.
|
|
69
|
+
const subAgentLimit = createLimiter(opts.config.maxSubagents);
|
|
70
|
+
|
|
71
|
+
// A `task` sub-agent: a fresh engine run to completion, returning the text it
|
|
72
|
+
// produced. Without an agent definition it uses the read-only toolset under an
|
|
73
|
+
// auto-approve gate; with one it uses that agent's tools (routed through the parent
|
|
74
|
+
// gate, so any mutations are still user-approved), model override, and instructions.
|
|
75
|
+
const runSubAgent: SubAgentRunner = ({ description, prompt, agent }) =>
|
|
76
|
+
subAgentLimit(async () => {
|
|
77
|
+
let model = resolved.model;
|
|
78
|
+
let childCache = cache;
|
|
79
|
+
if (agent?.model) {
|
|
80
|
+
try {
|
|
81
|
+
const r = resolveModel(agent.model, opts.config);
|
|
82
|
+
model = r.model;
|
|
83
|
+
childCache = isAnthropicFamily(r.provider, r.modelId);
|
|
84
|
+
} catch {
|
|
85
|
+
/* fall back to the parent model */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const system = agent
|
|
89
|
+
? buildAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description, instructions: agent.prompt })
|
|
90
|
+
: buildSubAgentPrompt({ cwd: opts.cwd, model: opts.modelSpec, description });
|
|
91
|
+
const tools = agent
|
|
92
|
+
? createToolSubset({ cwd: opts.cwd, gate, confineToCwd, allowedOutsideRoots }, agent.tools)
|
|
93
|
+
: createReadOnlyTools({ cwd: opts.cwd, gate: autoApproveGate, confineToCwd, allowedOutsideRoots });
|
|
94
|
+
|
|
95
|
+
const child = new QueryEngine({
|
|
96
|
+
routes: singleRouteSet(agent?.model ?? opts.modelSpec, model, childCache),
|
|
97
|
+
system,
|
|
98
|
+
tools,
|
|
99
|
+
maxSteps: Math.min(opts.config.maxSteps, 20),
|
|
100
|
+
});
|
|
101
|
+
let out = "";
|
|
102
|
+
let toolUses = 0;
|
|
103
|
+
for await (const ev of child.send(prompt)) {
|
|
104
|
+
if (ev.type === "text") out += ev.text;
|
|
105
|
+
else if (ev.type === "tool-call") toolUses++;
|
|
106
|
+
else if (ev.type === "error")
|
|
107
|
+
return { text: `Sub-agent error: ${ev.error}`, toolUses, tokens: child.usage.totalTokens };
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
text: out.trim() || "(sub-agent returned no output)",
|
|
111
|
+
toolUses,
|
|
112
|
+
tokens: child.usage.totalTokens,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const hooks = new HookRunner(loadHooks((opts.config as Record<string, unknown>).hooks), opts.cwd);
|
|
117
|
+
const tools = wrapToolsWithHooks(
|
|
118
|
+
{
|
|
119
|
+
...createTools({
|
|
120
|
+
cwd: opts.cwd,
|
|
121
|
+
gate,
|
|
122
|
+
confineToCwd,
|
|
123
|
+
allowedOutsideRoots,
|
|
124
|
+
todos,
|
|
125
|
+
runSubAgent,
|
|
126
|
+
onSubAgentMetrics: opts.onSubAgentMetrics,
|
|
127
|
+
recordMutation: opts.checkpoints ? (abs) => opts.checkpoints!.recordMutation(abs) : undefined,
|
|
128
|
+
processes: opts.processes,
|
|
129
|
+
attachments,
|
|
130
|
+
}),
|
|
131
|
+
...(opts.extraTools ?? {}),
|
|
132
|
+
},
|
|
133
|
+
hooks,
|
|
134
|
+
);
|
|
135
|
+
const outputStyleBody = opts.outputStyle
|
|
136
|
+
? findOutputStyle(opts.outputStyle, opts.cwd)?.body
|
|
137
|
+
: undefined;
|
|
138
|
+
const system = buildSystemPrompt({
|
|
139
|
+
cwd: opts.cwd,
|
|
140
|
+
model: opts.modelSpec,
|
|
141
|
+
outputStyleBody,
|
|
142
|
+
planMode: opts.planMode,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const engine = new QueryEngine({
|
|
146
|
+
routes: buildRouteSet(opts.config, opts.modelSpec, resolved.model, cache),
|
|
147
|
+
system,
|
|
148
|
+
tools,
|
|
149
|
+
maxSteps: opts.config.maxSteps,
|
|
150
|
+
contextBudget: opts.config.contextBudget,
|
|
151
|
+
compactRatio: opts.config.compactRatio,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
engine,
|
|
156
|
+
modelSpec: opts.modelSpec,
|
|
157
|
+
provider: resolved.provider,
|
|
158
|
+
modelId: resolved.modelId,
|
|
159
|
+
cwd: opts.cwd,
|
|
160
|
+
todos,
|
|
161
|
+
attachments,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Anthropic prompt caching only benefits Anthropic-family models: direct Anthropic,
|
|
166
|
+
// or an OpenRouter route to an Anthropic model. For everything else the cache hints
|
|
167
|
+
// are a harmless no-op, but we skip them to avoid sending unused providerOptions.
|
|
168
|
+
function isAnthropicFamily(provider: string, modelId: string): boolean {
|
|
169
|
+
if (provider === "anthropic") return true;
|
|
170
|
+
if (provider === "openrouter") return modelId.startsWith("anthropic/");
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Short display name for UI notices: drop any "vendor/" prefix from the model id.
|
|
175
|
+
function shortLabel(spec: string): string {
|
|
176
|
+
const modelId = spec.includes(":") ? spec.slice(spec.indexOf(":") + 1) : spec;
|
|
177
|
+
return modelId.slice(modelId.lastIndexOf("/") + 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Resolve a "provider:model" spec into a Route, deriving its per-model cache /
|
|
181
|
+
// thinking flags and supported input modalities from the model family.
|
|
182
|
+
function buildRoute(spec: string, config: Config): Route {
|
|
183
|
+
const r = resolveModel(spec, config);
|
|
184
|
+
const cache = isAnthropicFamily(r.provider, r.modelId);
|
|
185
|
+
return {
|
|
186
|
+
spec,
|
|
187
|
+
model: r.model,
|
|
188
|
+
cacheControl: cache,
|
|
189
|
+
thinkingBudget: cache ? config.thinkingBudget : undefined,
|
|
190
|
+
label: shortLabel(spec),
|
|
191
|
+
supports: modalitiesFor(r.provider, r.modelId),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A trivial RouteSet with only the default route (sub-agents, which run one fixed
|
|
196
|
+
// model). The high `longThreshold` / zero `fastMaxChars` keep the router on default.
|
|
197
|
+
function singleRouteSet(spec: string, model: LanguageModel, cacheControl: boolean): RouteSet {
|
|
198
|
+
const { provider, modelId } = parseModelSpec(spec);
|
|
199
|
+
return {
|
|
200
|
+
default: { spec, model, cacheControl, label: shortLabel(spec), supports: modalitiesFor(provider, modelId) },
|
|
201
|
+
longThreshold: Number.POSITIVE_INFINITY,
|
|
202
|
+
fastMaxChars: 0,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Pairs of (config key, RouteSet key, modality) for the modality routes.
|
|
207
|
+
const MODALITY_ROUTE_KEYS: { cfg: "vision" | "document" | "audio" | "video"; modality: Modality }[] = [
|
|
208
|
+
{ cfg: "vision", modality: "image" },
|
|
209
|
+
{ cfg: "document", modality: "document" },
|
|
210
|
+
{ cfg: "audio", modality: "audio" },
|
|
211
|
+
{ cfg: "video", modality: "video" },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
// Assemble the session's RouteSet: the default route (the already-resolved session
|
|
215
|
+
// model) plus any configured modality/long/fast routes, each tagged with the input
|
|
216
|
+
// modalities its model accepts. Optional routes that fail to resolve are skipped
|
|
217
|
+
// rather than failing the session. For each modality whose route is unset, hybrid
|
|
218
|
+
// auto-detect picks a capable model when the default can't handle that modality.
|
|
219
|
+
function buildRouteSet(
|
|
220
|
+
config: Config,
|
|
221
|
+
defaultSpec: string,
|
|
222
|
+
defaultModel: LanguageModel,
|
|
223
|
+
defaultCache: boolean,
|
|
224
|
+
): RouteSet {
|
|
225
|
+
const router = config.router;
|
|
226
|
+
const tryRoute = (spec?: string): Route | undefined => {
|
|
227
|
+
if (!spec) return undefined;
|
|
228
|
+
try {
|
|
229
|
+
return buildRoute(spec, config);
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined; // unconfigured/invalid optional route → ignored
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const { provider: defProvider, modelId: defModelId } = parseModelSpec(defaultSpec);
|
|
236
|
+
const routes: RouteSet = {
|
|
237
|
+
default: {
|
|
238
|
+
spec: defaultSpec,
|
|
239
|
+
model: defaultModel,
|
|
240
|
+
cacheControl: defaultCache,
|
|
241
|
+
thinkingBudget: defaultCache ? config.thinkingBudget : undefined,
|
|
242
|
+
label: shortLabel(defaultSpec),
|
|
243
|
+
supports: modalitiesFor(defProvider, defModelId),
|
|
244
|
+
},
|
|
245
|
+
vision: tryRoute(router?.vision),
|
|
246
|
+
document: tryRoute(router?.document),
|
|
247
|
+
audio: tryRoute(router?.audio),
|
|
248
|
+
video: tryRoute(router?.video),
|
|
249
|
+
long: tryRoute(router?.long),
|
|
250
|
+
fast: tryRoute(router?.fast),
|
|
251
|
+
longThreshold: router?.longThreshold ?? Math.floor((config.contextBudget ?? 120_000) / 2),
|
|
252
|
+
fastMaxChars: router?.fastMaxChars ?? 280,
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
if (router?.auto ?? true) {
|
|
256
|
+
for (const { cfg, modality } of MODALITY_ROUTE_KEYS) {
|
|
257
|
+
if (routes[cfg]) continue; // explicitly configured → leave it
|
|
258
|
+
if (modelSupports(modality, defProvider, defModelId)) continue; // default handles it
|
|
259
|
+
const suggestion = suggestModelFor(modality, config);
|
|
260
|
+
if (suggestion) routes[cfg] = tryRoute(suggestion);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return routes;
|
|
264
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { tool } from "ai";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { ToolContext } from "./context.ts";
|
|
4
|
+
import { exec } from "./exec.ts";
|
|
5
|
+
import { PermissionDeniedError } from "../permissions/gate.ts";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT = 120_000;
|
|
8
|
+
const MAX_TIMEOUT = 600_000;
|
|
9
|
+
// Cap how much command output enters the conversation. Unbounded output (a big
|
|
10
|
+
// `git diff`, a verbose build log) is otherwise re-sent on every subsequent step of
|
|
11
|
+
// the agentic loop, ballooning token usage. Keep the head and tail — both ends carry
|
|
12
|
+
// the most signal (the command's start and its final status/errors).
|
|
13
|
+
const MAX_OUTPUT_CHARS = 30_000;
|
|
14
|
+
const HEAD_CHARS = 20_000;
|
|
15
|
+
|
|
16
|
+
function clampOutput(text: string): string {
|
|
17
|
+
if (text.length <= MAX_OUTPUT_CHARS) return text;
|
|
18
|
+
const head = text.slice(0, HEAD_CHARS);
|
|
19
|
+
const tail = text.slice(text.length - (MAX_OUTPUT_CHARS - HEAD_CHARS));
|
|
20
|
+
const omitted = text.length - MAX_OUTPUT_CHARS;
|
|
21
|
+
return `${head}\n… (${omitted} chars of output truncated) …\n${tail}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bashTool(ctx: ToolContext) {
|
|
25
|
+
return tool({
|
|
26
|
+
description:
|
|
27
|
+
"Run a shell command in the working directory and return its output. " +
|
|
28
|
+
"Use for builds, tests, git, and other CLI tasks. Avoid long-running/interactive commands.",
|
|
29
|
+
inputSchema: z.object({
|
|
30
|
+
command: z.string().describe("The shell command to run."),
|
|
31
|
+
timeout: z.number().int().positive().optional().describe("Timeout in ms (max 600000)."),
|
|
32
|
+
run_in_background: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Run detached and return immediately; poll with bash_output, stop with kill_shell."),
|
|
36
|
+
}),
|
|
37
|
+
execute: async ({ command, timeout, run_in_background }) => {
|
|
38
|
+
const decision = await ctx.gate.request({
|
|
39
|
+
tool: "bash",
|
|
40
|
+
kind: "bash",
|
|
41
|
+
title: run_in_background ? "Run command (background)" : "Run command",
|
|
42
|
+
detail: command,
|
|
43
|
+
});
|
|
44
|
+
if (decision === "deny") throw new PermissionDeniedError("bash");
|
|
45
|
+
|
|
46
|
+
if (run_in_background) {
|
|
47
|
+
if (!ctx.processes) return "Background execution is not available in this context.";
|
|
48
|
+
const id = ctx.processes.spawn(command, ctx.cwd);
|
|
49
|
+
return `Started in background as ${id}. Read output with bash_output(bash_id="${id}"); stop with kill_shell(bash_id="${id}").`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
|
53
|
+
const { stdout, stderr, code, timedOut } = await exec(command, [], {
|
|
54
|
+
cwd: ctx.cwd,
|
|
55
|
+
timeoutMs,
|
|
56
|
+
shell: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const parts: string[] = [];
|
|
60
|
+
if (stdout.trim()) parts.push(clampOutput(stdout.trimEnd()));
|
|
61
|
+
if (stderr.trim()) parts.push(`[stderr]\n${clampOutput(stderr.trimEnd())}`);
|
|
62
|
+
if (timedOut) parts.push(`[timed out after ${timeoutMs}ms]`);
|
|
63
|
+
parts.push(`[exit code ${code ?? "null"}]`);
|
|
64
|
+
return parts.join("\n");
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function bashOutputTool(ctx: ToolContext) {
|
|
70
|
+
return tool({
|
|
71
|
+
description:
|
|
72
|
+
"Read new output from a background shell started with bash run_in_background. Returns only " +
|
|
73
|
+
"output produced since the previous read, plus the shell's status.",
|
|
74
|
+
inputSchema: z.object({
|
|
75
|
+
bash_id: z.string().describe("The background shell id (e.g. bash_1)."),
|
|
76
|
+
}),
|
|
77
|
+
execute: async ({ bash_id }) => {
|
|
78
|
+
if (!ctx.processes) return "Background processes are not available in this context.";
|
|
79
|
+
const r = ctx.processes.read(bash_id);
|
|
80
|
+
if (!r) return `No background shell "${bash_id}".`;
|
|
81
|
+
const head = `[${r.status}${r.status === "exited" ? `, exit ${r.code ?? "null"}` : ""}]`;
|
|
82
|
+
return r.output ? `${head}\n${r.output.trimEnd()}` : `${head} (no new output)`;
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function killShellTool(ctx: ToolContext) {
|
|
88
|
+
return tool({
|
|
89
|
+
description: "Stop a background shell started with bash run_in_background.",
|
|
90
|
+
inputSchema: z.object({
|
|
91
|
+
bash_id: z.string().describe("The background shell id to stop."),
|
|
92
|
+
}),
|
|
93
|
+
execute: async ({ bash_id }) => {
|
|
94
|
+
if (!ctx.processes) return "Background processes are not available in this context.";
|
|
95
|
+
return ctx.processes.kill(bash_id) ? `Stopped ${bash_id}.` : `No background shell "${bash_id}".`;
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|