@saadnvd1/agent-os 0.3.748 → 0.3.750
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 +1 -1
- package/app/api/models/choices/route.ts +7 -3
- package/app/api/sessions/[id]/model/route.ts +2 -23
- package/components/Analyst/StartAnalystSheet.tsx +7 -2
- package/components/Analyst/useAnalystModels.ts +19 -0
- package/components/Chat/ModelPicker.tsx +3 -2
- package/components/Chat/StepOutcomes.tsx +10 -46
- package/components/Chat/Tools.test.ts +11 -14
- package/components/Chat/Tools.tsx +3 -8
- package/components/Draft/AnalystModelChip.tsx +58 -0
- package/components/Draft/DraftChips.tsx +8 -0
- package/components/Draft/DraftPanel.tsx +7 -1
- package/components/Draft/RoleChips.tsx +12 -3
- package/components/Models/AgentRoleCard.tsx +9 -2
- package/components/Models/RoleModelSelect.tsx +23 -8
- package/lib/chat/events.ts +2 -0
- package/lib/chat/role-models.ts +46 -0
- package/lib/chat/settings.ts +7 -4
- package/lib/chat/socket.ts +3 -2
- package/lib/chat/step-summary.ts +54 -0
- package/lib/drafts.ts +2 -0
- package/lib/models/agent-binding.ts +23 -2
- package/lib/models/catalog.ts +43 -1
- package/lib/models/providers.ts +12 -0
- package/lib/models/resolve.ts +3 -7
- package/lib/models/session-model.ts +70 -4
- package/lib/models/tool-use.ts +78 -0
- package/lib/models/usable.ts +45 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ and check on all of it from your phone.
|
|
|
19
19
|
- **Other machines in the same list.** Link another machine that runs AgentOS and start or move work there. [More](docs/reference/machines.md)
|
|
20
20
|
- **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
|
|
21
21
|
- **Phone to orchestrator.** A paired phone can send a shake report or a shared page or file to a workspace's orchestrator (`POST /api/orchestrators/:workspaceId/intake`, saved under `intake/` beside the database, shared content marked untrusted), and fetch names to improve dictation (`GET /api/voice/vocab?workspace=`). [More](docs/guides/phone.md)
|
|
22
|
-
- **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
|
|
22
|
+
- **An analyst beside your coding agent.** Pair a chat with a second one that reviews each turn, explains it in plain English, flags issues and drafts the next prompt, which you send, edit or dismiss. It runs on Claude unless you pick another model, local ones included. Side by side on a desktop, a switch on a phone. [More](docs/decisions/2026-10-21-analyst-sessions.md)
|
|
23
23
|
- **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
|
|
24
24
|
- **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
|
|
25
25
|
- **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import { modelCatalog } from "@/lib/models/catalog";
|
|
2
|
+
import { modelCatalog, unconfiguredChoices } from "@/lib/models/catalog";
|
|
3
3
|
|
|
4
4
|
// GET /api/models/choices - every model a picker offers, from the server:
|
|
5
5
|
// the enabled providers' models and the ones Pi reaches, each with its
|
|
6
|
-
// locality (local or cloud)
|
|
6
|
+
// locality (local or cloud), the last availability check and whether it can
|
|
7
|
+
// call tools. Models of a provider that is off, or half set up, are listed
|
|
8
|
+
// with `unusable` saying why, so a picker can grey them rather than hide them.
|
|
7
9
|
export async function GET() {
|
|
8
|
-
return NextResponse.json({
|
|
10
|
+
return NextResponse.json({
|
|
11
|
+
choices: [...modelCatalog(), ...unconfiguredChoices()],
|
|
12
|
+
});
|
|
9
13
|
}
|
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from "next/server";
|
|
2
2
|
import { db, type Session } from "@/lib/db";
|
|
3
|
-
import {
|
|
4
|
-
import { providerFor, sessionRole } from "@/lib/models/resolve";
|
|
5
|
-
import { switchSessionModel } from "@/lib/models/session-model";
|
|
6
|
-
import { PI } from "@/lib/models/providers";
|
|
7
|
-
import { formatModelRef, parseModelRef } from "@/lib/models/roles";
|
|
3
|
+
import { switchSessionModelRef } from "@/lib/models/session-model";
|
|
8
4
|
|
|
9
5
|
// POST /api/sessions/:id/model {ref} - move a running session to another
|
|
10
6
|
// model ("provider/model", or "pi/<provider>/<model>" for a Pi session),
|
|
@@ -23,24 +19,7 @@ export async function POST(
|
|
|
23
19
|
if (typeof ref !== "string" || !ref.trim())
|
|
24
20
|
return NextResponse.json({ error: "ref is required" }, { status: 400 });
|
|
25
21
|
try {
|
|
26
|
-
|
|
27
|
-
if (session.agent_type === "pi") {
|
|
28
|
-
if (parsed.provider !== PI)
|
|
29
|
-
throw new Error(
|
|
30
|
-
"A Pi session runs on a Pi model (pi/<provider>/<model>)"
|
|
31
|
-
);
|
|
32
|
-
await setChatModel(id, parsed.model);
|
|
33
|
-
} else {
|
|
34
|
-
if (parsed.provider === PI)
|
|
35
|
-
throw new Error("Only a Pi session can move to a Pi model");
|
|
36
|
-
await switchSessionModel(id, {
|
|
37
|
-
role: sessionRole(session),
|
|
38
|
-
ref: formatModelRef(parsed),
|
|
39
|
-
provider: providerFor(parsed.provider),
|
|
40
|
-
model: parsed.model,
|
|
41
|
-
source: "task",
|
|
42
|
-
});
|
|
43
|
-
}
|
|
22
|
+
await switchSessionModelRef(session, ref);
|
|
44
23
|
return NextResponse.json({ ok: true });
|
|
45
24
|
} catch (error) {
|
|
46
25
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
} from "@/components/ui/dialog";
|
|
14
14
|
import { Switch } from "@/components/ui/switch";
|
|
15
15
|
import { RoleModelSelect } from "@/components/Models/RoleModelSelect";
|
|
16
|
+
import { useSessionsQuery } from "@/data/sessions/queries";
|
|
16
17
|
import { analystUi, analystUiActions } from "@/stores/analystUi";
|
|
17
18
|
import { useStartAnalystBeside } from "./pair";
|
|
19
|
+
import { useAnalystModels } from "./useAnalystModels";
|
|
18
20
|
|
|
19
21
|
// Pairs an analyst with a chat: it reviews each turn the coding agent
|
|
20
22
|
// finishes and recommends the next prompt, which the user sends or not.
|
|
@@ -46,6 +48,9 @@ function StartAnalystForm({
|
|
|
46
48
|
const [model, setModel] = useState<string | null>(null);
|
|
47
49
|
const [reviewLast, setReviewLast] = useState(false);
|
|
48
50
|
const start = useStartAnalystBeside();
|
|
51
|
+
const { data } = useSessionsQuery();
|
|
52
|
+
const coder = data?.sessions.find((s) => s.id === sessionId);
|
|
53
|
+
const models = useAnalystModels(coder?.project_id);
|
|
49
54
|
|
|
50
55
|
return (
|
|
51
56
|
<DialogContent className="sm:max-w-[460px]">
|
|
@@ -68,8 +73,8 @@ function StartAnalystForm({
|
|
|
68
73
|
<RoleModelSelect
|
|
69
74
|
id="analyst-model"
|
|
70
75
|
value={model}
|
|
71
|
-
inheritLabel=
|
|
72
|
-
|
|
76
|
+
inheritLabel={models.defaultLabel}
|
|
77
|
+
blockedBy={models.blockedBy}
|
|
73
78
|
onChange={setModel}
|
|
74
79
|
/>
|
|
75
80
|
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useAgentRolesQuery, useModelChoices } from "@/data/models";
|
|
2
|
+
import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
|
|
3
|
+
|
|
4
|
+
// What an analyst's model pickers need, all from the server: the list, the
|
|
5
|
+
// reason a model can't be picked, and the label of the default (the model
|
|
6
|
+
// the analyst role resolves to in the project the analyst starts in).
|
|
7
|
+
export function useAnalystModels(projectId?: string | null) {
|
|
8
|
+
const { data: choices = [] } = useModelChoices();
|
|
9
|
+
const roles = useAgentRolesQuery(
|
|
10
|
+
projectId ? "project" : "settings",
|
|
11
|
+
projectId
|
|
12
|
+
);
|
|
13
|
+
const bound = roles.data?.roles.find((r) => r.id === "analyst")?.binding;
|
|
14
|
+
return {
|
|
15
|
+
choices,
|
|
16
|
+
defaultLabel: defaultAnalystLabel(bound?.ref, choices),
|
|
17
|
+
blockedBy: analystBlock,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -53,6 +53,7 @@ export function ModelPicker({
|
|
|
53
53
|
<DropdownMenuItem
|
|
54
54
|
key={m.value}
|
|
55
55
|
onSelect={() => onPick(m.value)}
|
|
56
|
+
disabled={!!m.disabled}
|
|
56
57
|
className="flex min-h-11 items-start gap-2 md:min-h-9"
|
|
57
58
|
>
|
|
58
59
|
<Check
|
|
@@ -60,9 +61,9 @@ export function ModelPicker({
|
|
|
60
61
|
/>
|
|
61
62
|
<span className="flex flex-col">
|
|
62
63
|
<span className="text-sm">{m.label}</span>
|
|
63
|
-
{m.description && (
|
|
64
|
+
{(m.disabled || m.description) && (
|
|
64
65
|
<span className="text-muted-foreground line-clamp-2 text-xs">
|
|
65
|
-
{m.description}
|
|
66
|
+
{m.disabled ?? m.description}
|
|
66
67
|
</span>
|
|
67
68
|
)}
|
|
68
69
|
</span>
|
|
@@ -1,21 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { ToolItem } from "@/lib/chat/group";
|
|
4
|
-
import {
|
|
5
|
-
import { cn } from "@/lib/utils";
|
|
6
|
-
import { StatusIcon } from "./Tools";
|
|
4
|
+
import { failedSteps } from "@/lib/chat/step-summary";
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const WORD = {
|
|
11
|
-
running: "running",
|
|
12
|
-
done: "ok",
|
|
13
|
-
error: "failed",
|
|
14
|
-
stopped: "not run",
|
|
15
|
-
} as const;
|
|
16
|
-
|
|
17
|
-
// One line per collapsed group: how each step ended, so the results aren't
|
|
18
|
-
// lost behind the fold.
|
|
6
|
+
// Under a collapsed group that had failures: which steps failed. Nothing when
|
|
7
|
+
// every step passed.
|
|
19
8
|
export function StepOutcomes({
|
|
20
9
|
tools,
|
|
21
10
|
onOpen,
|
|
@@ -23,44 +12,19 @@ export function StepOutcomes({
|
|
|
23
12
|
tools: ToolItem[];
|
|
24
13
|
onOpen: () => void;
|
|
25
14
|
}) {
|
|
26
|
-
const
|
|
15
|
+
const { names, more } = failedSteps(tools);
|
|
16
|
+
if (names.length === 0) return null;
|
|
27
17
|
return (
|
|
28
18
|
<button
|
|
29
19
|
type="button"
|
|
30
20
|
onClick={onOpen}
|
|
31
21
|
aria-label="Show steps"
|
|
32
|
-
className="flex min-h-
|
|
22
|
+
className="text-destructive flex min-h-8 w-full min-w-0 items-center gap-x-3 px-2 pb-1 text-left text-xs md:min-h-0"
|
|
33
23
|
>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
key={t.id}
|
|
39
|
-
title={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
40
|
-
aria-label={`${t.title}: ${WORD[t.status]}${code ? `, ${code}` : ""}`}
|
|
41
|
-
className="flex min-w-0 items-center gap-1"
|
|
42
|
-
>
|
|
43
|
-
<StatusIcon status={t.status} />
|
|
44
|
-
{code && (
|
|
45
|
-
<span
|
|
46
|
-
className={cn(
|
|
47
|
-
"font-mono text-[11px]",
|
|
48
|
-
t.status === "error"
|
|
49
|
-
? "text-destructive"
|
|
50
|
-
: "text-muted-foreground"
|
|
51
|
-
)}
|
|
52
|
-
>
|
|
53
|
-
{code}
|
|
54
|
-
</span>
|
|
55
|
-
)}
|
|
56
|
-
</span>
|
|
57
|
-
);
|
|
58
|
-
})}
|
|
59
|
-
{tools.length > shown.length && (
|
|
60
|
-
<span className="text-muted-foreground/70 font-mono text-[11px]">
|
|
61
|
-
+{tools.length - shown.length} more
|
|
62
|
-
</span>
|
|
63
|
-
)}
|
|
24
|
+
<span className="min-w-0 truncate">
|
|
25
|
+
{names.map((n) => `✕ ${n}`).join(" ")}
|
|
26
|
+
{more > 0 && ` +${more} more`}
|
|
27
|
+
</span>
|
|
64
28
|
</button>
|
|
65
29
|
);
|
|
66
30
|
}
|
|
@@ -2,7 +2,6 @@ import { createElement } from "react";
|
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
import type { ToolItem } from "@/lib/chat/group";
|
|
5
|
-
import { MAX_CHIPS } from "./StepOutcomes";
|
|
6
5
|
import { ToolGroup } from "./Tools";
|
|
7
6
|
|
|
8
7
|
const tool = (id: string, over: Partial<ToolItem> = {}): ToolItem => ({
|
|
@@ -20,7 +19,7 @@ const render = (tools: ToolItem[]) =>
|
|
|
20
19
|
renderToStaticMarkup(createElement(ToolGroup, { tools }));
|
|
21
20
|
|
|
22
21
|
describe("collapsed ToolGroup", () => {
|
|
23
|
-
it("
|
|
22
|
+
it("names the failed steps and counts the rest", () => {
|
|
24
23
|
const html = render([
|
|
25
24
|
tool("a", { output: "HTTP/2 200 \n" }),
|
|
26
25
|
tool("b", { status: "error", output: "Exit code 7" }),
|
|
@@ -31,22 +30,20 @@ describe("collapsed ToolGroup", () => {
|
|
|
31
30
|
expect(html).toContain("1 ok");
|
|
32
31
|
expect(html).toContain("2 failed");
|
|
33
32
|
expect(html).toContain("1 not run");
|
|
34
|
-
expect(html).toContain("check
|
|
35
|
-
expect(html).toContain("check
|
|
36
|
-
expect(html).toContain("check
|
|
37
|
-
expect(html).toContain("check d: not run");
|
|
33
|
+
expect(html).toContain("✕ check b");
|
|
34
|
+
expect(html).toContain("✕ check c");
|
|
35
|
+
expect(html).not.toContain("check a");
|
|
38
36
|
});
|
|
39
37
|
|
|
40
|
-
it("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
);
|
|
38
|
+
it("has no outcome row when every step passed", () => {
|
|
39
|
+
const html = render([tool("a"), tool("b")]);
|
|
40
|
+
expect(html).toContain("2 steps · ran 2 commands");
|
|
41
|
+
expect(html).not.toContain("Show steps");
|
|
44
42
|
});
|
|
45
43
|
|
|
46
|
-
it("
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
it("shows no outcome row while a step runs", () => {
|
|
45
|
+
expect(render([tool("a", { status: "running" })])).not.toContain(
|
|
46
|
+
"Show steps"
|
|
49
47
|
);
|
|
50
|
-
expect(render(tools)).toContain("+3 more");
|
|
51
48
|
});
|
|
52
49
|
});
|
|
@@ -9,6 +9,7 @@ import { needsBody } from "@/lib/chat/bodyCache";
|
|
|
9
9
|
import { cn } from "@/lib/utils";
|
|
10
10
|
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
11
11
|
import { stepCounts, toolOutcome } from "@/lib/chat/tool-outcome";
|
|
12
|
+
import { stepSummary } from "@/lib/chat/step-summary";
|
|
12
13
|
import { StepOutcomes } from "./StepOutcomes";
|
|
13
14
|
import { Highlighted } from "./Code";
|
|
14
15
|
import { BackgroundButton, BackgroundState, useBackground } from "./background";
|
|
@@ -227,13 +228,12 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
227
228
|
export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
228
229
|
const running = tools.some((t) => t.status === "running");
|
|
229
230
|
const { ok, failed, stopped } = stepCounts(tools);
|
|
230
|
-
const edits = tools.filter((t) => t.diff || t.hasDiff).length;
|
|
231
231
|
const moved = tools.filter((t) => t.backgroundTaskId).length;
|
|
232
232
|
const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
|
|
233
233
|
const latest =
|
|
234
234
|
tools.findLast((t) => t.status === "running") ?? tools[tools.length - 1];
|
|
235
235
|
return (
|
|
236
|
-
<div className="bg-foreground/[0.025] rounded-xl px-2 py-
|
|
236
|
+
<div className="bg-foreground/[0.025] rounded-xl px-2 py-0.5">
|
|
237
237
|
<div className="flex items-center gap-1">
|
|
238
238
|
<button
|
|
239
239
|
type="button"
|
|
@@ -250,12 +250,7 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
|
|
|
250
250
|
<Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
251
251
|
) : null}
|
|
252
252
|
<span className="min-w-0 flex-1 truncate">
|
|
253
|
-
{running
|
|
254
|
-
? latest.title
|
|
255
|
-
: `${tools.length} step${tools.length === 1 ? "" : "s"}`}
|
|
256
|
-
{!running &&
|
|
257
|
-
edits > 0 &&
|
|
258
|
-
` · ${edits} edit${edits === 1 ? "" : "s"}`}
|
|
253
|
+
{running ? latest.title : stepSummary(tools)}
|
|
259
254
|
{!running && (failed > 0 || stopped > 0) && ` · ${ok} ok`}
|
|
260
255
|
</span>
|
|
261
256
|
{running && <Elapsed tool={latest} />}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Cpu } from "lucide-react";
|
|
4
|
+
import {
|
|
5
|
+
AvailabilityDot,
|
|
6
|
+
LocalityBadge,
|
|
7
|
+
} from "@/components/Models/ModelBadges";
|
|
8
|
+
import { useAnalystModels } from "@/components/Analyst/useAnalystModels";
|
|
9
|
+
import type { Draft } from "@/lib/drafts";
|
|
10
|
+
import { ChipItem, ChipMenu } from "./Chip";
|
|
11
|
+
|
|
12
|
+
// The model of the analyst a new chat starts with: the analyst role's
|
|
13
|
+
// default unless one is picked. The list is the server's; a model that
|
|
14
|
+
// can't serve an analyst is greyed with the reason.
|
|
15
|
+
export function AnalystModelChip({
|
|
16
|
+
draft,
|
|
17
|
+
projectId,
|
|
18
|
+
onChange,
|
|
19
|
+
}: {
|
|
20
|
+
draft: Draft;
|
|
21
|
+
projectId: string | null;
|
|
22
|
+
onChange: (patch: Partial<Draft>) => void;
|
|
23
|
+
}) {
|
|
24
|
+
const { choices, defaultLabel, blockedBy } = useAnalystModels(projectId);
|
|
25
|
+
const picked = choices.find((c) => c.ref === draft.analystModel);
|
|
26
|
+
return (
|
|
27
|
+
<ChipMenu
|
|
28
|
+
icon={Cpu}
|
|
29
|
+
name="Analyst model"
|
|
30
|
+
label={picked?.label ?? draft.analystModel ?? defaultLabel}
|
|
31
|
+
>
|
|
32
|
+
<ChipItem
|
|
33
|
+
checked={!draft.analystModel}
|
|
34
|
+
onSelect={() => onChange({ analystModel: null })}
|
|
35
|
+
>
|
|
36
|
+
{defaultLabel}
|
|
37
|
+
</ChipItem>
|
|
38
|
+
{choices.map((c) => {
|
|
39
|
+
const why = c.unusable ?? blockedBy(c);
|
|
40
|
+
return (
|
|
41
|
+
<ChipItem
|
|
42
|
+
key={c.ref}
|
|
43
|
+
checked={c.ref === draft.analystModel}
|
|
44
|
+
onSelect={() => onChange({ analystModel: c.ref })}
|
|
45
|
+
disabled={!!why}
|
|
46
|
+
hint={why ?? c.group}
|
|
47
|
+
>
|
|
48
|
+
<span className="flex items-center gap-1.5">
|
|
49
|
+
<AvailabilityDot availability={c.availability} />
|
|
50
|
+
{c.label}
|
|
51
|
+
<LocalityBadge locality={c.locality} />
|
|
52
|
+
</span>
|
|
53
|
+
</ChipItem>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</ChipMenu>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -20,6 +20,7 @@ import { modelChoices, useModelsQuery } from "@/data/models";
|
|
|
20
20
|
import { useBrowserMachine } from "@/data/machines";
|
|
21
21
|
import { machineLabel } from "@/lib/machines/label";
|
|
22
22
|
import { RoleChips } from "./RoleChips";
|
|
23
|
+
import { AnalystModelChip } from "./AnalystModelChip";
|
|
23
24
|
import { AgentModelChip } from "./AgentModelChip";
|
|
24
25
|
import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
|
|
25
26
|
import {
|
|
@@ -232,6 +233,13 @@ export function DraftChips({
|
|
|
232
233
|
onChange={(withAnalyst) => onChange({ withAnalyst })}
|
|
233
234
|
/>
|
|
234
235
|
)}
|
|
236
|
+
{project && !draft.openPr && local && draft.withAnalyst && (
|
|
237
|
+
<AnalystModelChip
|
|
238
|
+
draft={draft}
|
|
239
|
+
projectId={project.id}
|
|
240
|
+
onChange={onChange}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
235
243
|
{project && draft.openPr && local && (
|
|
236
244
|
<ChipMenu
|
|
237
245
|
icon={Cpu}
|
|
@@ -179,7 +179,13 @@ export function DraftPanel({
|
|
|
179
179
|
);
|
|
180
180
|
if (draft.withAnalyst && project && draft.hostId === "local") {
|
|
181
181
|
if (session.view === "chat")
|
|
182
|
-
void startAnalyst.run(
|
|
182
|
+
void startAnalyst.run(
|
|
183
|
+
{
|
|
184
|
+
coderSessionId: session.id,
|
|
185
|
+
analystModel: draft.analystModel ?? undefined,
|
|
186
|
+
},
|
|
187
|
+
paneId
|
|
188
|
+
);
|
|
183
189
|
else toast("An analyst reviews chats: this session runs in a terminal");
|
|
184
190
|
}
|
|
185
191
|
} catch (error) {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "@/components/Models/ModelBadges";
|
|
8
8
|
import { useAgentRolesQuery, useModelChoices } from "@/data/models";
|
|
9
9
|
import type { Draft } from "@/lib/drafts";
|
|
10
|
+
import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
|
|
10
11
|
import {
|
|
11
12
|
AGENT_ROLES,
|
|
12
13
|
AGENT_ROLE_DEFS,
|
|
@@ -34,6 +35,9 @@ export function RoleChips({
|
|
|
34
35
|
const current = override ?? bound?.choice ?? null;
|
|
35
36
|
const modelName = (ref: string | undefined | null) =>
|
|
36
37
|
ref ? ref.split("/").slice(1).join("/") : "…";
|
|
38
|
+
const analyst = role === "analyst";
|
|
39
|
+
const whyNot = (c: (typeof choices)[number]) =>
|
|
40
|
+
c.unusable ?? (analyst ? analystBlock(c) : null);
|
|
37
41
|
return (
|
|
38
42
|
<>
|
|
39
43
|
<ChipMenu
|
|
@@ -71,7 +75,9 @@ export function RoleChips({
|
|
|
71
75
|
label={
|
|
72
76
|
draft.roleModel
|
|
73
77
|
? modelName(draft.roleModel)
|
|
74
|
-
:
|
|
78
|
+
: analyst
|
|
79
|
+
? defaultAnalystLabel(bound?.binding?.ref, choices)
|
|
80
|
+
: `Role model: ${modelName(bound?.binding?.ref)}`
|
|
75
81
|
}
|
|
76
82
|
>
|
|
77
83
|
<ChipItem
|
|
@@ -79,14 +85,17 @@ export function RoleChips({
|
|
|
79
85
|
onSelect={() => onChange({ roleModel: null })}
|
|
80
86
|
hint={bound?.binding ? modelName(bound.binding.ref) : undefined}
|
|
81
87
|
>
|
|
82
|
-
|
|
88
|
+
{analyst
|
|
89
|
+
? defaultAnalystLabel(bound?.binding?.ref, choices)
|
|
90
|
+
: "Role model"}
|
|
83
91
|
</ChipItem>
|
|
84
92
|
{choices.map((c) => (
|
|
85
93
|
<ChipItem
|
|
86
94
|
key={c.ref}
|
|
87
95
|
checked={c.ref === draft.roleModel}
|
|
88
96
|
onSelect={() => onChange({ roleModel: c.ref })}
|
|
89
|
-
|
|
97
|
+
disabled={!!whyNot(c)}
|
|
98
|
+
hint={whyNot(c) ?? c.group}
|
|
90
99
|
>
|
|
91
100
|
<span className="flex items-center gap-1.5">
|
|
92
101
|
<AvailabilityDot availability={c.availability} />
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { AlertTriangle } from "lucide-react";
|
|
4
4
|
import type { AgentRoleView } from "@/lib/models/agent-roles-view";
|
|
5
|
+
import { analystBlock, defaultAnalystLabel } from "@/lib/models/usable";
|
|
5
6
|
import { cn } from "@/lib/utils";
|
|
6
7
|
import { RoleModelSelect } from "./RoleModelSelect";
|
|
7
8
|
|
|
@@ -33,7 +34,11 @@ export function AgentRoleCard({
|
|
|
33
34
|
onFallback: (ref: string | null) => void;
|
|
34
35
|
}) {
|
|
35
36
|
const down = role.choice?.availability.state === "down";
|
|
36
|
-
const
|
|
37
|
+
const analyst = role.id === "analyst";
|
|
38
|
+
const label = analyst
|
|
39
|
+
? defaultAnalystLabel(role.default, [])
|
|
40
|
+
: `Default (${role.default.split("/").slice(1).join("/")})`;
|
|
41
|
+
const blockedBy = analyst ? analystBlock : undefined;
|
|
37
42
|
const inheritLabel =
|
|
38
43
|
scope === "settings"
|
|
39
44
|
? role.modelRole === "advise"
|
|
@@ -66,6 +71,7 @@ export function AgentRoleCard({
|
|
|
66
71
|
? `${inheritLabel} · ${role.binding.ref.split("/").slice(1).join("/")}`
|
|
67
72
|
: inheritLabel
|
|
68
73
|
}
|
|
74
|
+
blockedBy={blockedBy}
|
|
69
75
|
disabled={pending}
|
|
70
76
|
onChange={onChange}
|
|
71
77
|
/>
|
|
@@ -102,7 +108,8 @@ export function AgentRoleCard({
|
|
|
102
108
|
id={`agent-role-fallback-${role.id}`}
|
|
103
109
|
value={role.fallback}
|
|
104
110
|
inheritLabel="None (don't fall back)"
|
|
105
|
-
claudeOnly
|
|
111
|
+
claudeOnly={!analyst}
|
|
112
|
+
blockedBy={blockedBy}
|
|
106
113
|
disabled={pending}
|
|
107
114
|
onChange={onFallback}
|
|
108
115
|
/>
|
|
@@ -15,25 +15,35 @@ import { AvailabilityDot, LocalityBadge } from "./ModelBadges";
|
|
|
15
15
|
|
|
16
16
|
const INHERIT = "__inherit";
|
|
17
17
|
|
|
18
|
-
function Row({ choice }: { choice: ModelChoice }) {
|
|
18
|
+
function Row({ choice, why }: { choice: ModelChoice; why?: string | null }) {
|
|
19
19
|
return (
|
|
20
|
-
<span className="flex min-w-0 flex-1
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
21
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
22
|
+
<AvailabilityDot availability={choice.availability} />
|
|
23
|
+
<span className="truncate">{choice.label}</span>
|
|
24
|
+
<LocalityBadge locality={choice.locality} />
|
|
25
|
+
</span>
|
|
26
|
+
{why && (
|
|
27
|
+
<span className="text-muted-foreground text-xs whitespace-normal">
|
|
28
|
+
{why}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
24
31
|
</span>
|
|
25
32
|
);
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
// A model picker over the server's list: grouped by provider, each model
|
|
29
36
|
// labelled Local or Cloud with whether its server answers. `claudeOnly`
|
|
30
|
-
// leaves out the models that run through Pi.
|
|
37
|
+
// leaves out the models that run through Pi. `blockedBy` greys a model with
|
|
38
|
+
// the reason it can't be picked instead of hiding it; a model whose provider
|
|
39
|
+
// isn't set up is always greyed.
|
|
31
40
|
export function RoleModelSelect({
|
|
32
41
|
id,
|
|
33
42
|
value,
|
|
34
43
|
inheritLabel,
|
|
35
44
|
claudeOnly,
|
|
36
45
|
gateOnly,
|
|
46
|
+
blockedBy,
|
|
37
47
|
disabled,
|
|
38
48
|
onChange,
|
|
39
49
|
}: {
|
|
@@ -43,11 +53,16 @@ export function RoleModelSelect({
|
|
|
43
53
|
claudeOnly?: boolean;
|
|
44
54
|
// Only models that can serve a merge gate are selectable.
|
|
45
55
|
gateOnly?: boolean;
|
|
56
|
+
blockedBy?: (c: ModelChoice) => string | null;
|
|
46
57
|
disabled?: boolean;
|
|
47
58
|
onChange: (ref: string | null) => void;
|
|
48
59
|
}) {
|
|
49
60
|
const { data } = useModelChoices();
|
|
50
61
|
const choices = (data ?? []).filter((c) => !claudeOnly || c.agent !== "pi");
|
|
62
|
+
const whyNot = (c: ModelChoice) =>
|
|
63
|
+
c.unusable ??
|
|
64
|
+
blockedBy?.(c) ??
|
|
65
|
+
(gateOnly && !c.gateCapable ? "Can't serve a merge gate" : null);
|
|
51
66
|
const groups = [...new Set(choices.map((c) => c.group))];
|
|
52
67
|
const current = value ? choices.find((c) => c.ref === value) : undefined;
|
|
53
68
|
return (
|
|
@@ -73,10 +88,10 @@ export function RoleModelSelect({
|
|
|
73
88
|
<SelectItem
|
|
74
89
|
key={c.ref}
|
|
75
90
|
value={c.ref}
|
|
76
|
-
disabled={
|
|
91
|
+
disabled={!!whyNot(c)}
|
|
77
92
|
className="min-h-11 md:min-h-0"
|
|
78
93
|
>
|
|
79
|
-
<Row choice={c} />
|
|
94
|
+
<Row choice={c} why={whyNot(c)} />
|
|
80
95
|
</SelectItem>
|
|
81
96
|
))}
|
|
82
97
|
</SelectGroup>
|
package/lib/chat/events.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model list in a role session's composer. A chat's own list is whatever
|
|
3
|
+
* its agent reports (for Claude Code, only Claude's aliases), so an analyst
|
|
4
|
+
* would never be offered Qwen, Pi's models or DeepSeek. An analyst's list is
|
|
5
|
+
* the server's one catalog instead, with each model it can't move to greyed
|
|
6
|
+
* and given the reason.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Session } from "../db";
|
|
10
|
+
import { modelDisplayName } from "../model-catalog";
|
|
11
|
+
import { modelCatalog, unconfiguredChoices } from "../models/catalog";
|
|
12
|
+
import { PI } from "../models/providers";
|
|
13
|
+
import { sessionModelRef } from "../models/resolve";
|
|
14
|
+
import { ANTHROPIC } from "../models/roles";
|
|
15
|
+
import { analystBlock } from "../models/usable";
|
|
16
|
+
import type { ChatModel } from "./events";
|
|
17
|
+
|
|
18
|
+
const isAnalystChat = (s: Session) =>
|
|
19
|
+
s.agent_role === "analyst" &&
|
|
20
|
+
(s.agent_type === "claude" || s.agent_type === "pi");
|
|
21
|
+
|
|
22
|
+
// The session's model as a ref, which is what its list's values are.
|
|
23
|
+
export const composerModelValue = (s: Session): string =>
|
|
24
|
+
s.agent_type === "pi" ? `${PI}/${s.model}` : sessionModelRef(s);
|
|
25
|
+
|
|
26
|
+
export function roleComposerModels(
|
|
27
|
+
session: Session,
|
|
28
|
+
own: ChatModel[]
|
|
29
|
+
): ChatModel[] {
|
|
30
|
+
if (!isAnalystChat(session)) return own;
|
|
31
|
+
const agent = session.agent_type === "pi" ? "pi" : "claude";
|
|
32
|
+
const list = [...modelCatalog(), ...unconfiguredChoices()].map(
|
|
33
|
+
(c): ChatModel => {
|
|
34
|
+
const disabled = analystBlock(c, { agent });
|
|
35
|
+
return {
|
|
36
|
+
value: c.ref,
|
|
37
|
+
label: c.ref.startsWith(`${ANTHROPIC}/`)
|
|
38
|
+
? modelDisplayName(c.label)
|
|
39
|
+
: c.label,
|
|
40
|
+
description: c.group,
|
|
41
|
+
...(disabled && { disabled }),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
return list.length ? list : own;
|
|
46
|
+
}
|
package/lib/chat/settings.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { db, type Session } from "../db";
|
|
|
9
9
|
import { agentEnv } from "../agents/launch";
|
|
10
10
|
import { modelDisplayName, resolveModelForAgent } from "../model-catalog";
|
|
11
11
|
import { chatDriverFor } from "./drivers";
|
|
12
|
+
import { composerModelValue, roleComposerModels } from "./role-models";
|
|
12
13
|
import {
|
|
13
14
|
CHAT_ACCESS,
|
|
14
15
|
type ChatAccess,
|
|
@@ -75,15 +76,17 @@ export function cachedCommands(session: Session): ChatCommand[] {
|
|
|
75
76
|
export function emitCapabilities(session: Session, listener?: Listener): void {
|
|
76
77
|
const caps = registry.caps.get(capsKey(session));
|
|
77
78
|
if (!caps) return;
|
|
78
|
-
const
|
|
79
|
+
const models = roleComposerModels(session, caps.models);
|
|
80
|
+
const model =
|
|
81
|
+
models === caps.models ? chatModel(session) : composerModelValue(session);
|
|
79
82
|
const m: ChatServerMessage = {
|
|
80
83
|
type: "capabilities",
|
|
81
84
|
commands: visibleCommands(caps),
|
|
82
|
-
models
|
|
85
|
+
models,
|
|
83
86
|
model,
|
|
84
87
|
modelLabel:
|
|
85
|
-
|
|
86
|
-
modelDisplayName(
|
|
88
|
+
models.find((o) => o.value === model)?.label ??
|
|
89
|
+
modelDisplayName(chatModel(session)),
|
|
87
90
|
access: effectiveChatAccess(session.chat_access),
|
|
88
91
|
plan: canPlan(session) ? !!session.chat_plan && !noApprovalsOn() : null,
|
|
89
92
|
background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
|
package/lib/chat/socket.ts
CHANGED
|
@@ -24,12 +24,12 @@ import {
|
|
|
24
24
|
sendChat,
|
|
25
25
|
sendQueuedNow,
|
|
26
26
|
setChatAccess,
|
|
27
|
-
setChatModel,
|
|
28
27
|
setChatPlan,
|
|
29
28
|
stopChatTask,
|
|
30
29
|
undoChat,
|
|
31
30
|
watchChat,
|
|
32
31
|
} from "./runner";
|
|
32
|
+
import { setComposerModel } from "../models/session-model";
|
|
33
33
|
import { stopChatTurn } from "./stop";
|
|
34
34
|
import { clientSend } from "./client-send";
|
|
35
35
|
import { searchChat } from "./search";
|
|
@@ -189,7 +189,8 @@ function onChatMessage(
|
|
|
189
189
|
.then(answer)
|
|
190
190
|
);
|
|
191
191
|
} else if (msg.type === "interrupt") run(stopChatTurn(sessionId));
|
|
192
|
-
else if (msg.type === "set_model")
|
|
192
|
+
else if (msg.type === "set_model")
|
|
193
|
+
run(setComposerModel(sessionId, msg.model));
|
|
193
194
|
else if (msg.type === "set_access") run(setChatAccess(sessionId, msg.access));
|
|
194
195
|
else if (msg.type === "set_plan") run(setChatPlan(sessionId, !!msg.plan));
|
|
195
196
|
else if (msg.type === "refresh_commands") run(refreshCapabilities(sessionId));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ToolItem } from "./group";
|
|
2
|
+
|
|
3
|
+
export const MAX_FILES = 3;
|
|
4
|
+
export const MAX_FAILED = 3;
|
|
5
|
+
|
|
6
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
7
|
+
|
|
8
|
+
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
|
|
9
|
+
|
|
10
|
+
const editedPath = (t: ToolItem): string | undefined => {
|
|
11
|
+
if (t.diff?.path) return t.diff.path;
|
|
12
|
+
const i = (t.input ?? {}) as Record<string, unknown>;
|
|
13
|
+
const p = i.file_path ?? i.notebook_path;
|
|
14
|
+
return typeof p === "string" ? p : undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// What a finished run of steps did, after its count: "5 steps · edited a.ts,
|
|
18
|
+
// lib/b.ts · ran 2 commands". Files edited more than once are named once.
|
|
19
|
+
export function stepSummary(tools: ToolItem[]): string {
|
|
20
|
+
const parts = [plural(tools.length, "step")];
|
|
21
|
+
const edited = tools.filter((t) => t.diff || t.hasDiff);
|
|
22
|
+
if (edited.length > 0) {
|
|
23
|
+
const files = [
|
|
24
|
+
...new Set(
|
|
25
|
+
edited
|
|
26
|
+
.map(editedPath)
|
|
27
|
+
.filter((p): p is string => !!p)
|
|
28
|
+
.map(baseName)
|
|
29
|
+
),
|
|
30
|
+
];
|
|
31
|
+
if (files.length === 0) parts.push(plural(edited.length, "edit"));
|
|
32
|
+
else {
|
|
33
|
+
const more = files.length - MAX_FILES;
|
|
34
|
+
parts.push(
|
|
35
|
+
`edited ${files.slice(0, MAX_FILES).join(", ")}${more > 0 ? ` +${more}` : ""}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const commands = tools.filter((t) => t.name === "Bash").length;
|
|
40
|
+
if (commands > 0) parts.push(`ran ${plural(commands, "command")}`);
|
|
41
|
+
return parts.join(" · ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The failed steps' names, for the line under a group that had failures.
|
|
45
|
+
export function failedSteps(tools: ToolItem[]): {
|
|
46
|
+
names: string[];
|
|
47
|
+
more: number;
|
|
48
|
+
} {
|
|
49
|
+
const failed = tools.filter((t) => t.status === "error");
|
|
50
|
+
return {
|
|
51
|
+
names: failed.slice(0, MAX_FAILED).map((t) => t.title),
|
|
52
|
+
more: Math.max(0, failed.length - MAX_FAILED),
|
|
53
|
+
};
|
|
54
|
+
}
|
package/lib/drafts.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface Draft {
|
|
|
35
35
|
roleModel?: string | null;
|
|
36
36
|
// Pair it with an analyst that reviews each turn (a local chat only).
|
|
37
37
|
withAnalyst?: boolean;
|
|
38
|
+
// The analyst's model, "provider/model"; unset, the analyst role's.
|
|
39
|
+
analystModel?: string | null;
|
|
38
40
|
// A task's priority in the queue; unset: normal.
|
|
39
41
|
taskPriority?: Priority;
|
|
40
42
|
createdAt: number;
|
|
@@ -9,6 +9,7 @@ import { AGENT_ROLE_DEFS, type AgentRole } from "./agent-roles";
|
|
|
9
9
|
import { modelAvailability, choiceFor } from "./catalog";
|
|
10
10
|
import { savedModelSettings } from "./config";
|
|
11
11
|
import { PI } from "./providers";
|
|
12
|
+
import { checkToolUse, ollamaOrigin } from "./tool-use";
|
|
12
13
|
import { resolveRole, type RoleSource, type RoutedModel } from "./resolve";
|
|
13
14
|
|
|
14
15
|
export interface RoleBinding {
|
|
@@ -50,6 +51,22 @@ export class ModelUnavailableError extends Error {
|
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
// An analyst reads code and transcripts through tools: a model its server
|
|
55
|
+
// says can't call them is refused, with the reason.
|
|
56
|
+
export async function assertCanCallTools(
|
|
57
|
+
role: AgentRole,
|
|
58
|
+
ref: string
|
|
59
|
+
): Promise<void> {
|
|
60
|
+
if (role !== "analyst") return;
|
|
61
|
+
const choice = choiceFor(ref);
|
|
62
|
+
if (!choice || choice.locality !== "local" || !choice.endpoint) return;
|
|
63
|
+
const model = choice.ref.split("/").slice(choice.agent === "pi" ? 2 : 1);
|
|
64
|
+
if (!(await checkToolUse(ollamaOrigin(choice.endpoint), model.join("/"))))
|
|
65
|
+
throw new Error(
|
|
66
|
+
`${choice.label} can't call tools; an analyst reads code through tools`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
export interface RoleStart {
|
|
54
71
|
role: AgentRole;
|
|
55
72
|
routed: RoutedModel;
|
|
@@ -82,13 +99,17 @@ export async function prepareRoleStart(
|
|
|
82
99
|
): Promise<RoleStart> {
|
|
83
100
|
const binding = bindingFor(role, opts);
|
|
84
101
|
const state = await modelAvailability(binding.routed.ref);
|
|
85
|
-
if (state.state !== "down")
|
|
102
|
+
if (state.state !== "down") {
|
|
103
|
+
await assertCanCallTools(role, binding.routed.ref);
|
|
86
104
|
return startFrom(role, binding.routed, binding.routed.source);
|
|
105
|
+
}
|
|
87
106
|
if (opts.useFallback && binding.fallback) {
|
|
88
107
|
const alt = bindingFor(role, { ...opts, override: binding.fallback });
|
|
89
108
|
const altState = await modelAvailability(alt.routed.ref);
|
|
90
|
-
if (altState.state !== "down")
|
|
109
|
+
if (altState.state !== "down") {
|
|
110
|
+
await assertCanCallTools(role, alt.routed.ref);
|
|
91
111
|
return startFrom(role, alt.routed, "fallback");
|
|
112
|
+
}
|
|
92
113
|
}
|
|
93
114
|
throw new ModelUnavailableError(
|
|
94
115
|
role,
|
package/lib/models/catalog.ts
CHANGED
|
@@ -10,7 +10,8 @@ import os from "os";
|
|
|
10
10
|
import path from "path";
|
|
11
11
|
import { effectiveModels, type EffectiveModels } from "./config";
|
|
12
12
|
import { ANTHROPIC } from "./roles";
|
|
13
|
-
import { PI, type ModelProvider } from "./providers";
|
|
13
|
+
import { PI, providerProblem, type ModelProvider } from "./providers";
|
|
14
|
+
import { cachedToolUse, ollamaOrigin } from "./tool-use";
|
|
14
15
|
import { hostLocality, type Locality } from "./locality";
|
|
15
16
|
import {
|
|
16
17
|
cachedAvailability,
|
|
@@ -31,6 +32,10 @@ export interface ModelChoice {
|
|
|
31
32
|
availability: Availability;
|
|
32
33
|
// May serve a merge gate: Claude Code can run it.
|
|
33
34
|
gateCapable: boolean;
|
|
35
|
+
// May call tools, which an analyst needs (lib/models/tool-use).
|
|
36
|
+
toolUse: boolean;
|
|
37
|
+
// Why it can't be started at all (its provider is off or half set up).
|
|
38
|
+
unusable?: string;
|
|
34
39
|
// Where the server is (never a key).
|
|
35
40
|
endpoint?: string;
|
|
36
41
|
}
|
|
@@ -84,6 +89,16 @@ export function piProviders(file = piModelsFile()): PiProvider[] {
|
|
|
84
89
|
const providerEndpoint = (p: ModelProvider) =>
|
|
85
90
|
p.baseUrl ? probeUrl(p.baseUrl, p.api) : undefined;
|
|
86
91
|
|
|
92
|
+
// Anthropic's and cloud models can; a model on a local server is asked of it.
|
|
93
|
+
const toolUseOf = (
|
|
94
|
+
baseUrl: string | undefined,
|
|
95
|
+
locality: Locality,
|
|
96
|
+
model: string
|
|
97
|
+
) =>
|
|
98
|
+
baseUrl && locality === "local"
|
|
99
|
+
? cachedToolUse(ollamaOrigin(baseUrl), model)
|
|
100
|
+
: true;
|
|
101
|
+
|
|
87
102
|
export function modelCatalog(
|
|
88
103
|
effective: EffectiveModels = effectiveModels(),
|
|
89
104
|
pi: PiProvider[] = piProviders()
|
|
@@ -98,6 +113,7 @@ export function modelCatalog(
|
|
|
98
113
|
: locality === "local" && endpoint
|
|
99
114
|
? cachedAvailability(endpoint)
|
|
100
115
|
: { state: "unknown", checkedAt: null };
|
|
116
|
+
const unusable = providerProblem(p) ?? undefined;
|
|
101
117
|
for (const m of p.models)
|
|
102
118
|
out.push({
|
|
103
119
|
ref: `${p.id}/${m}`,
|
|
@@ -107,6 +123,8 @@ export function modelCatalog(
|
|
|
107
123
|
locality,
|
|
108
124
|
availability,
|
|
109
125
|
gateCapable: p.api === "anthropic",
|
|
126
|
+
toolUse: toolUseOf(p.baseUrl, locality, m),
|
|
127
|
+
...(unusable && { unusable }),
|
|
110
128
|
endpoint: p.baseUrl,
|
|
111
129
|
});
|
|
112
130
|
}
|
|
@@ -126,12 +144,36 @@ export function modelCatalog(
|
|
|
126
144
|
locality,
|
|
127
145
|
availability,
|
|
128
146
|
gateCapable: false,
|
|
147
|
+
toolUse: toolUseOf(p.baseUrl, locality, m.id),
|
|
129
148
|
endpoint: p.baseUrl,
|
|
130
149
|
});
|
|
131
150
|
}
|
|
132
151
|
return out;
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
// The models of providers that are turned off, so a picker can show them
|
|
155
|
+
// greyed with the reason rather than leave them out.
|
|
156
|
+
export function unconfiguredChoices(
|
|
157
|
+
effective: EffectiveModels = effectiveModels()
|
|
158
|
+
): ModelChoice[] {
|
|
159
|
+
return effective.providers
|
|
160
|
+
.filter((p) => !p.enabled)
|
|
161
|
+
.flatMap((p) =>
|
|
162
|
+
p.models.map((m) => ({
|
|
163
|
+
ref: `${p.id}/${m}`,
|
|
164
|
+
label: m,
|
|
165
|
+
group: p.label,
|
|
166
|
+
agent: "claude" as const,
|
|
167
|
+
locality: hostLocality(p.baseUrl),
|
|
168
|
+
availability: { state: "unknown" as const, checkedAt: null },
|
|
169
|
+
gateCapable: false,
|
|
170
|
+
toolUse: true,
|
|
171
|
+
unusable: providerProblem(p) ?? undefined,
|
|
172
|
+
endpoint: p.baseUrl,
|
|
173
|
+
}))
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
export const choiceFor = (
|
|
136
178
|
ref: string,
|
|
137
179
|
effective?: EffectiveModels
|
package/lib/models/providers.ts
CHANGED
|
@@ -50,6 +50,18 @@ export interface ModelPrice {
|
|
|
50
50
|
output: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// Why a provider can't run a Claude Code session, or null when it can.
|
|
54
|
+
export function providerProblem(p: ModelProvider): string | null {
|
|
55
|
+
if (!p.enabled) return `The ${p.label} provider is turned off`;
|
|
56
|
+
// Anthropic runs on the Claude login: no URL or key of ours.
|
|
57
|
+
if (p.id === ANTHROPIC) return null;
|
|
58
|
+
if (!p.baseUrl || !p.auth)
|
|
59
|
+
return `The ${p.label} provider needs a base URL and a key`;
|
|
60
|
+
if (p.api !== "anthropic")
|
|
61
|
+
return `${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`;
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
export const ANTHROPIC_PROVIDER: ModelProvider = {
|
|
54
66
|
id: ANTHROPIC,
|
|
55
67
|
label: "Anthropic (Claude login)",
|
package/lib/models/resolve.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
ANTHROPIC_PROVIDER,
|
|
22
22
|
PI,
|
|
23
23
|
PI_PROVIDER,
|
|
24
|
+
providerProblem,
|
|
24
25
|
type ModelProvider,
|
|
25
26
|
} from "./providers";
|
|
26
27
|
import {
|
|
@@ -111,13 +112,8 @@ export function providerFor(
|
|
|
111
112
|
}
|
|
112
113
|
const p = models.providers.find((x) => x.id === id);
|
|
113
114
|
if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
throw new Error(`The ${p.label} provider needs a base URL and a key`);
|
|
117
|
-
if (p.api !== "anthropic")
|
|
118
|
-
throw new Error(
|
|
119
|
-
`${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`
|
|
120
|
-
);
|
|
115
|
+
const problem = providerProblem(p);
|
|
116
|
+
if (problem) throw new Error(problem);
|
|
121
117
|
return p;
|
|
122
118
|
}
|
|
123
119
|
|
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { db, type Session } from "../db";
|
|
10
|
-
import { registry } from "../chat/registry";
|
|
11
|
-
import { setChatModel } from "../chat/settings";
|
|
12
|
-
import {
|
|
13
|
-
|
|
10
|
+
import { getSession, registry } from "../chat/registry";
|
|
11
|
+
import { emitCapabilities, setChatModel } from "../chat/settings";
|
|
12
|
+
import {
|
|
13
|
+
providerFor,
|
|
14
|
+
sessionRole,
|
|
15
|
+
setSessionModel,
|
|
16
|
+
type RoutedModel,
|
|
17
|
+
} from "./resolve";
|
|
18
|
+
import { PI } from "./providers";
|
|
19
|
+
import { isAgentRole } from "./agent-roles";
|
|
20
|
+
import { assertCanCallTools } from "./agent-binding";
|
|
21
|
+
import { ANTHROPIC, formatModelRef, parseModelRef } from "./roles";
|
|
14
22
|
|
|
15
23
|
export async function switchSessionModel(
|
|
16
24
|
sessionId: string,
|
|
@@ -38,3 +46,61 @@ export async function applySessionModel(
|
|
|
38
46
|
if (registry.live.has(sessionId))
|
|
39
47
|
(await import("../chat/runner")).stopChat(sessionId);
|
|
40
48
|
}
|
|
49
|
+
|
|
50
|
+
// Moves a session to "provider/model" (or "pi/<provider>/<model>" for a Pi
|
|
51
|
+
// session), keeping its conversation. Only this session's row changes: no
|
|
52
|
+
// other session's model and no role setting.
|
|
53
|
+
export async function switchSessionModelRef(
|
|
54
|
+
session: Session,
|
|
55
|
+
ref: string
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const parsed = parseModelRef(ref);
|
|
58
|
+
if (isAgentRole(session.agent_role))
|
|
59
|
+
await assertCanCallTools(session.agent_role, formatModelRef(parsed));
|
|
60
|
+
if (session.agent_type === "pi") {
|
|
61
|
+
if (parsed.provider !== PI)
|
|
62
|
+
throw new Error(
|
|
63
|
+
"A Pi session runs on a Pi model (pi/<provider>/<model>)"
|
|
64
|
+
);
|
|
65
|
+
await setChatModel(session.id, parsed.model);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (parsed.provider === PI)
|
|
69
|
+
throw new Error("Only a Pi session can move to a Pi model");
|
|
70
|
+
await switchSessionModel(session.id, {
|
|
71
|
+
role: sessionRole(session),
|
|
72
|
+
ref: formatModelRef(parsed),
|
|
73
|
+
provider: providerFor(parsed.provider),
|
|
74
|
+
model: parsed.model,
|
|
75
|
+
source: "task",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A pick in a chat's model list. A full ref ("provider/model") moves the
|
|
80
|
+
// session across providers; a bare model on a session routed to another
|
|
81
|
+
// provider goes back to Anthropic, so "opus" is never asked of another
|
|
82
|
+
// provider's endpoint. Anything else is the agent's own model name.
|
|
83
|
+
export async function setComposerModel(
|
|
84
|
+
sessionId: string,
|
|
85
|
+
value: string
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const session = db
|
|
88
|
+
.prepare(`SELECT * FROM session_rows WHERE id = ?`)
|
|
89
|
+
.get(sessionId) as Session | undefined;
|
|
90
|
+
const model = value.trim();
|
|
91
|
+
if (!session || !model) return;
|
|
92
|
+
const claude = session.agent_type === "claude";
|
|
93
|
+
const ref = claude
|
|
94
|
+
? model.includes("/")
|
|
95
|
+
? model
|
|
96
|
+
: session.model_provider
|
|
97
|
+
? `${ANTHROPIC}/${model}`
|
|
98
|
+
: null
|
|
99
|
+
: session.agent_type === "pi" && model.startsWith(`${PI}/`)
|
|
100
|
+
? model
|
|
101
|
+
: null;
|
|
102
|
+
if (!ref) return setChatModel(sessionId, model);
|
|
103
|
+
await switchSessionModelRef(session, ref);
|
|
104
|
+
// A move across providers stops the worker, which tells nobody.
|
|
105
|
+
emitCapabilities(getSession(sessionId));
|
|
106
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a model can call tools, which an analyst needs to read code and
|
|
3
|
+
* transcripts. There is no list of it to read, so the server asks: an Ollama
|
|
4
|
+
* server reports each model's capabilities (POST /api/show), and one that
|
|
5
|
+
* answers without "tools" is out. Anything that can't be told (a server with
|
|
6
|
+
* no such endpoint, one that doesn't answer, a cloud provider) counts as
|
|
7
|
+
* able: Pi turns text tool calls into real ones, and hiding a model we can't
|
|
8
|
+
* judge is worse than letting it try. Cached, and never awaited by a request
|
|
9
|
+
* handler unless it asks to.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const TTL_MS = 10 * 60_000;
|
|
13
|
+
const TIMEOUT_MS = 1_500;
|
|
14
|
+
|
|
15
|
+
interface Entry {
|
|
16
|
+
toolUse: boolean;
|
|
17
|
+
at: number;
|
|
18
|
+
}
|
|
19
|
+
const cache = new Map<string, Entry>();
|
|
20
|
+
const probing = new Map<string, Promise<boolean>>();
|
|
21
|
+
|
|
22
|
+
// Where Ollama's own API lives for an OpenAI- or Anthropic-style base URL.
|
|
23
|
+
export const ollamaOrigin = (baseUrl: string): string =>
|
|
24
|
+
baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
25
|
+
|
|
26
|
+
const key = (origin: string, model: string) => `${origin}\n${model}`;
|
|
27
|
+
|
|
28
|
+
async function probe(origin: string, model: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${origin}/api/show`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "content-type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ model }),
|
|
34
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
35
|
+
redirect: "manual",
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) return true;
|
|
38
|
+
const body = (await res.json()) as { capabilities?: unknown };
|
|
39
|
+
return Array.isArray(body.capabilities)
|
|
40
|
+
? body.capabilities.includes("tools")
|
|
41
|
+
: true;
|
|
42
|
+
} catch {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The last answer, probing in the background when it is stale.
|
|
48
|
+
export function cachedToolUse(origin: string, model: string): boolean {
|
|
49
|
+
const k = key(origin, model);
|
|
50
|
+
const hit = cache.get(k);
|
|
51
|
+
if (!hit || Date.now() - hit.at >= TTL_MS) void checkToolUse(origin, model);
|
|
52
|
+
return hit?.toolUse ?? true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function checkToolUse(
|
|
56
|
+
origin: string,
|
|
57
|
+
model: string
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
const k = key(origin, model);
|
|
60
|
+
const hit = cache.get(k);
|
|
61
|
+
if (hit && Date.now() - hit.at < TTL_MS) return hit.toolUse;
|
|
62
|
+
let running = probing.get(k);
|
|
63
|
+
if (!running) {
|
|
64
|
+
running = probe(origin, model)
|
|
65
|
+
.then((toolUse) => {
|
|
66
|
+
cache.set(k, { toolUse, at: Date.now() });
|
|
67
|
+
return toolUse;
|
|
68
|
+
})
|
|
69
|
+
.finally(() => probing.delete(k));
|
|
70
|
+
probing.set(k, running);
|
|
71
|
+
}
|
|
72
|
+
return running;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearToolUse(): void {
|
|
76
|
+
cache.clear();
|
|
77
|
+
probing.clear();
|
|
78
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a model in the server's list can't be picked for a role, and the name
|
|
3
|
+
* a role's default shows. Imported by the browser too, so no Node modules.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { modelDisplayName } from "../model-catalog";
|
|
7
|
+
import { ANTHROPIC } from "./roles";
|
|
8
|
+
import type { ModelChoice } from "./catalog";
|
|
9
|
+
|
|
10
|
+
// Why a model can't be picked for an analyst, or null when it can. An
|
|
11
|
+
// analyst reads code and transcripts through tools, so a model that can't
|
|
12
|
+
// call them is out; one whose server isn't answering is too.
|
|
13
|
+
export function analystBlock(
|
|
14
|
+
c: Pick<ModelChoice, "unusable" | "toolUse" | "availability" | "agent">,
|
|
15
|
+
opts: { agent?: "claude" | "pi" } = {}
|
|
16
|
+
): string | null {
|
|
17
|
+
if (c.unusable) return c.unusable;
|
|
18
|
+
if (!c.toolUse) return "Can't call tools";
|
|
19
|
+
if (c.availability.state === "down")
|
|
20
|
+
return c.availability.error
|
|
21
|
+
? `Not answering (${c.availability.error})`
|
|
22
|
+
: "Not answering";
|
|
23
|
+
if (opts.agent && c.agent !== opts.agent)
|
|
24
|
+
return c.agent === "pi"
|
|
25
|
+
? "Runs on Pi: start a new analyst on it"
|
|
26
|
+
: "Runs on Claude Code: start a new analyst on it";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// "Opus 5.5" for an Anthropic ref, the list's own label for any other.
|
|
31
|
+
export function defaultModelName(
|
|
32
|
+
ref: string | null | undefined,
|
|
33
|
+
choices: Pick<ModelChoice, "ref" | "label">[]
|
|
34
|
+
): string {
|
|
35
|
+
if (!ref) return "…";
|
|
36
|
+
const found = choices.find((c) => c.ref === ref);
|
|
37
|
+
if (ref.startsWith(`${ANTHROPIC}/`))
|
|
38
|
+
return modelDisplayName(ref.slice(ANTHROPIC.length + 1));
|
|
39
|
+
return found?.label ?? ref.split("/").slice(1).join("/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const defaultAnalystLabel = (
|
|
43
|
+
ref: string | null | undefined,
|
|
44
|
+
choices: Pick<ModelChoice, "ref" | "label">[]
|
|
45
|
+
) => `Default analyst model (${defaultModelName(ref, choices)})`;
|