@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moving a session to another model without losing its conversation or
|
|
3
|
+
* worktree. On the same provider a live conversation switches in place;
|
|
4
|
+
* across providers the endpoint and key are in the agent's environment, so
|
|
5
|
+
* its worker goes and the next message starts one that resumes the same
|
|
6
|
+
* conversation on the new provider.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { db, type Session } from "../db";
|
|
10
|
+
import { registry } from "../chat/registry";
|
|
11
|
+
import { setChatModel } from "../chat/settings";
|
|
12
|
+
import { setSessionModel, type RoutedModel } from "./resolve";
|
|
13
|
+
import { ANTHROPIC } from "./roles";
|
|
14
|
+
|
|
15
|
+
export async function switchSessionModel(
|
|
16
|
+
sessionId: string,
|
|
17
|
+
routed: RoutedModel
|
|
18
|
+
): Promise<void> {
|
|
19
|
+
const before = db
|
|
20
|
+
.prepare(`SELECT model_provider FROM sessions WHERE id = ?`)
|
|
21
|
+
.get(sessionId) as Pick<Session, "model_provider"> | undefined;
|
|
22
|
+
setSessionModel(sessionId, routed);
|
|
23
|
+
await applySessionModel(
|
|
24
|
+
sessionId,
|
|
25
|
+
before?.model_provider ?? ANTHROPIC,
|
|
26
|
+
routed
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// What's live catches up with the row: the model now, or a fresh worker.
|
|
31
|
+
export async function applySessionModel(
|
|
32
|
+
sessionId: string,
|
|
33
|
+
fromProvider: string,
|
|
34
|
+
routed: RoutedModel
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (fromProvider === routed.provider.id)
|
|
37
|
+
return setChatModel(sessionId, routed.model);
|
|
38
|
+
if (registry.live.has(sessionId))
|
|
39
|
+
(await import("../chat/runner")).stopChat(sessionId);
|
|
40
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Test provider": one tiny request to a provider's endpoint with its key,
|
|
3
|
+
* reporting whether it answered. The key goes only into the request's
|
|
4
|
+
* header; what comes back is scrubbed of it before anyone sees it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
|
|
8
|
+
import { effectiveModels } from "./config";
|
|
9
|
+
import type { ModelProvider } from "./providers";
|
|
10
|
+
import { ANTHROPIC } from "./roles";
|
|
11
|
+
import { redact, resolveSecret, type SecretCommand } from "./secrets";
|
|
12
|
+
import os from "os";
|
|
13
|
+
|
|
14
|
+
export interface ProviderTest {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
message: string;
|
|
17
|
+
ms: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PROMPT = "Reply with the word OK.";
|
|
21
|
+
|
|
22
|
+
export async function testProvider(
|
|
23
|
+
providerId: string,
|
|
24
|
+
model?: string,
|
|
25
|
+
deps: {
|
|
26
|
+
exec?: SecretCommand;
|
|
27
|
+
fetch?: typeof fetch;
|
|
28
|
+
claude?: ClaudeRunner;
|
|
29
|
+
} = {}
|
|
30
|
+
): Promise<ProviderTest> {
|
|
31
|
+
const started = Date.now();
|
|
32
|
+
const done = (ok: boolean, message: string): ProviderTest => ({
|
|
33
|
+
ok,
|
|
34
|
+
message: redact(message).slice(0, 300),
|
|
35
|
+
ms: Date.now() - started,
|
|
36
|
+
});
|
|
37
|
+
const p = effectiveModels().providers.find((x) => x.id === providerId);
|
|
38
|
+
if (!p) return done(false, `No provider "${providerId}"`);
|
|
39
|
+
const m = model || p.tiers?.haiku || p.models[0];
|
|
40
|
+
if (!m) return done(false, "It lists no model to try");
|
|
41
|
+
try {
|
|
42
|
+
if (p.id === ANTHROPIC) {
|
|
43
|
+
await (deps.claude ?? runClaude)({
|
|
44
|
+
cwd: os.tmpdir(),
|
|
45
|
+
system: "Answer in the schema.",
|
|
46
|
+
prompt: PROMPT,
|
|
47
|
+
schema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: { ok: { type: "boolean" } },
|
|
50
|
+
required: ["ok"],
|
|
51
|
+
},
|
|
52
|
+
tools: [],
|
|
53
|
+
model: m,
|
|
54
|
+
timeoutMs: 60_000,
|
|
55
|
+
});
|
|
56
|
+
return done(true, `${m} answered through the Claude login`);
|
|
57
|
+
}
|
|
58
|
+
if (!p.baseUrl || !p.auth) return done(false, "Set its base URL and key");
|
|
59
|
+
const key = await resolveSecret(p.auth, deps.exec);
|
|
60
|
+
const res = await (deps.fetch ?? fetch)(...request(p, m, key));
|
|
61
|
+
// Never followed: a redirect would carry the key somewhere else.
|
|
62
|
+
if (res.status >= 300 && res.status < 400)
|
|
63
|
+
return done(
|
|
64
|
+
false,
|
|
65
|
+
`HTTP ${res.status}: the endpoint redirected; set its final URL`
|
|
66
|
+
);
|
|
67
|
+
if (res.ok) return done(true, `${m} answered (HTTP ${res.status})`);
|
|
68
|
+
const body = (await res.text().catch(() => ""))
|
|
69
|
+
.slice(0, 200)
|
|
70
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
|
|
71
|
+
return done(false, `HTTP ${res.status}: ${body}`);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return done(false, e instanceof Error ? e.message : String(e));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function request(
|
|
78
|
+
p: ModelProvider,
|
|
79
|
+
model: string,
|
|
80
|
+
key: string
|
|
81
|
+
): [string, RequestInit] {
|
|
82
|
+
const base = p.baseUrl!.replace(/\/+$/, "");
|
|
83
|
+
const messages = [{ role: "user", content: PROMPT }];
|
|
84
|
+
const signal = AbortSignal.timeout(30_000);
|
|
85
|
+
const redirect = "manual" as const;
|
|
86
|
+
if (p.api === "openai")
|
|
87
|
+
return [
|
|
88
|
+
`${base}/chat/completions`,
|
|
89
|
+
{
|
|
90
|
+
method: "POST",
|
|
91
|
+
redirect,
|
|
92
|
+
signal,
|
|
93
|
+
headers: {
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
authorization: `Bearer ${key}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ model, max_tokens: 8, messages }),
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
return [
|
|
101
|
+
`${base}/v1/messages`,
|
|
102
|
+
{
|
|
103
|
+
method: "POST",
|
|
104
|
+
redirect,
|
|
105
|
+
signal,
|
|
106
|
+
headers: {
|
|
107
|
+
"content-type": "application/json",
|
|
108
|
+
"anthropic-version": "2023-06-01",
|
|
109
|
+
...(p.authHeader === "x-api-key"
|
|
110
|
+
? { "x-api-key": key }
|
|
111
|
+
: { authorization: `Bearer ${key}` }),
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ model, max_tokens: 8, messages }),
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* the brakes.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { parseOverride, type ModelOverride } from "../models/resolve";
|
|
7
8
|
import { sendMessage } from "../bus";
|
|
8
9
|
import { chatState, interruptChat } from "../chat/runner";
|
|
9
10
|
import { hostExec, isRemoteHost } from "../hosts";
|
|
@@ -80,9 +81,17 @@ export async function startTask(
|
|
|
80
81
|
workspaceId: string,
|
|
81
82
|
projectRef: string,
|
|
82
83
|
prompt: string,
|
|
83
|
-
opts: {
|
|
84
|
+
opts: {
|
|
85
|
+
base?: string;
|
|
86
|
+
name?: string;
|
|
87
|
+
view?: StartView;
|
|
88
|
+
after?: string;
|
|
89
|
+
model?: ModelOverride;
|
|
90
|
+
plan?: boolean;
|
|
91
|
+
} = {}
|
|
84
92
|
): Promise<string> {
|
|
85
93
|
const project = workspaceProject(workspaceId, projectRef);
|
|
94
|
+
const model = parseOverride(opts.model) ?? undefined;
|
|
86
95
|
// Its brakes read this machine's usage, not the other machine's account.
|
|
87
96
|
if (isRemoteHost(project.host_id))
|
|
88
97
|
throw new Error(
|
|
@@ -97,6 +106,7 @@ export async function startTask(
|
|
|
97
106
|
prompt,
|
|
98
107
|
name: opts.name,
|
|
99
108
|
view,
|
|
109
|
+
model,
|
|
100
110
|
baseBranch: opts.base || undefined,
|
|
101
111
|
after: opts.after,
|
|
102
112
|
originWorkspaceId: workspaceId,
|
|
@@ -112,6 +122,8 @@ export async function startTask(
|
|
|
112
122
|
name: opts.name,
|
|
113
123
|
baseBranch: opts.base || undefined,
|
|
114
124
|
view,
|
|
125
|
+
model,
|
|
126
|
+
plan: opts.plan,
|
|
115
127
|
}),
|
|
116
128
|
(s) => s.id
|
|
117
129
|
)
|
|
@@ -92,7 +92,8 @@ Reading:
|
|
|
92
92
|
Acting:
|
|
93
93
|
- \`${TOOL_NAMES.send}\` (session, message, scope_change?): message a session; it arrives as its next prompt. scope_change records it on the task's brief for its review.
|
|
94
94
|
- \`${TOOL_NAMES.message_orchestrator}\` (workspace, message): message another workspace's orchestrator, the only thing you reach outside this workspace. Use it to hand over a bug or ask for something in its projects; it decides what to do there.
|
|
95
|
-
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does.
|
|
95
|
+
- \`${TOOL_NAMES.start_task}\` (project, prompt, base?, name?, view?, after?, model?, plan?): a task in its own worktree that ends in a PR. Write the prompt as a full brief; name it in 2-6 words, or it's named from the prompt. It runs as a chat; pass view terminal only when the job needs a TUI. Pass after (a task's id or name, or "any") to hold it until that task finishes, instead of remembering it in your notes. Over the workspace's running task limit it's queued too: the result says "Queued (position N)", it starts by itself, and you get an event when it does. Its models follow the role settings (it plans on the planning model, then implements on the implementation one); pass model ("provider/model", or a map of role to one) only when Saad asks for one, and another provider than anthropic only then.
|
|
96
|
+
- \`${TOOL_NAMES.set_task_model}\` (task, model, message?): move a paused task to another model and carry on in the same conversation and worktree; it keeps that model from then on. Only when Saad asks.
|
|
96
97
|
- \`${TOOL_NAMES.start_session}\` (project, prompt, name?, view?): an interactive session, a chat unless view is terminal.
|
|
97
98
|
- \`${TOOL_NAMES.stack}\` (target, plan_only?): run a board's open cards as stacked tasks; plan_only shows the plan without starting.
|
|
98
99
|
- \`${TOOL_NAMES.land}\` (id): merge a stack bottom-up through the gates.
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { spawn } from "child_process";
|
|
12
12
|
import { runUsageOf, type RunUsage } from "../usage/check-runs";
|
|
13
|
+
import { routedRun } from "../models/launch";
|
|
14
|
+
import type { ModelRole } from "../models/roles";
|
|
15
|
+
import { redact } from "../models/secrets";
|
|
13
16
|
|
|
14
17
|
export interface ClaudeRun {
|
|
15
18
|
cwd: string;
|
|
@@ -21,16 +24,18 @@ export interface ClaudeRun {
|
|
|
21
24
|
// Permission rules within those, e.g. "Bash(git diff:*)".
|
|
22
25
|
allow?: string[];
|
|
23
26
|
timeoutMs?: number;
|
|
24
|
-
// A model alias or id
|
|
27
|
+
// A model alias or id on Anthropic; the role's model otherwise.
|
|
25
28
|
model?: string;
|
|
26
29
|
// What the run cost, from its result, even when it failed.
|
|
27
30
|
onUsage?: (usage: RunUsage) => void;
|
|
31
|
+
// The kind of work it is, which picks its model and provider
|
|
32
|
+
// (lib/models): Settings' and the workspace's role models.
|
|
33
|
+
role?: ModelRole;
|
|
34
|
+
workspaceId?: string | null;
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export type ClaudeRunner = (run: ClaudeRun) => Promise<unknown>;
|
|
31
38
|
|
|
32
|
-
const MODEL = process.env.AGENTOS_REVIEW_MODEL;
|
|
33
|
-
|
|
34
39
|
// All a check's process gets from the server's environment: enough to run
|
|
35
40
|
// and to sign in to Claude, nothing else.
|
|
36
41
|
const ENV_ALLOW = [
|
|
@@ -57,7 +62,7 @@ export function checkEnv(
|
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
export function claudeArgs(run: ClaudeRun): string[] {
|
|
60
|
-
const model = run.model
|
|
65
|
+
const model = run.model;
|
|
61
66
|
return [
|
|
62
67
|
"-p",
|
|
63
68
|
"--output-format",
|
|
@@ -87,12 +92,35 @@ export function claudeArgs(run: ClaudeRun): string[] {
|
|
|
87
92
|
];
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
interface ClaudeResult {
|
|
96
|
+
is_error?: boolean;
|
|
97
|
+
result?: string;
|
|
98
|
+
structured_output?: unknown;
|
|
99
|
+
}
|
|
100
|
+
|
|
90
101
|
// The structured answer, or throws with what went wrong.
|
|
91
|
-
export const runClaude: ClaudeRunner = (run) =>
|
|
92
|
-
|
|
102
|
+
export const runClaude: ClaudeRunner = async (run) => {
|
|
103
|
+
const routed =
|
|
104
|
+
run.role && !run.model
|
|
105
|
+
? await routedRun(run.role, { workspaceId: run.workspaceId })
|
|
106
|
+
: null;
|
|
107
|
+
const result = await execClaude(
|
|
108
|
+
{ ...run, model: run.model ?? routed?.model },
|
|
109
|
+
{ ...checkEnv(), ...routed?.env },
|
|
110
|
+
routed?.provider ?? "anthropic"
|
|
111
|
+
);
|
|
112
|
+
return result.structured_output;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function execClaude(
|
|
116
|
+
run: ClaudeRun,
|
|
117
|
+
env: NodeJS.ProcessEnv,
|
|
118
|
+
provider: string
|
|
119
|
+
): Promise<ClaudeResult> {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
93
121
|
const child = spawn("claude", claudeArgs(run), {
|
|
94
122
|
cwd: run.cwd,
|
|
95
|
-
env
|
|
123
|
+
env,
|
|
96
124
|
stdio: ["pipe", "pipe", "pipe"],
|
|
97
125
|
});
|
|
98
126
|
let out = "";
|
|
@@ -113,20 +141,21 @@ export const runClaude: ClaudeRunner = (run) =>
|
|
|
113
141
|
child.on("close", () => {
|
|
114
142
|
clearTimeout(timer);
|
|
115
143
|
try {
|
|
116
|
-
const result = JSON.parse(out) as
|
|
117
|
-
is_error?: boolean;
|
|
118
|
-
result?: string;
|
|
119
|
-
structured_output?: unknown;
|
|
120
|
-
};
|
|
144
|
+
const result = JSON.parse(out) as ClaudeResult;
|
|
121
145
|
const usage = runUsageOf(result);
|
|
122
|
-
if (usage) run.onUsage?.(usage);
|
|
146
|
+
if (usage) run.onUsage?.({ ...usage, provider });
|
|
123
147
|
if (result.is_error || result.structured_output === undefined)
|
|
124
148
|
throw new Error(result.result || "No answer");
|
|
125
|
-
resolve(result
|
|
149
|
+
resolve(result);
|
|
126
150
|
} catch (e) {
|
|
127
151
|
const why = e instanceof Error ? e.message : String(e);
|
|
128
|
-
reject(
|
|
152
|
+
reject(
|
|
153
|
+
new Error(
|
|
154
|
+
redact(`claude -p failed: ${why} ${err.slice(-300)}`.trim())
|
|
155
|
+
)
|
|
156
|
+
);
|
|
129
157
|
}
|
|
130
158
|
});
|
|
131
159
|
child.stdin.end(run.prompt);
|
|
132
160
|
});
|
|
161
|
+
}
|
package/lib/orchestrator/home.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
* sessions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { resolveRoleOrDefault } from "../models/resolve";
|
|
8
9
|
import fs from "fs";
|
|
9
10
|
import os from "os";
|
|
10
11
|
import path from "path";
|
|
11
12
|
import { randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
|
12
13
|
import { db, type Session } from "../db";
|
|
13
|
-
import { resolveModelForAgent } from "../model-catalog";
|
|
14
14
|
import { getWorkspace } from "../workspaces";
|
|
15
15
|
|
|
16
16
|
// It works through tools, not in a repo: a scratch folder of its own.
|
|
@@ -62,7 +62,7 @@ export function ensureOrchestrator(workspaceId: string): Session {
|
|
|
62
62
|
`${workspace.name} orchestrator`,
|
|
63
63
|
`claude-${id}`,
|
|
64
64
|
dir,
|
|
65
|
-
|
|
65
|
+
resolveRoleOrDefault("orchestrator", { workspaceId }).model,
|
|
66
66
|
workspaceId
|
|
67
67
|
);
|
|
68
68
|
return getOrchestrator(workspaceId)!;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { resumeOnModel } from "../models/resume";
|
|
2
|
+
import { workspaceTask } from "./targets";
|
|
1
3
|
import { getWorkspace } from "../workspaces";
|
|
2
4
|
import {
|
|
3
5
|
done,
|
|
@@ -33,6 +35,7 @@ const ACTING = new Set<ToolName>([
|
|
|
33
35
|
"send",
|
|
34
36
|
"message_orchestrator",
|
|
35
37
|
"start_task",
|
|
38
|
+
"set_task_model",
|
|
36
39
|
"start_session",
|
|
37
40
|
"stack",
|
|
38
41
|
"land",
|
|
@@ -97,8 +100,18 @@ export async function runTool(
|
|
|
97
100
|
name: a.name,
|
|
98
101
|
view: a.view,
|
|
99
102
|
after: a.after,
|
|
103
|
+
model: a.model,
|
|
104
|
+
plan: a.plan,
|
|
100
105
|
});
|
|
101
106
|
}
|
|
107
|
+
case "set_task_model": {
|
|
108
|
+
const a = p(tool, raw);
|
|
109
|
+
const task = workspaceTask(w, a.task);
|
|
110
|
+
const out = await resumeOnModel(task.id, a.model, a.message);
|
|
111
|
+
return out.resumed
|
|
112
|
+
? `Resumed ${task.name} on ${out.ref}: same conversation and worktree.`
|
|
113
|
+
: `${task.name} hasn't launched yet; it starts on ${out.ref}.`;
|
|
114
|
+
}
|
|
102
115
|
case "start_session": {
|
|
103
116
|
const a = p(tool, raw);
|
|
104
117
|
return startSession(w, a.project, a.prompt, {
|
|
@@ -14,6 +14,7 @@ export const TOOL_NAMES = {
|
|
|
14
14
|
orchestrators: name("orchestrators"),
|
|
15
15
|
message_orchestrator: name("message_orchestrator"),
|
|
16
16
|
start_task: name("start_task"),
|
|
17
|
+
set_task_model: name("set_task_model"),
|
|
17
18
|
start_session: name("start_session"),
|
|
18
19
|
stack: name("stack"),
|
|
19
20
|
stack_status: name("stack_status"),
|
|
@@ -6,6 +6,7 @@ import { BRANCH_NAME } from "../git";
|
|
|
6
6
|
import { RAISED_KINDS } from "./asks";
|
|
7
7
|
import { MAX_RULE_CHARS } from "./rules";
|
|
8
8
|
import { PROJECT_NAME } from "../project-add/name";
|
|
9
|
+
import { TASK_ROLES, type ModelRole } from "../models/roles";
|
|
9
10
|
|
|
10
11
|
const ref = (what: string) => z.string().trim().min(1).max(300).describe(what);
|
|
11
12
|
const session = ref("Session name, project/name, or id from sessions");
|
|
@@ -19,6 +20,23 @@ const view = z
|
|
|
19
20
|
"How its agent runs: chat (the default), or terminal only when the job needs a TUI (an interactive program, a full-screen tool)"
|
|
20
21
|
);
|
|
21
22
|
|
|
23
|
+
const modelRef = z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.regex(/^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i, 'not "provider/model"');
|
|
27
|
+
const modelOverride = z
|
|
28
|
+
.union([
|
|
29
|
+
modelRef,
|
|
30
|
+
z.partialRecord(
|
|
31
|
+
z.enum(TASK_ROLES as [ModelRole, ...ModelRole[]]),
|
|
32
|
+
modelRef
|
|
33
|
+
),
|
|
34
|
+
])
|
|
35
|
+
.optional()
|
|
36
|
+
.describe(
|
|
37
|
+
"Its own model, as \"provider/model\" (anthropic/claude-sonnet-5-5, deepseek/deepseek-v4-pro) for all its roles, or a map of role (plan, implement, review, scope) to one. Default: the workspace's and Settings' role models; another provider only when asked"
|
|
38
|
+
);
|
|
39
|
+
|
|
22
40
|
export const TOOL_SHAPES = {
|
|
23
41
|
sessions: {},
|
|
24
42
|
read: {
|
|
@@ -79,6 +97,26 @@ export const TOOL_SHAPES = {
|
|
|
79
97
|
.describe(
|
|
80
98
|
'Queue it until this task finishes (id or name), or "any" for whichever running task finishes first'
|
|
81
99
|
),
|
|
100
|
+
model: modelOverride,
|
|
101
|
+
plan: z
|
|
102
|
+
.boolean()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe(
|
|
105
|
+
"Plan first on the planning model, then implement on the implementation model (default: Settings)"
|
|
106
|
+
),
|
|
107
|
+
},
|
|
108
|
+
set_task_model: {
|
|
109
|
+
task,
|
|
110
|
+
model: modelOverride.unwrap(),
|
|
111
|
+
message: z
|
|
112
|
+
.string()
|
|
113
|
+
.trim()
|
|
114
|
+
.min(1)
|
|
115
|
+
.max(8000)
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(
|
|
118
|
+
"What it's told on resuming (default: carry on where it left off)"
|
|
119
|
+
),
|
|
82
120
|
},
|
|
83
121
|
start_session: {
|
|
84
122
|
project,
|
|
@@ -61,7 +61,9 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
61
61
|
message_orchestrator:
|
|
62
62
|
"Message another workspace's orchestrator, by workspace name; never any other session there. It arrives as its next prompt, marked as from you and fenced as untrusted.",
|
|
63
63
|
start_task:
|
|
64
|
-
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Refused while a brake holds.",
|
|
64
|
+
"Start a task: an agent in its own worktree that ends in a PR. It runs as a chat unless view is terminal (only for a job that needs a TUI). With after (a task's id or name, or \"any\"), or over the workspace's running task limit, it's queued and starts by itself; the result says Queued (position N). Its models follow the role settings (planning, implementation, review, scope); give model only when Saad asks for one. Refused while a brake holds.",
|
|
65
|
+
set_task_model:
|
|
66
|
+
'Resume a paused task on another model ("provider/model", or a map of role to one), keeping its conversation and worktree; it\'s told to carry on (or message). Refused mid-turn.',
|
|
65
67
|
start_session:
|
|
66
68
|
"Start an interactive agent session in a project with a prompt. It runs as a chat unless view is terminal (only for a job that needs a TUI). Refused while a brake holds.",
|
|
67
69
|
stack:
|
package/lib/pr-generation.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { routedRun } from "./models/launch";
|
|
1
2
|
import { execFile } from "child_process";
|
|
2
3
|
import { git as runGit } from "./git-exec";
|
|
3
4
|
import { run } from "./tasks/gh";
|
|
@@ -154,13 +155,15 @@ async function generateWithClaude(
|
|
|
154
155
|
|
|
155
156
|
// `claude --print` with the prompt on stdin, off the event loop and killed
|
|
156
157
|
// at the ceiling.
|
|
157
|
-
function claudePrint(cwd: string, prompt: string): Promise<string> {
|
|
158
|
+
async function claudePrint(cwd: string, prompt: string): Promise<string> {
|
|
159
|
+
const routed = await routedRun("helper");
|
|
158
160
|
return new Promise((resolve, reject) => {
|
|
159
161
|
const child = execFile(
|
|
160
162
|
"claude",
|
|
161
|
-
["--print"],
|
|
163
|
+
["--print", "--model", routed.model],
|
|
162
164
|
{
|
|
163
165
|
cwd,
|
|
166
|
+
env: { ...process.env, ...routed.env },
|
|
164
167
|
encoding: "utf-8",
|
|
165
168
|
timeout: 30_000,
|
|
166
169
|
killSignal: "SIGKILL",
|
|
@@ -73,7 +73,7 @@ export const PROVIDERS: ProviderDefinition[] = [
|
|
|
73
73
|
supportsFork: true,
|
|
74
74
|
resumeArgs: (id) => ["--resume", id],
|
|
75
75
|
forkArgs: (id) => ["--resume", id, "--fork-session"],
|
|
76
|
-
modelFlag:
|
|
76
|
+
modelFlag: "--model",
|
|
77
77
|
initialPromptFlag: "", // Positional argument
|
|
78
78
|
},
|
|
79
79
|
{
|
package/lib/session-titles.ts
CHANGED
|
@@ -174,7 +174,7 @@ export async function generateTitle(
|
|
|
174
174
|
prompt: input,
|
|
175
175
|
schema: SCHEMA,
|
|
176
176
|
tools: [],
|
|
177
|
-
|
|
177
|
+
role: "helper",
|
|
178
178
|
timeoutMs: 30_000,
|
|
179
179
|
})) as { title?: unknown };
|
|
180
180
|
return cleanTitle(out?.title);
|
package/lib/sessions/launch.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { db, queries, type Session } from "../db";
|
|
|
11
11
|
import type { AgentType } from "../providers";
|
|
12
12
|
import type { ChatAccess, ChatImage } from "../chat/events";
|
|
13
13
|
import { resolveModelForAgent } from "../model-catalog";
|
|
14
|
+
import { resolveRole, setSessionModel } from "../models/resolve";
|
|
15
|
+
import { ANTHROPIC } from "../models/roles";
|
|
14
16
|
import { getProject } from "../projects";
|
|
15
17
|
import { getHost, isRemoteHost } from "../hosts";
|
|
16
18
|
import { hostLink } from "../hosts/remote-api";
|
|
@@ -214,14 +216,26 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
214
216
|
await fs.promises.mkdir(SCRATCH_DIR, { recursive: true });
|
|
215
217
|
|
|
216
218
|
const agentType = input.agentType;
|
|
217
|
-
const model = resolveModelForAgent(
|
|
218
|
-
agentType,
|
|
219
|
-
input.model?.trim() || project?.default_model
|
|
220
|
-
);
|
|
221
219
|
const view =
|
|
222
220
|
input.view === "terminal" || !supportsChat(agentType) || remote
|
|
223
221
|
? "terminal"
|
|
224
222
|
: DEFAULT_START_VIEW;
|
|
223
|
+
// Claude with no model picked runs on the interactive role's, here; on
|
|
224
|
+
// another provider only as a chat, whose worker carries its endpoint.
|
|
225
|
+
const routed =
|
|
226
|
+
agentType === "claude" && !input.model?.trim() && !remote
|
|
227
|
+
? resolveRole("interactive", { workspaceId: project?.workspace_id })
|
|
228
|
+
: null;
|
|
229
|
+
const interactive =
|
|
230
|
+
routed && (routed.provider.id === ANTHROPIC || view === "chat")
|
|
231
|
+
? routed
|
|
232
|
+
: null;
|
|
233
|
+
const model = interactive
|
|
234
|
+
? interactive.model
|
|
235
|
+
: resolveModelForAgent(
|
|
236
|
+
agentType,
|
|
237
|
+
input.model?.trim() || project?.default_model
|
|
238
|
+
);
|
|
225
239
|
const text = input.prompt?.trim() ?? "";
|
|
226
240
|
const naming: Naming = text
|
|
227
241
|
? await nameFor(text, projectPath, input.name)
|
|
@@ -252,6 +266,7 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
|
|
|
252
266
|
db.prepare(
|
|
253
267
|
`UPDATE sessions SET view = ?, name_source = ?, setup_status = ? WHERE id = ?`
|
|
254
268
|
).run(view, naming.source, useWorktree ? "running" : null, id);
|
|
269
|
+
if (interactive) setSessionModel(id, interactive);
|
|
255
270
|
if (input.access)
|
|
256
271
|
db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
|
|
257
272
|
input.access,
|