@saadnvd1/agent-os 0.3.470 → 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 +139 -36
- package/app/api/fresh-start/route.ts +9 -5
- 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 +8 -3
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/Chat/ChatPanel.tsx +5 -5
- package/components/Chat/StaleChip.tsx +7 -6
- 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 +69 -5
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/FreshStartForm.tsx +13 -13
- 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 +5 -0
- package/lib/agents/launch.ts +13 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/refresh.ts +31 -13
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/stale.ts +25 -16
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +70 -24
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +70 -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/armed.ts +1 -2
- package/lib/orchestrator/brief.ts +5 -4
- package/lib/orchestrator/claude-cli.ts +48 -14
- package/lib/orchestrator/diff.ts +1 -1
- package/lib/orchestrator/external-gates.ts +8 -43
- package/lib/orchestrator/gates.ts +10 -10
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/pr-wake.ts +2 -2
- package/lib/orchestrator/rebase.ts +6 -45
- package/lib/orchestrator/review.ts +20 -5
- package/lib/orchestrator/scope.ts +11 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/signoff.ts +13 -8
- 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-audit/render.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/stacks/land.ts +23 -16
- package/lib/tasks/brief.ts +3 -14
- package/lib/tasks/code-review.ts +69 -107
- package/lib/tasks/gh.ts +3 -2
- package/lib/tasks/index.ts +60 -9
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/tasks/state.ts +4 -4
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +170 -0
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +51 -0
- package/lib/usage/turns.ts +107 -18
- package/package.json +1 -1
- package/stores/usageUi.ts +11 -2
- package/scripts/check-code-review.ts +0 -21
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a model runs. A provider is config: Anthropic through the Claude
|
|
3
|
+
* login (no key), or an Anthropic- or OpenAI-compatible endpoint with a base
|
|
4
|
+
* URL, a reference to its key and its model names. The key itself is never
|
|
5
|
+
* here: only where to fetch it from at launch (lib/models/secrets).
|
|
6
|
+
* Imported by the browser too, so no Node modules here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ANTHROPIC } from "./roles";
|
|
10
|
+
|
|
11
|
+
// Where a provider's key lives. Never the key.
|
|
12
|
+
export type SecretRef =
|
|
13
|
+
| { kind: "tokenvault"; project: string; desc?: string }
|
|
14
|
+
| { kind: "keychain"; service: string; account?: string }
|
|
15
|
+
| { kind: "env"; name: string };
|
|
16
|
+
|
|
17
|
+
export type ProviderApi = "anthropic" | "openai";
|
|
18
|
+
|
|
19
|
+
export interface ModelTiers {
|
|
20
|
+
opus?: string;
|
|
21
|
+
sonnet?: string;
|
|
22
|
+
haiku?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelProvider {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
api: ProviderApi;
|
|
29
|
+
// Absent for Anthropic itself, which uses the Claude login.
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
auth?: SecretRef;
|
|
32
|
+
// How the key is sent: Authorization: Bearer, or x-api-key.
|
|
33
|
+
authHeader?: "bearer" | "x-api-key";
|
|
34
|
+
models: string[];
|
|
35
|
+
// Claude Code's tiers mapped to this provider's models, so subagents and
|
|
36
|
+
// background work (which ask for "haiku" or "sonnet") route here too.
|
|
37
|
+
tiers?: ModelTiers;
|
|
38
|
+
// Plain settings the endpoint wants (a longer timeout, say). Never a key.
|
|
39
|
+
env?: Record<string, string>;
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModelPrice {
|
|
44
|
+
input: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
cacheRead: number;
|
|
47
|
+
output: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ANTHROPIC_PROVIDER: ModelProvider = {
|
|
51
|
+
id: ANTHROPIC,
|
|
52
|
+
label: "Anthropic (Claude login)",
|
|
53
|
+
api: "anthropic",
|
|
54
|
+
models: [
|
|
55
|
+
"claude-opus-5-5",
|
|
56
|
+
"claude-sonnet-5-5",
|
|
57
|
+
"claude-haiku-4-5-20251001",
|
|
58
|
+
"claude-fable-5-1",
|
|
59
|
+
],
|
|
60
|
+
enabled: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Checked against each provider's docs on 2026-10-11: DeepSeek
|
|
64
|
+
// (api-docs.deepseek.com/guides/anthropic_api) and Z.ai
|
|
65
|
+
// (docs.z.ai/devpack/tool/claude). Off until a key reference is set.
|
|
66
|
+
export const PROVIDER_PRESETS: ModelProvider[] = [
|
|
67
|
+
ANTHROPIC_PROVIDER,
|
|
68
|
+
{
|
|
69
|
+
id: "deepseek",
|
|
70
|
+
label: "DeepSeek",
|
|
71
|
+
api: "anthropic",
|
|
72
|
+
baseUrl: "https://api.deepseek.com/anthropic",
|
|
73
|
+
authHeader: "x-api-key",
|
|
74
|
+
models: ["deepseek-v4-pro", "deepseek-flash"],
|
|
75
|
+
tiers: {
|
|
76
|
+
opus: "deepseek-v4-pro",
|
|
77
|
+
sonnet: "deepseek-flash",
|
|
78
|
+
haiku: "deepseek-flash",
|
|
79
|
+
},
|
|
80
|
+
enabled: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "zai",
|
|
84
|
+
label: "Z.ai (GLM)",
|
|
85
|
+
api: "anthropic",
|
|
86
|
+
baseUrl: "https://api.z.ai/api/anthropic",
|
|
87
|
+
authHeader: "bearer",
|
|
88
|
+
models: ["glm-5.3", "glm-5.3-flash"],
|
|
89
|
+
tiers: { opus: "glm-5.3", sonnet: "glm-5.3", haiku: "glm-5.3-flash" },
|
|
90
|
+
env: { API_TIMEOUT_MS: "3000000" },
|
|
91
|
+
enabled: false,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Dollars per million tokens, from each provider's price page on
|
|
96
|
+
// 2026-10-11. DeepSeek and GLM have no cache-write price (a write costs
|
|
97
|
+
// what input does), and DeepSeek's are its peak prices.
|
|
98
|
+
export const DEFAULT_PRICES: Record<string, ModelPrice> = {
|
|
99
|
+
"anthropic/claude-opus-5-5": {
|
|
100
|
+
input: 4,
|
|
101
|
+
cacheWrite: 5,
|
|
102
|
+
cacheRead: 0.2,
|
|
103
|
+
output: 20,
|
|
104
|
+
},
|
|
105
|
+
"anthropic/claude-sonnet-5-5": {
|
|
106
|
+
input: 2,
|
|
107
|
+
cacheWrite: 2.5,
|
|
108
|
+
cacheRead: 0.1,
|
|
109
|
+
output: 10,
|
|
110
|
+
},
|
|
111
|
+
"anthropic/claude-haiku-4-5-20251001": {
|
|
112
|
+
input: 1,
|
|
113
|
+
cacheWrite: 1.25,
|
|
114
|
+
cacheRead: 0.1,
|
|
115
|
+
output: 5,
|
|
116
|
+
},
|
|
117
|
+
"anthropic/claude-fable-5-1": {
|
|
118
|
+
input: 10,
|
|
119
|
+
cacheWrite: 12.5,
|
|
120
|
+
cacheRead: 0.25,
|
|
121
|
+
output: 50,
|
|
122
|
+
},
|
|
123
|
+
"deepseek/deepseek-v4-pro": {
|
|
124
|
+
input: 1.32,
|
|
125
|
+
cacheWrite: 1.32,
|
|
126
|
+
cacheRead: 0.044,
|
|
127
|
+
output: 3.96,
|
|
128
|
+
},
|
|
129
|
+
"deepseek/deepseek-flash": {
|
|
130
|
+
input: 0.3,
|
|
131
|
+
cacheWrite: 0.3,
|
|
132
|
+
cacheRead: 0.006,
|
|
133
|
+
output: 1.2,
|
|
134
|
+
},
|
|
135
|
+
"zai/glm-5.3": { input: 1.4, cacheWrite: 1.4, cacheRead: 0.26, output: 4.4 },
|
|
136
|
+
"zai/glm-5.3-flash": {
|
|
137
|
+
input: 0.15,
|
|
138
|
+
cacheWrite: 0.15,
|
|
139
|
+
cacheRead: 0.03,
|
|
140
|
+
output: 0.5,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Claude Code's family aliases, priced as the model each stands for.
|
|
145
|
+
export const ANTHROPIC_ALIASES: Record<string, string> = {
|
|
146
|
+
opus: "claude-opus-5-5",
|
|
147
|
+
sonnet: "claude-sonnet-5-5",
|
|
148
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
149
|
+
fable: "claude-fable-5-1",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Anthropic is billed by the plan, not per token: a turn there costs a share
|
|
153
|
+
// of the plan's window, and its dollars are what the API would have charged.
|
|
154
|
+
export const isMetered = (providerId: string) => providerId !== ANTHROPIC;
|
|
155
|
+
|
|
156
|
+
export function describeSecretRef(ref: SecretRef | undefined): string {
|
|
157
|
+
if (!ref) return "no key";
|
|
158
|
+
if (ref.kind === "tokenvault")
|
|
159
|
+
return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
|
|
160
|
+
if (ref.kind === "keychain")
|
|
161
|
+
return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
|
|
162
|
+
return `$${ref.name}`;
|
|
163
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which model a piece of work runs on, and what its agent needs to reach
|
|
3
|
+
* it. The most specific setting wins: the task's own override, then its
|
|
4
|
+
* workspace's, then Settings, then AGENTOS_REVIEW_MODEL or
|
|
5
|
+
* AGENTOS_TITLE_MODEL, then the default (lib/models/roles).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { getProject } from "../projects";
|
|
10
|
+
import {
|
|
11
|
+
effectiveModels,
|
|
12
|
+
roleMapSchema,
|
|
13
|
+
savedModelSettings,
|
|
14
|
+
workspaceModels,
|
|
15
|
+
type EffectiveModels,
|
|
16
|
+
type RoleMap,
|
|
17
|
+
} from "./config";
|
|
18
|
+
import { ANTHROPIC_PROVIDER, type ModelProvider } from "./providers";
|
|
19
|
+
import {
|
|
20
|
+
ANTHROPIC,
|
|
21
|
+
DEFAULT_ROLE_MODELS,
|
|
22
|
+
GATE_ROLES,
|
|
23
|
+
formatModelRef,
|
|
24
|
+
isModelRole,
|
|
25
|
+
parseModelRef,
|
|
26
|
+
sessionRole,
|
|
27
|
+
type ModelRole,
|
|
28
|
+
} from "./roles";
|
|
29
|
+
import {
|
|
30
|
+
resolveSecret,
|
|
31
|
+
secretShellExpansion,
|
|
32
|
+
type SecretCommand,
|
|
33
|
+
} from "./secrets";
|
|
34
|
+
|
|
35
|
+
export type RoleSource = "task" | "workspace" | "settings" | "env" | "default";
|
|
36
|
+
|
|
37
|
+
// The server's own environment can still stand in for a role's default:
|
|
38
|
+
// what AgentOS read before model routing existed.
|
|
39
|
+
const ENV_ROLE_MODELS: Partial<Record<ModelRole, string>> = {
|
|
40
|
+
review: "AGENTOS_REVIEW_MODEL",
|
|
41
|
+
scope: "AGENTOS_REVIEW_MODEL",
|
|
42
|
+
helper: "AGENTOS_TITLE_MODEL",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const envModel = (role: ModelRole): string | undefined => {
|
|
46
|
+
const name = ENV_ROLE_MODELS[role];
|
|
47
|
+
return (name && process.env[name]?.trim()) || undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface RoutedModel {
|
|
51
|
+
role: ModelRole;
|
|
52
|
+
ref: string;
|
|
53
|
+
provider: ModelProvider;
|
|
54
|
+
model: string;
|
|
55
|
+
source: RoleSource;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A task's override: one model for all its roles, or a model per role.
|
|
59
|
+
export type ModelOverride = string | RoleMap;
|
|
60
|
+
|
|
61
|
+
export function parseOverride(value: unknown): ModelOverride | null {
|
|
62
|
+
if (value == null || value === "") return null;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
const ref = value.trim();
|
|
65
|
+
providerFor(parseModelRef(ref).provider);
|
|
66
|
+
return ref.includes("/") ? ref : formatModelRef(parseModelRef(ref));
|
|
67
|
+
}
|
|
68
|
+
const got = roleMapSchema.safeParse(value);
|
|
69
|
+
const gate = got.success && GATE_ROLES.find((r) => got.data[r]);
|
|
70
|
+
if (gate)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`model: a task can't pick its own ${gate} model; set it in Settings or the workspace`
|
|
73
|
+
);
|
|
74
|
+
if (!got.success)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`model: give "provider/model", or a map of role to "provider/model"`
|
|
77
|
+
);
|
|
78
|
+
for (const ref of Object.values(got.data))
|
|
79
|
+
if (ref) providerFor(parseModelRef(ref).provider);
|
|
80
|
+
return Object.keys(got.data).length ? got.data : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const overrideFor = (o: ModelOverride | null | undefined, role: ModelRole) =>
|
|
84
|
+
GATE_ROLES.includes(role) ? undefined : typeof o === "string" ? o : o?.[role];
|
|
85
|
+
|
|
86
|
+
// The provider by id, or throws why it can't be used.
|
|
87
|
+
export function providerFor(
|
|
88
|
+
id: string,
|
|
89
|
+
models: EffectiveModels = effectiveModels()
|
|
90
|
+
): ModelProvider {
|
|
91
|
+
if (id === ANTHROPIC) return ANTHROPIC_PROVIDER;
|
|
92
|
+
const p = models.providers.find((x) => x.id === id);
|
|
93
|
+
if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
|
|
94
|
+
if (!p.enabled) throw new Error(`The ${p.label} provider is turned off`);
|
|
95
|
+
if (!p.baseUrl || !p.auth)
|
|
96
|
+
throw new Error(`The ${p.label} provider needs a base URL and a key`);
|
|
97
|
+
if (p.api !== "anthropic")
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`
|
|
100
|
+
);
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveRole(
|
|
105
|
+
role: ModelRole,
|
|
106
|
+
opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
|
|
107
|
+
models: EffectiveModels = effectiveModels()
|
|
108
|
+
): RoutedModel {
|
|
109
|
+
const task = overrideFor(opts.override, role);
|
|
110
|
+
const ws = opts.workspaceId
|
|
111
|
+
? workspaceModels(opts.workspaceId).roles?.[role]
|
|
112
|
+
: undefined;
|
|
113
|
+
const saved = models.roles[role];
|
|
114
|
+
// The variable stands in only where Settings names nothing for the role.
|
|
115
|
+
const env =
|
|
116
|
+
saved === DEFAULT_ROLE_MODELS[role] && !savedModelSettings().roles?.[role]
|
|
117
|
+
? envModel(role)
|
|
118
|
+
: undefined;
|
|
119
|
+
const [ref, source]: [string, RoleSource] = task
|
|
120
|
+
? [task, "task"]
|
|
121
|
+
: ws
|
|
122
|
+
? [ws, "workspace"]
|
|
123
|
+
: saved !== DEFAULT_ROLE_MODELS[role]
|
|
124
|
+
? [saved, "settings"]
|
|
125
|
+
: env
|
|
126
|
+
? [env, "env"]
|
|
127
|
+
: [saved, "default"];
|
|
128
|
+
const parsed = parseModelRef(ref);
|
|
129
|
+
return {
|
|
130
|
+
role,
|
|
131
|
+
ref: formatModelRef(parsed),
|
|
132
|
+
provider: providerFor(parsed.provider, models),
|
|
133
|
+
model: parsed.model,
|
|
134
|
+
source,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The role's built-in model on the Claude login: what work falls back to
|
|
139
|
+
// when the provider it's routed to can't be used.
|
|
140
|
+
export function defaultRoute(role: ModelRole): RoutedModel {
|
|
141
|
+
const parsed = parseModelRef(DEFAULT_ROLE_MODELS[role]);
|
|
142
|
+
return {
|
|
143
|
+
role,
|
|
144
|
+
ref: formatModelRef(parsed),
|
|
145
|
+
provider: ANTHROPIC_PROVIDER,
|
|
146
|
+
model: parsed.model,
|
|
147
|
+
source: "default",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resolveRoleOrDefault(
|
|
152
|
+
role: ModelRole,
|
|
153
|
+
opts: { workspaceId?: string | null } = {}
|
|
154
|
+
): RoutedModel {
|
|
155
|
+
try {
|
|
156
|
+
return resolveRole(role, opts);
|
|
157
|
+
} catch {
|
|
158
|
+
return defaultRoute(role);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A session's workspace: an orchestrator's own, or its project's.
|
|
163
|
+
export function sessionWorkspace(
|
|
164
|
+
session: Pick<Session, "workspace_id" | "project_id">
|
|
165
|
+
): string | null {
|
|
166
|
+
if (session.workspace_id) return session.workspace_id;
|
|
167
|
+
return session.project_id
|
|
168
|
+
? (getProject(session.project_id)?.workspace_id ?? null)
|
|
169
|
+
: null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function taskOverride(
|
|
173
|
+
session: Pick<Session, "model_overrides">
|
|
174
|
+
): ModelOverride | null {
|
|
175
|
+
if (!session.model_overrides) return null;
|
|
176
|
+
try {
|
|
177
|
+
return parseOverride(JSON.parse(session.model_overrides));
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What the agent's environment needs beyond the model name, with the key
|
|
184
|
+
// fetched now. Nothing for Anthropic: the Claude login is the default.
|
|
185
|
+
export async function providerEnv(
|
|
186
|
+
provider: ModelProvider,
|
|
187
|
+
model: string,
|
|
188
|
+
exec?: SecretCommand
|
|
189
|
+
): Promise<Record<string, string>> {
|
|
190
|
+
if (provider.id === ANTHROPIC) return {};
|
|
191
|
+
const key = await resolveSecret(provider.auth!, exec);
|
|
192
|
+
return { ...routingEnv(provider, model), [authVar(provider)]: key };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The same for a terminal launch: the plain settings, and shell text that
|
|
196
|
+
// sets the key from its reference inside the agent's shell.
|
|
197
|
+
export function providerShellEnv(
|
|
198
|
+
provider: ModelProvider,
|
|
199
|
+
model: string
|
|
200
|
+
): { env: Record<string, string>; exportKey: string } {
|
|
201
|
+
if (provider.id === ANTHROPIC) return { env: {}, exportKey: "" };
|
|
202
|
+
return {
|
|
203
|
+
env: routingEnv(provider, model),
|
|
204
|
+
exportKey: `export ${authVar(provider)}=${secretShellExpansion(provider.auth!)};`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const authVar = (p: ModelProvider) =>
|
|
209
|
+
p.authHeader === "x-api-key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN";
|
|
210
|
+
|
|
211
|
+
// The endpoint, Claude Code's tiers mapped to its models (so subagents and
|
|
212
|
+
// background calls asking for "haiku" stay on it), and the other key
|
|
213
|
+
// variable emptied so a key in AgentOS's own environment isn't sent there.
|
|
214
|
+
function routingEnv(p: ModelProvider, model: string): Record<string, string> {
|
|
215
|
+
const other =
|
|
216
|
+
authVar(p) === "ANTHROPIC_API_KEY"
|
|
217
|
+
? "ANTHROPIC_AUTH_TOKEN"
|
|
218
|
+
: "ANTHROPIC_API_KEY";
|
|
219
|
+
return {
|
|
220
|
+
...p.env,
|
|
221
|
+
ANTHROPIC_BASE_URL: p.baseUrl!,
|
|
222
|
+
[other]: "",
|
|
223
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: p.tiers?.opus ?? model,
|
|
224
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: p.tiers?.sonnet ?? model,
|
|
225
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: p.tiers?.haiku ?? model,
|
|
226
|
+
CLAUDE_CODE_SUBAGENT_MODEL: p.tiers?.sonnet ?? model,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Records what a session runs on: the model its CLI is given, and its
|
|
231
|
+
// provider (NULL for Anthropic).
|
|
232
|
+
export function setSessionModel(sessionId: string, routed: RoutedModel): void {
|
|
233
|
+
db.prepare(
|
|
234
|
+
`UPDATE sessions SET model = ?, model_provider = ? WHERE id = ?`
|
|
235
|
+
).run(
|
|
236
|
+
routed.model,
|
|
237
|
+
routed.provider.id === ANTHROPIC ? null : routed.provider.id,
|
|
238
|
+
sessionId
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const sessionModelRef = (
|
|
243
|
+
s: Pick<Session, "model" | "model_provider">
|
|
244
|
+
): string => `${s.model_provider ?? ANTHROPIC}/${s.model}`;
|
|
245
|
+
|
|
246
|
+
export { isModelRole, sessionRole };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resuming a task on another model: its conversation and worktree carry on,
|
|
3
|
+
* only the model (and maybe the provider) under it changes. The choice is
|
|
4
|
+
* kept as the task's own override, so later turns and restarts stay on it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { db, type Session } from "../db";
|
|
8
|
+
import { chatState, sendChat } from "../chat/runner";
|
|
9
|
+
import { launchPending } from "../tasks/launch-gate";
|
|
10
|
+
import { killTmux } from "../tasks/start";
|
|
11
|
+
import { launchClaude } from "../agents/launch";
|
|
12
|
+
import { switchSessionModel } from "./session-model";
|
|
13
|
+
import {
|
|
14
|
+
parseOverride,
|
|
15
|
+
resolveRole,
|
|
16
|
+
sessionWorkspace,
|
|
17
|
+
setSessionModel,
|
|
18
|
+
taskOverride,
|
|
19
|
+
type ModelOverride,
|
|
20
|
+
} from "./resolve";
|
|
21
|
+
import { sessionRole } from "./roles";
|
|
22
|
+
|
|
23
|
+
export const RESUME_MESSAGE =
|
|
24
|
+
"You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
|
|
25
|
+
|
|
26
|
+
// A per-role map adds to the one it has; one model replaces it.
|
|
27
|
+
export function mergeOverride(
|
|
28
|
+
before: ModelOverride | null,
|
|
29
|
+
next: ModelOverride
|
|
30
|
+
): ModelOverride {
|
|
31
|
+
if (typeof next === "string" || !before) return next;
|
|
32
|
+
if (typeof before === "string") return next;
|
|
33
|
+
return { ...before, ...next };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResumeResult {
|
|
37
|
+
ref: string;
|
|
38
|
+
// Whether a message went to its agent now: a task still setting up, or
|
|
39
|
+
// held, starts on the new model by itself.
|
|
40
|
+
resumed: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resumeOnModel(
|
|
44
|
+
sessionId: string,
|
|
45
|
+
model: unknown,
|
|
46
|
+
message = RESUME_MESSAGE
|
|
47
|
+
): Promise<ResumeResult> {
|
|
48
|
+
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
49
|
+
| Session
|
|
50
|
+
| undefined;
|
|
51
|
+
if (!s?.task_status) throw new Error("No such task");
|
|
52
|
+
if (s.task_status !== "running")
|
|
53
|
+
throw new Error(
|
|
54
|
+
`That task is ${s.task_status}; only a running one resumes`
|
|
55
|
+
);
|
|
56
|
+
const next = parseOverride(model);
|
|
57
|
+
if (!next) throw new Error("Pick a model");
|
|
58
|
+
const override = mergeOverride(taskOverride(s), next);
|
|
59
|
+
const routed = resolveRole(sessionRole(s), {
|
|
60
|
+
workspaceId: sessionWorkspace(s),
|
|
61
|
+
override,
|
|
62
|
+
});
|
|
63
|
+
const state = s.view === "chat" ? chatState(sessionId) : null;
|
|
64
|
+
if (state === "running" || state === "waiting")
|
|
65
|
+
throw new Error(
|
|
66
|
+
"It's mid-turn: stop it first, or wait for the turn to end"
|
|
67
|
+
);
|
|
68
|
+
db.prepare(`UPDATE sessions SET model_overrides = ? WHERE id = ?`).run(
|
|
69
|
+
JSON.stringify(override),
|
|
70
|
+
sessionId
|
|
71
|
+
);
|
|
72
|
+
if (launchPending(sessionId)) {
|
|
73
|
+
setSessionModel(sessionId, routed);
|
|
74
|
+
return { ref: routed.ref, resumed: false };
|
|
75
|
+
}
|
|
76
|
+
if (s.view === "chat") {
|
|
77
|
+
await switchSessionModel(sessionId, routed);
|
|
78
|
+
await sendChat(sessionId, {
|
|
79
|
+
text: message,
|
|
80
|
+
origin: { kind: "decision", label: `Resumed on ${routed.ref}` },
|
|
81
|
+
});
|
|
82
|
+
return { ref: routed.ref, resumed: true };
|
|
83
|
+
}
|
|
84
|
+
if (!s.claude_session_id || !s.worktree_path)
|
|
85
|
+
throw new Error("Its terminal conversation can't be resumed");
|
|
86
|
+
setSessionModel(sessionId, routed);
|
|
87
|
+
await killTmux(s.tmux_name);
|
|
88
|
+
await launchClaude({
|
|
89
|
+
sessionId,
|
|
90
|
+
tmuxName: s.tmux_name,
|
|
91
|
+
cwd: s.worktree_path,
|
|
92
|
+
model: routed.model,
|
|
93
|
+
provider: routed.provider.id === "anthropic" ? null : routed.provider.id,
|
|
94
|
+
prompt: message,
|
|
95
|
+
brief: s.task_brief ?? undefined,
|
|
96
|
+
resume: s.claude_session_id,
|
|
97
|
+
});
|
|
98
|
+
return { ref: routed.ref, resumed: true };
|
|
99
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds of work AgentOS launches agents for, and the model each runs on
|
|
3
|
+
* unless Settings, a workspace or the task says otherwise. A model is named
|
|
4
|
+
* by a reference, "provider/model": the provider is one of lib/models/
|
|
5
|
+
* providers, and the model is that provider's own id. Imported by the
|
|
6
|
+
* browser too, so no Node modules here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MODEL_ROLES = [
|
|
10
|
+
"plan",
|
|
11
|
+
"implement",
|
|
12
|
+
"orchestrator",
|
|
13
|
+
"review",
|
|
14
|
+
"scope",
|
|
15
|
+
"interactive",
|
|
16
|
+
"helper",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type ModelRole = (typeof MODEL_ROLES)[number];
|
|
20
|
+
|
|
21
|
+
export const ROLE_LABELS: Record<ModelRole, { label: string; hint: string }> = {
|
|
22
|
+
plan: {
|
|
23
|
+
label: "Task planning",
|
|
24
|
+
hint: "A task's plan step, written to a file before any code changes",
|
|
25
|
+
},
|
|
26
|
+
implement: {
|
|
27
|
+
label: "Task implementation",
|
|
28
|
+
hint: "A task's agent, following the plan in its worktree",
|
|
29
|
+
},
|
|
30
|
+
orchestrator: { label: "Orchestrator", hint: "A workspace's orchestrator" },
|
|
31
|
+
review: {
|
|
32
|
+
label: "Gate review",
|
|
33
|
+
hint: "The independent review a PR passes before it merges",
|
|
34
|
+
},
|
|
35
|
+
scope: { label: "Scope check", hint: "Whether a diff stays in its card" },
|
|
36
|
+
interactive: {
|
|
37
|
+
label: "Interactive sessions",
|
|
38
|
+
hint: "Sessions you start and talk to yourself",
|
|
39
|
+
},
|
|
40
|
+
helper: {
|
|
41
|
+
label: "Chat & summary helpers",
|
|
42
|
+
hint: "Titles, summaries and digests",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ANTHROPIC = "anthropic";
|
|
47
|
+
const OPUS = `${ANTHROPIC}/claude-opus-5-5`;
|
|
48
|
+
const SONNET = `${ANTHROPIC}/claude-sonnet-5-5`;
|
|
49
|
+
const HAIKU = `${ANTHROPIC}/claude-haiku-4-5-20251001`;
|
|
50
|
+
|
|
51
|
+
// Claude stays the default for every role: another provider is opt-in.
|
|
52
|
+
export const DEFAULT_ROLE_MODELS: Record<ModelRole, string> = {
|
|
53
|
+
plan: OPUS,
|
|
54
|
+
review: OPUS,
|
|
55
|
+
implement: SONNET,
|
|
56
|
+
orchestrator: SONNET,
|
|
57
|
+
scope: SONNET,
|
|
58
|
+
interactive: OPUS,
|
|
59
|
+
helper: HAIKU,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The roles that gate a task's merge: only Settings and its workspace pick
|
|
63
|
+
// their model, never the task (or the orchestrator that started it), so
|
|
64
|
+
// work can't choose its own reviewer.
|
|
65
|
+
export const GATE_ROLES: readonly ModelRole[] = ["review", "scope"];
|
|
66
|
+
export const TASK_ROLES = MODEL_ROLES.filter((r) => !GATE_ROLES.includes(r));
|
|
67
|
+
|
|
68
|
+
export const isModelRole = (v: unknown): v is ModelRole =>
|
|
69
|
+
typeof v === "string" && (MODEL_ROLES as readonly string[]).includes(v);
|
|
70
|
+
|
|
71
|
+
export interface ModelRef {
|
|
72
|
+
provider: string;
|
|
73
|
+
model: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "provider/model", split at the first slash (a model id may hold more).
|
|
77
|
+
// A bare model ("opus", "claude-sonnet-5-5") is Anthropic's.
|
|
78
|
+
export function parseModelRef(ref: string): ModelRef {
|
|
79
|
+
const s = ref.trim();
|
|
80
|
+
const at = s.indexOf("/");
|
|
81
|
+
if (at <= 0) return { provider: ANTHROPIC, model: s };
|
|
82
|
+
return { provider: s.slice(0, at), model: s.slice(at + 1) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const formatModelRef = (r: ModelRef) => `${r.provider}/${r.model}`;
|
|
86
|
+
|
|
87
|
+
export const REF_PATTERN = /^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i;
|
|
88
|
+
|
|
89
|
+
// The role a session's next turn runs as.
|
|
90
|
+
export function sessionRole(session: {
|
|
91
|
+
role?: string | null;
|
|
92
|
+
task_status?: string | null;
|
|
93
|
+
plan_phase?: string | null;
|
|
94
|
+
}): ModelRole {
|
|
95
|
+
if (session.role === "orchestrator") return "orchestrator";
|
|
96
|
+
if (!session.task_status) return "interactive";
|
|
97
|
+
return session.plan_phase === "planning" ? "plan" : "implement";
|
|
98
|
+
}
|