@saadnvd1/agent-os 0.3.748 → 0.3.749

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 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) and the last availability check.
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({ choices: modelCatalog() });
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 { setChatModel } from "@/lib/chat/settings";
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
- const parsed = parseModelRef(ref);
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="The analyst role's model"
72
- claudeOnly
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>
@@ -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({ coderSessionId: session.id }, paneId);
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
- : `Role model: ${modelName(bound?.binding?.ref)}`
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
- Role model
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
- hint={c.group}
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 label = `Default (${role.default.split("/").slice(1).join("/")})`;
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 items-center gap-2">
21
- <AvailabilityDot availability={choice.availability} />
22
- <span className="truncate">{choice.label}</span>
23
- <LocalityBadge locality={choice.locality} />
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={gateOnly && !c.gateCapable}
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>
@@ -27,6 +27,8 @@ export interface ChatModel {
27
27
  value: string;
28
28
  label: string;
29
29
  description?: string;
30
+ // Why it can't be picked now; it is listed greyed with this.
31
+ disabled?: string;
30
32
  }
31
33
 
32
34
  export type ToolStatus = "running" | "done" | "error" | "stopped";
@@ -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
+ }
@@ -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 model = chatModel(session);
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: caps.models,
85
+ models,
83
86
  model,
84
87
  modelLabel:
85
- caps.models.find((o) => o.value === model)?.label ??
86
- modelDisplayName(model),
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,
@@ -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") run(setChatModel(sessionId, msg.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));
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,
@@ -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
@@ -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)",
@@ -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
- if (!p.enabled) throw new Error(`The ${p.label} provider is turned off`);
115
- if (!p.baseUrl || !p.auth)
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 { setSessionModel, type RoutedModel } from "./resolve";
13
- import { ANTHROPIC } from "./roles";
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)})`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.748",
3
+ "version": "0.3.749",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"