@saadnvd1/agent-os 0.3.471 → 0.3.472
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/README.md +100 -0
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +7 -4
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +38 -3
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +3 -0
- package/lib/agents/launch.ts +11 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +13 -2
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +28 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claude-cli.ts +44 -15
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/review.ts +2 -0
- package/lib/orchestrator/scope.ts +2 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +38 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/index.ts +59 -8
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +5 -2
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +26 -0
- package/lib/usage/turns.ts +79 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
package/lib/agents/launch.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { execFile } from "child_process";
|
|
5
5
|
import { promisify } from "util";
|
|
6
6
|
import { getProvider } from "../providers";
|
|
7
|
+
import { sessionShellEnv } from "../models/launch";
|
|
7
8
|
import { shellQuote } from "../hosts/ssh";
|
|
8
9
|
import { runInBackground } from "../async-operations";
|
|
9
10
|
import { trustPromptKeys } from "../tasks/state";
|
|
@@ -64,6 +65,8 @@ export async function launchClaude(opts: {
|
|
|
64
65
|
tmuxName: string;
|
|
65
66
|
cwd: string;
|
|
66
67
|
model: string;
|
|
68
|
+
// The model's provider (lib/models); Anthropic when absent.
|
|
69
|
+
provider?: string | null;
|
|
67
70
|
prompt: string;
|
|
68
71
|
brief?: string;
|
|
69
72
|
// Continue this Claude conversation; the prompt is its next message.
|
|
@@ -88,7 +91,13 @@ export async function launchClaude(opts: {
|
|
|
88
91
|
sessionId: opts.resume,
|
|
89
92
|
})
|
|
90
93
|
.join(" ");
|
|
91
|
-
|
|
94
|
+
// The provider's key is fetched by the pane's own shell from its
|
|
95
|
+
// reference: only the reference is in this command line.
|
|
96
|
+
const routing = sessionShellEnv({
|
|
97
|
+
model: opts.model,
|
|
98
|
+
model_provider: opts.provider ?? null,
|
|
99
|
+
});
|
|
100
|
+
const agent = `export PATH=${shellQuote(AOS_BIN_DIR)}:"$HOME/.local/bin:$PATH"; ${routing.exportKey} ${provider.command} ${flags} ${CLAUDE_STATUS_SETTINGS_FLAG} --append-system-prompt-file ${shellQuote(briefFile)} "$(cat ${shellQuote(promptFile)})"; exec "\${SHELL:-/bin/sh}" -l`;
|
|
92
101
|
await execFileAsync(
|
|
93
102
|
"tmux",
|
|
94
103
|
[
|
|
@@ -98,7 +107,7 @@ export async function launchClaude(opts: {
|
|
|
98
107
|
opts.tmuxName,
|
|
99
108
|
"-c",
|
|
100
109
|
opts.cwd,
|
|
101
|
-
...envArgs(agentEnv(opts.sessionId)),
|
|
110
|
+
...envArgs({ ...agentEnv(opts.sessionId), ...routing.env }),
|
|
102
111
|
agent,
|
|
103
112
|
],
|
|
104
113
|
{ cwd: opts.cwd }
|
package/lib/chat/context.ts
CHANGED
package/lib/chat/runner.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* events to whoever is watching. Restarting the server leaves turns running.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { handoffAfterPlan } from "../models/plan-step";
|
|
8
9
|
import { randomUUID } from "crypto";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import { db, type Session } from "../db";
|
|
@@ -163,6 +164,10 @@ function onWorkerEvent(sessionId: string, live: Live, e: WorkerEvent): void {
|
|
|
163
164
|
clearTimeout(live.retiring);
|
|
164
165
|
live.retiring = undefined;
|
|
165
166
|
retireIfStale(sessionId, live);
|
|
167
|
+
if (e.state === "idle")
|
|
168
|
+
void handoffAfterPlan(sessionId, (id, m) => sendQueuedNow(id, m)).catch(
|
|
169
|
+
(err) => console.error(`Plan handoff for ${sessionId} failed:`, err)
|
|
170
|
+
);
|
|
166
171
|
} else if (e.type === "suggestion") {
|
|
167
172
|
emit(sessionId, e);
|
|
168
173
|
if (live.retiring && e.text) retire(sessionId, live);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Session } from "../../db";
|
|
2
2
|
import type { ChatStartOptions } from "../driver";
|
|
3
3
|
import { refreshSettings } from "../refresh";
|
|
4
|
+
import { sessionProviderEnv } from "../../models/launch";
|
|
4
5
|
|
|
5
6
|
export type RoleExtras = Pick<
|
|
6
7
|
ChatStartOptions,
|
|
@@ -17,11 +18,22 @@ export type RoleExtras = Pick<
|
|
|
17
18
|
// Start over on a new conversation once its context passes this many
|
|
18
19
|
// tokens (lib/chat/refresh); absent or 0, never.
|
|
19
20
|
refresh?: { atTokens: number; role: "orchestrator" | "task" };
|
|
21
|
+
// What the session's model provider needs in the agent's environment
|
|
22
|
+
// (lib/models), its key fetched just now. Held by this process only.
|
|
23
|
+
env?: Record<string, string>;
|
|
20
24
|
};
|
|
21
25
|
|
|
22
26
|
// What a session's role adds to its chat. An orchestrator's module loads
|
|
23
27
|
// only for one; a task's chat gets its brief, as its terminal agent would.
|
|
24
28
|
export async function roleExtras(session: Session): Promise<RoleExtras> {
|
|
29
|
+
const [extras, env] = await Promise.all([
|
|
30
|
+
ownExtras(session),
|
|
31
|
+
sessionProviderEnv(session),
|
|
32
|
+
]);
|
|
33
|
+
return env ? { ...extras, env } : extras;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function ownExtras(session: Session): Promise<RoleExtras> {
|
|
25
37
|
if (session.role === "orchestrator")
|
|
26
38
|
return (await import("../../orchestrator/worker")).orchestratorExtras(
|
|
27
39
|
session
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -76,6 +76,8 @@ export class ChatHost {
|
|
|
76
76
|
// resumed conversation's saved totals.
|
|
77
77
|
private usage: UsageTotals | null;
|
|
78
78
|
private turnsRecorded = 0;
|
|
79
|
+
// When the running turn started, for its wall time.
|
|
80
|
+
private turnStartedAt: number | null = null;
|
|
79
81
|
// A permission mode change still on its way to the agent: a message sent
|
|
80
82
|
// right after it (carrying out a plan) must not overtake it.
|
|
81
83
|
private modeChange: Promise<void> = Promise.resolve();
|
|
@@ -161,7 +163,7 @@ export class ChatHost {
|
|
|
161
163
|
throw new Error(`${session.agent_type} can't enforce this role's tools`);
|
|
162
164
|
this.atRest = driver.atRest !== false;
|
|
163
165
|
const cwd = session.working_directory.replace(/^~/, os.homedir());
|
|
164
|
-
const env = agentEnv(session.id);
|
|
166
|
+
const env = { ...agentEnv(session.id), ...extras.env };
|
|
165
167
|
// Every chat can show visuals, asked for like any tool under its access
|
|
166
168
|
// setting (they read files and reach the web). Not an orchestrator: it
|
|
167
169
|
// reads other sessions' text, and a browser would be a way out for it.
|
|
@@ -290,7 +292,15 @@ export class ChatHost {
|
|
|
290
292
|
this.setSuggestion(e.text);
|
|
291
293
|
this.retireIfDone();
|
|
292
294
|
} else if (e.type === "usage") {
|
|
293
|
-
|
|
295
|
+
const at = Date.now();
|
|
296
|
+
this.usage = recordTurn(
|
|
297
|
+
this.session,
|
|
298
|
+
this.usage,
|
|
299
|
+
e.totals,
|
|
300
|
+
at,
|
|
301
|
+
this.turnStartedAt === null ? null : at - this.turnStartedAt
|
|
302
|
+
);
|
|
303
|
+
this.turnStartedAt = null;
|
|
294
304
|
this.turnsRecorded++;
|
|
295
305
|
} else if (e.type === "usage_start") {
|
|
296
306
|
// Too late once a turn was measured without it.
|
|
@@ -306,6 +316,7 @@ export class ChatHost {
|
|
|
306
316
|
this.refreshIfFull(e.context.usedTokens);
|
|
307
317
|
} else if (e.type === "turn_start") {
|
|
308
318
|
this.turnsStarted++;
|
|
319
|
+
this.turnStartedAt ??= Date.now();
|
|
309
320
|
if (this.behind) this.behind--;
|
|
310
321
|
clearTimeout(this.restTimer);
|
|
311
322
|
this.restTimer = undefined;
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -29,13 +29,16 @@ async function main(sessionId: string) {
|
|
|
29
29
|
const { db } = await import("../../db");
|
|
30
30
|
const { ChatHost } = await import("./host");
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const row = db
|
|
33
33
|
.prepare(`SELECT * FROM sessions WHERE id = ?`)
|
|
34
34
|
.get(sessionId) as Session | undefined;
|
|
35
|
-
if (!
|
|
35
|
+
if (!row) {
|
|
36
36
|
console.error(`No session ${sessionId}`);
|
|
37
37
|
process.exit(1);
|
|
38
38
|
}
|
|
39
|
+
const session = await (
|
|
40
|
+
await import("../../models/launch")
|
|
41
|
+
).routeOrchestrator(row);
|
|
39
42
|
|
|
40
43
|
const clients = new Set<net.Socket>();
|
|
41
44
|
const emit = (e: WorkerEvent) => {
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1400,6 +1400,34 @@ const migrations: Migration[] = [
|
|
|
1400
1400
|
`);
|
|
1401
1401
|
},
|
|
1402
1402
|
},
|
|
1403
|
+
{
|
|
1404
|
+
id: 61,
|
|
1405
|
+
name: "model_routing",
|
|
1406
|
+
// One transaction, so a crash part-way leaves nothing to skip as
|
|
1407
|
+
// "duplicate column" on the next start.
|
|
1408
|
+
up: (db) =>
|
|
1409
|
+
db.transaction(() => {
|
|
1410
|
+
// What a session runs on beyond the model name: its provider (NULL is
|
|
1411
|
+
// Anthropic), a task's own models by role (JSON), and a task's plan
|
|
1412
|
+
// step (planning, then implementing). Each turn keeps the model,
|
|
1413
|
+
// provider and role it ran as, how long it took, and the plan's 5-hour
|
|
1414
|
+
// window when it ended, so spend compares by model. A check run keeps the
|
|
1415
|
+
// provider its model came from.
|
|
1416
|
+
for (const col of [
|
|
1417
|
+
`ALTER TABLE sessions ADD COLUMN model_provider TEXT`,
|
|
1418
|
+
`ALTER TABLE sessions ADD COLUMN model_overrides TEXT`,
|
|
1419
|
+
`ALTER TABLE sessions ADD COLUMN plan_phase TEXT`,
|
|
1420
|
+
`ALTER TABLE task_queue ADD COLUMN model_overrides TEXT`,
|
|
1421
|
+
`ALTER TABLE chat_turns ADD COLUMN model TEXT`,
|
|
1422
|
+
`ALTER TABLE chat_turns ADD COLUMN provider TEXT`,
|
|
1423
|
+
`ALTER TABLE chat_turns ADD COLUMN role TEXT`,
|
|
1424
|
+
`ALTER TABLE chat_turns ADD COLUMN duration_ms INTEGER`,
|
|
1425
|
+
`ALTER TABLE chat_turns ADD COLUMN window_pct REAL`,
|
|
1426
|
+
`ALTER TABLE check_runs ADD COLUMN provider TEXT`,
|
|
1427
|
+
])
|
|
1428
|
+
db.exec(col);
|
|
1429
|
+
})(),
|
|
1430
|
+
},
|
|
1403
1431
|
];
|
|
1404
1432
|
|
|
1405
1433
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/db/types.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface Session {
|
|
|
14
14
|
parent_session_id: string | null;
|
|
15
15
|
claude_session_id: string | null;
|
|
16
16
|
model: string;
|
|
17
|
+
// The model's provider (lib/models), NULL for Anthropic.
|
|
18
|
+
model_provider?: string | null;
|
|
19
|
+
// A task's own models: "provider/model", or JSON of role to one.
|
|
20
|
+
model_overrides?: string | null;
|
|
21
|
+
// A task's plan step: planning on the planning model, then implementing.
|
|
22
|
+
plan_phase?: "planning" | "implementing" | null;
|
|
17
23
|
system_prompt: string | null;
|
|
18
24
|
group_path: string; // Deprecated - use project_id
|
|
19
25
|
project_id: string | null;
|
package/lib/drafts.ts
CHANGED
|
@@ -23,6 +23,9 @@ export interface Draft {
|
|
|
23
23
|
openPr: boolean;
|
|
24
24
|
// A task that runs in a terminal rather than a chat (the default).
|
|
25
25
|
terminal?: boolean;
|
|
26
|
+
// A task's own model, "provider/model"; unset, its roles' models
|
|
27
|
+
// (lib/models).
|
|
28
|
+
taskModel?: string | null;
|
|
26
29
|
createdAt: number;
|
|
27
30
|
}
|
|
28
31
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model routing settings, kept in the settings table: the model each role
|
|
3
|
+
* runs on, the providers (the presets, edited or added to), the price table,
|
|
4
|
+
* and whether tasks plan first. A workspace may set its own role models.
|
|
5
|
+
* Nothing here is ever a key: a provider names where its key lives.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { ENV_NAME, RESERVED_ENV } from "../project-config/schema";
|
|
11
|
+
import { KEY_ENV_NAME } from "./secrets";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_PRICES,
|
|
14
|
+
PROVIDER_PRESETS,
|
|
15
|
+
type ModelPrice,
|
|
16
|
+
type ModelProvider,
|
|
17
|
+
} from "./providers";
|
|
18
|
+
import {
|
|
19
|
+
ANTHROPIC,
|
|
20
|
+
DEFAULT_ROLE_MODELS,
|
|
21
|
+
MODEL_ROLES,
|
|
22
|
+
REF_PATTERN,
|
|
23
|
+
type ModelRole,
|
|
24
|
+
} from "./roles";
|
|
25
|
+
|
|
26
|
+
// A name passed to `tv` or `security` as its own argument: no option, no
|
|
27
|
+
// line break, nothing a shell would read.
|
|
28
|
+
const refName = z
|
|
29
|
+
.string()
|
|
30
|
+
.trim()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(120)
|
|
33
|
+
.regex(/^[\w .@:+-]+$/, "letters, digits, spaces and . @ : + - _ only")
|
|
34
|
+
.refine((s) => !s.startsWith("-"), "can't start with -");
|
|
35
|
+
|
|
36
|
+
const secretRef = z.discriminatedUnion("kind", [
|
|
37
|
+
z.strictObject({
|
|
38
|
+
kind: z.literal("tokenvault"),
|
|
39
|
+
project: refName,
|
|
40
|
+
desc: refName.optional(),
|
|
41
|
+
}),
|
|
42
|
+
z.strictObject({
|
|
43
|
+
kind: z.literal("keychain"),
|
|
44
|
+
service: refName,
|
|
45
|
+
account: refName.optional(),
|
|
46
|
+
}),
|
|
47
|
+
z.strictObject({
|
|
48
|
+
kind: z.literal("env"),
|
|
49
|
+
name: z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(
|
|
52
|
+
KEY_ENV_NAME,
|
|
53
|
+
"must be a *_API_KEY variable, not ANTHROPIC_, CLAUDE_ or AGENTOS_"
|
|
54
|
+
),
|
|
55
|
+
}),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const modelId = z
|
|
59
|
+
.string()
|
|
60
|
+
.trim()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(120)
|
|
63
|
+
.regex(/^[\w.:@/-]+$/, "not a model id");
|
|
64
|
+
|
|
65
|
+
const baseUrl = z
|
|
66
|
+
.string()
|
|
67
|
+
.trim()
|
|
68
|
+
.url()
|
|
69
|
+
.refine(
|
|
70
|
+
(u) =>
|
|
71
|
+
u.startsWith("https://") ||
|
|
72
|
+
/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/.test(u),
|
|
73
|
+
"must be https (or http on localhost)"
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const providerSchema = z.strictObject({
|
|
77
|
+
id: z
|
|
78
|
+
.string()
|
|
79
|
+
.regex(/^[a-z0-9][a-z0-9_-]{0,39}$/, "lowercase letters, digits, - and _"),
|
|
80
|
+
label: z.string().trim().min(1).max(60),
|
|
81
|
+
api: z.enum(["anthropic", "openai"]),
|
|
82
|
+
baseUrl: baseUrl.optional(),
|
|
83
|
+
auth: secretRef.optional(),
|
|
84
|
+
authHeader: z.enum(["bearer", "x-api-key"]).optional(),
|
|
85
|
+
models: z.array(modelId).max(40),
|
|
86
|
+
tiers: z
|
|
87
|
+
.strictObject({
|
|
88
|
+
opus: modelId.optional(),
|
|
89
|
+
sonnet: modelId.optional(),
|
|
90
|
+
haiku: modelId.optional(),
|
|
91
|
+
})
|
|
92
|
+
.optional(),
|
|
93
|
+
// A key can't hide here: no name an agent CLI reads for credentials.
|
|
94
|
+
env: z
|
|
95
|
+
.record(
|
|
96
|
+
z
|
|
97
|
+
.string()
|
|
98
|
+
.regex(ENV_NAME)
|
|
99
|
+
.refine((n) => !RESERVED_ENV.test(n), "is reserved"),
|
|
100
|
+
z.string().max(200)
|
|
101
|
+
)
|
|
102
|
+
.optional(),
|
|
103
|
+
enabled: z.boolean(),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const price = z.strictObject({
|
|
107
|
+
input: z.number().min(0),
|
|
108
|
+
cacheWrite: z.number().min(0),
|
|
109
|
+
cacheRead: z.number().min(0),
|
|
110
|
+
output: z.number().min(0),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const roleRef = z.string().trim().regex(REF_PATTERN, "must be provider/model");
|
|
114
|
+
const roleMap = z.partialRecord(z.enum(MODEL_ROLES), roleRef);
|
|
115
|
+
|
|
116
|
+
export const modelSettingsSchema = z.strictObject({
|
|
117
|
+
roles: roleMap.optional(),
|
|
118
|
+
providers: z.array(providerSchema).optional(),
|
|
119
|
+
prices: z.record(z.string().regex(REF_PATTERN), price).optional(),
|
|
120
|
+
planStep: z.boolean().optional(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const workspaceModelsSchema = z.strictObject({
|
|
124
|
+
roles: roleMap.optional(),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export type ModelSettings = z.infer<typeof modelSettingsSchema>;
|
|
128
|
+
export type WorkspaceModels = z.infer<typeof workspaceModelsSchema>;
|
|
129
|
+
export type RoleMap = Partial<Record<ModelRole, string>>;
|
|
130
|
+
export const roleMapSchema = roleMap;
|
|
131
|
+
|
|
132
|
+
const GLOBAL_KEY = "models";
|
|
133
|
+
const workspaceKey = (id: string) => `models.workspace.${id}`;
|
|
134
|
+
|
|
135
|
+
function read<T>(key: string, schema: z.ZodType<T>): T | null {
|
|
136
|
+
const row = db
|
|
137
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
138
|
+
.get(key) as { value: string } | undefined;
|
|
139
|
+
if (!row) return null;
|
|
140
|
+
try {
|
|
141
|
+
const got = schema.safeParse(JSON.parse(row.value));
|
|
142
|
+
return got.success ? got.data : null;
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function problems(error: z.ZodError): string {
|
|
149
|
+
return error.issues
|
|
150
|
+
.map((i) => `${i.path.join(".") || "(top level)"}: ${i.message}`)
|
|
151
|
+
.join("; ");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function write<T>(key: string, schema: z.ZodType<T>, value: unknown): T {
|
|
155
|
+
const got = schema.safeParse(value ?? {});
|
|
156
|
+
if (!got.success)
|
|
157
|
+
throw new Error(`Invalid model settings: ${problems(got.error)}`);
|
|
158
|
+
db.prepare(
|
|
159
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
160
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
161
|
+
).run(key, JSON.stringify(got.data));
|
|
162
|
+
return got.data;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export const savedModelSettings = (): ModelSettings =>
|
|
166
|
+
read(GLOBAL_KEY, modelSettingsSchema) ?? {};
|
|
167
|
+
|
|
168
|
+
export function setModelSettings(value: unknown): ModelSettings {
|
|
169
|
+
const parsed = modelSettingsSchema.safeParse(value ?? {});
|
|
170
|
+
if (!parsed.success)
|
|
171
|
+
throw new Error(`Invalid model settings: ${problems(parsed.error)}`);
|
|
172
|
+
const ids = parsed.data.providers?.map((p) => p.id) ?? [];
|
|
173
|
+
if (new Set(ids).size !== ids.length)
|
|
174
|
+
throw new Error("Invalid model settings: two providers share an id");
|
|
175
|
+
const anthropic = parsed.data.providers?.find((p) => p.id === ANTHROPIC);
|
|
176
|
+
if (anthropic && (anthropic.baseUrl || anthropic.auth || !anthropic.enabled))
|
|
177
|
+
throw new Error(
|
|
178
|
+
"Invalid model settings: anthropic is the Claude login; add another provider for an endpoint"
|
|
179
|
+
);
|
|
180
|
+
return write(GLOBAL_KEY, modelSettingsSchema, parsed.data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const workspaceModels = (workspaceId: string): WorkspaceModels =>
|
|
184
|
+
read(workspaceKey(workspaceId), workspaceModelsSchema) ?? {};
|
|
185
|
+
|
|
186
|
+
export function setWorkspaceModels(
|
|
187
|
+
workspaceId: string,
|
|
188
|
+
value: unknown
|
|
189
|
+
): WorkspaceModels {
|
|
190
|
+
const saved = write(workspaceKey(workspaceId), workspaceModelsSchema, value);
|
|
191
|
+
if (!Object.keys(saved.roles ?? {}).length)
|
|
192
|
+
db.prepare(`DELETE FROM settings WHERE key = ?`).run(
|
|
193
|
+
workspaceKey(workspaceId)
|
|
194
|
+
);
|
|
195
|
+
return saved;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The settings in force: the presets with any saved edits laid over them
|
|
199
|
+
// (by id), saved providers that aren't presets, and every role filled.
|
|
200
|
+
export interface EffectiveModels {
|
|
201
|
+
roles: Record<ModelRole, string>;
|
|
202
|
+
providers: ModelProvider[];
|
|
203
|
+
prices: Record<string, ModelPrice>;
|
|
204
|
+
planStep: boolean;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function effectiveModels(
|
|
208
|
+
saved: ModelSettings = savedModelSettings()
|
|
209
|
+
): EffectiveModels {
|
|
210
|
+
const byId = new Map(PROVIDER_PRESETS.map((p) => [p.id, p]));
|
|
211
|
+
for (const p of saved.providers ?? []) byId.set(p.id, p as ModelProvider);
|
|
212
|
+
return {
|
|
213
|
+
roles: { ...DEFAULT_ROLE_MODELS, ...saved.roles },
|
|
214
|
+
providers: [...byId.values()],
|
|
215
|
+
prices: { ...DEFAULT_PRICES, ...saved.prices },
|
|
216
|
+
planStep: saved.planStep ?? true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an agent is launched with to run on its routed model: the model
|
|
3
|
+
* name for its CLI, and the provider's endpoint and key in its environment.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Session } from "../db";
|
|
7
|
+
import {
|
|
8
|
+
defaultRoute,
|
|
9
|
+
providerEnv,
|
|
10
|
+
providerFor,
|
|
11
|
+
providerShellEnv,
|
|
12
|
+
resolveRole,
|
|
13
|
+
setSessionModel,
|
|
14
|
+
type ModelOverride,
|
|
15
|
+
} from "./resolve";
|
|
16
|
+
import { ANTHROPIC, type ModelRole } from "./roles";
|
|
17
|
+
import { redact, type SecretCommand } from "./secrets";
|
|
18
|
+
|
|
19
|
+
const why = (error: unknown) =>
|
|
20
|
+
redact(error instanceof Error ? error.message : String(error));
|
|
21
|
+
|
|
22
|
+
// A chat session's provider environment, or null on Anthropic.
|
|
23
|
+
export async function sessionProviderEnv(
|
|
24
|
+
session: Pick<Session, "model" | "model_provider">,
|
|
25
|
+
exec?: SecretCommand
|
|
26
|
+
): Promise<Record<string, string> | null> {
|
|
27
|
+
if (!session.model_provider) return null;
|
|
28
|
+
return providerEnv(providerFor(session.model_provider), session.model, exec);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A terminal session's: plain settings for its tmux environment, and shell
|
|
32
|
+
// text that sets the key from its reference inside the pane.
|
|
33
|
+
export function sessionShellEnv(
|
|
34
|
+
session: Pick<Session, "model" | "model_provider">
|
|
35
|
+
): { env: Record<string, string>; exportKey: string } {
|
|
36
|
+
if (!session.model_provider) return { env: {}, exportKey: "" };
|
|
37
|
+
return providerShellEnv(providerFor(session.model_provider), session.model);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RoutedRun {
|
|
41
|
+
model: string;
|
|
42
|
+
provider: string;
|
|
43
|
+
env: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A one-shot run (a review, a scope check, a title) on its role's model,
|
|
47
|
+
// or on the role's default when its provider is off or its key can't be
|
|
48
|
+
// read: a title or a review still happens.
|
|
49
|
+
export async function routedRun(
|
|
50
|
+
role: ModelRole,
|
|
51
|
+
opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
|
|
52
|
+
exec?: SecretCommand
|
|
53
|
+
): Promise<RoutedRun> {
|
|
54
|
+
try {
|
|
55
|
+
const routed = resolveRole(role, opts);
|
|
56
|
+
return {
|
|
57
|
+
model: routed.model,
|
|
58
|
+
provider: routed.provider.id,
|
|
59
|
+
env: await providerEnv(routed.provider, routed.model, exec),
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const routed = defaultRoute(role);
|
|
63
|
+
console.warn(`[models] ${role}: ${why(error)}; on ${routed.ref} instead`);
|
|
64
|
+
return { model: routed.model, provider: ANTHROPIC, env: {} };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// An orchestrator runs on its role's model, as its workspace and Settings
|
|
69
|
+
// say now: each worker start picks up a change. When that provider is off
|
|
70
|
+
// or its key can't be read, it starts on the default rather than not at all.
|
|
71
|
+
export async function routeOrchestrator(
|
|
72
|
+
session: Session,
|
|
73
|
+
exec?: SecretCommand
|
|
74
|
+
): Promise<Session> {
|
|
75
|
+
if (session.role !== "orchestrator") return session;
|
|
76
|
+
let routed;
|
|
77
|
+
try {
|
|
78
|
+
routed = resolveRole("orchestrator", { workspaceId: session.workspace_id });
|
|
79
|
+
await providerEnv(routed.provider, routed.model, exec);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
routed = defaultRoute("orchestrator");
|
|
82
|
+
console.warn(
|
|
83
|
+
`[models] orchestrator: ${why(error)}; on ${routed.ref} instead`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
setSessionModel(session.id, routed);
|
|
87
|
+
return {
|
|
88
|
+
...session,
|
|
89
|
+
model: routed.model,
|
|
90
|
+
model_provider:
|
|
91
|
+
routed.provider.id === ANTHROPIC ? null : routed.provider.id,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A task's plan step: its first turn runs on the planning model and writes
|
|
3
|
+
* the implementation plan to a file in the worktree; when that turn ends,
|
|
4
|
+
* the same conversation switches to the implementation model (Claude Code
|
|
5
|
+
* keeps the transcript across a model change, and a resume takes a new
|
|
6
|
+
* model) and is told to follow the plan.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { db, type Session } from "../db";
|
|
12
|
+
import { enqueue } from "../chat/queued";
|
|
13
|
+
import { applySessionModel } from "./session-model";
|
|
14
|
+
import {
|
|
15
|
+
resolveRole,
|
|
16
|
+
sessionWorkspace,
|
|
17
|
+
setSessionModel,
|
|
18
|
+
taskOverride,
|
|
19
|
+
} from "./resolve";
|
|
20
|
+
import { ANTHROPIC } from "./roles";
|
|
21
|
+
|
|
22
|
+
export const PLAN_FILE = ".agentos/plan.md";
|
|
23
|
+
|
|
24
|
+
export function planPrompt(task: string): string {
|
|
25
|
+
return `${task}
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
Plan first. This turn, only read and plan: write the implementation plan for the task above to \`${PLAN_FILE}\` in this worktree (what to change, file by file; the tests; how you'll verify it; anything ambiguous and the call you'd make). Change no other file, and don't commit, push or open a PR. End the turn once the plan is written: the implementation model takes over this conversation next and follows it.`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function implementPrompt(planned: boolean): string {
|
|
32
|
+
return planned
|
|
33
|
+
? `The plan is in \`${PLAN_FILE}\`. Implement it now, finishing the task as its instructions above say. Where the plan turns out wrong, do what's right and say so.`
|
|
34
|
+
: `No plan was written to \`${PLAN_FILE}\`. Plan briefly, then implement the task, finishing it as its instructions above say.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The worktree's plan folder, kept out of git by its own .gitignore.
|
|
38
|
+
export function preparePlanDir(worktree: string): void {
|
|
39
|
+
const dir = path.join(worktree, path.dirname(PLAN_FILE));
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
fs.writeFileSync(path.join(dir, ".gitignore"), "*\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const implementMessageId = (sessionId: string) =>
|
|
45
|
+
`implement-${sessionId}`;
|
|
46
|
+
|
|
47
|
+
// Called when a chat goes idle. A task whose plan turn has ended (it has a
|
|
48
|
+
// recorded plan turn) moves to implementing, once, and the implementation
|
|
49
|
+
// message is queued under a fixed id, which a restart sends rather than
|
|
50
|
+
// repeats.
|
|
51
|
+
export async function handoffAfterPlan(
|
|
52
|
+
sessionId: string,
|
|
53
|
+
send: (sessionId: string, id: string) => Promise<void>
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
const planned = db
|
|
56
|
+
.prepare(
|
|
57
|
+
`SELECT 1 FROM chat_turns WHERE session_id = ? AND role = 'plan' LIMIT 1`
|
|
58
|
+
)
|
|
59
|
+
.get(sessionId);
|
|
60
|
+
if (!planned) return false;
|
|
61
|
+
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
62
|
+
| Session
|
|
63
|
+
| undefined;
|
|
64
|
+
if (s?.plan_phase !== "planning" || !s.worktree_path) return false;
|
|
65
|
+
const routed = resolveRole("implement", {
|
|
66
|
+
workspaceId: sessionWorkspace(s),
|
|
67
|
+
override: taskOverride(s),
|
|
68
|
+
});
|
|
69
|
+
const id = implementMessageId(sessionId);
|
|
70
|
+
// The phase, the model and the message in one go: a restart part-way
|
|
71
|
+
// finds either none of them or all, and sends the message on the new model.
|
|
72
|
+
const claimed = db.transaction(() => {
|
|
73
|
+
const changed = db
|
|
74
|
+
.prepare(
|
|
75
|
+
`UPDATE sessions SET plan_phase = 'implementing'
|
|
76
|
+
WHERE id = ? AND plan_phase = 'planning' AND task_status = 'running'`
|
|
77
|
+
)
|
|
78
|
+
.run(sessionId).changes;
|
|
79
|
+
if (!changed) return false;
|
|
80
|
+
setSessionModel(sessionId, routed);
|
|
81
|
+
enqueue(sessionId, {
|
|
82
|
+
id,
|
|
83
|
+
text: implementPrompt(
|
|
84
|
+
fs.existsSync(path.join(s.worktree_path!, PLAN_FILE))
|
|
85
|
+
),
|
|
86
|
+
origin: { kind: "decision", label: "Plan step" },
|
|
87
|
+
});
|
|
88
|
+
return true;
|
|
89
|
+
})();
|
|
90
|
+
if (!claimed) return false;
|
|
91
|
+
await applySessionModel(sessionId, s.model_provider ?? ANTHROPIC, routed);
|
|
92
|
+
await send(sessionId, id);
|
|
93
|
+
return true;
|
|
94
|
+
}
|