@saadnvd1/agent-os 0.3.744 → 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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/app/api/models/choices/route.ts +7 -3
  3. package/app/api/sessions/[id]/model/route.ts +2 -23
  4. package/components/Analyst/StartAnalystSheet.tsx +7 -2
  5. package/components/Analyst/useAnalystModels.ts +19 -0
  6. package/components/Approvals/NoApprovalsBadge.tsx +22 -9
  7. package/components/Away/AwayToggle.tsx +5 -5
  8. package/components/Chat/ChatPanel.tsx +10 -2
  9. package/components/Chat/Messages.test.ts +33 -0
  10. package/components/Chat/Messages.tsx +15 -0
  11. package/components/Chat/ModelPicker.tsx +3 -2
  12. package/components/Chat/idle.ts +9 -0
  13. package/components/Draft/AnalystModelChip.tsx +58 -0
  14. package/components/Draft/DraftChips.tsx +8 -0
  15. package/components/Draft/DraftPanel.tsx +7 -1
  16. package/components/Draft/RoleChips.tsx +12 -3
  17. package/components/Models/AgentRoleCard.tsx +9 -2
  18. package/components/Models/RoleModelSelect.tsx +23 -8
  19. package/components/SessionList/MachineSwitcher.tsx +1 -1
  20. package/components/SessionList/ProjectFilter.tsx +1 -1
  21. package/components/SessionList/SidebarHeader.tsx +3 -3
  22. package/components/SessionList/SidebarSearch.tsx +1 -1
  23. package/components/SessionList/index.tsx +1 -1
  24. package/components/Settings/NoApprovalsSection.test.ts +5 -2
  25. package/lib/chat/demo.ts +23 -2
  26. package/lib/chat/drivers/demo-script.ts +30 -4
  27. package/lib/chat/drivers/opencode-config.ts +5 -0
  28. package/lib/chat/drivers/opencode-mapper.ts +104 -17
  29. package/lib/chat/drivers/opencode.ts +19 -1
  30. package/lib/chat/drivers/turn-outcome.ts +22 -1
  31. package/lib/chat/events.ts +12 -1
  32. package/lib/chat/group.ts +11 -2
  33. package/lib/chat/progress.ts +4 -0
  34. package/lib/chat/role-models.ts +46 -0
  35. package/lib/chat/runner-watch.ts +1 -1
  36. package/lib/chat/settings.ts +7 -4
  37. package/lib/chat/socket.ts +3 -2
  38. package/lib/chat/turn-end.ts +16 -0
  39. package/lib/drafts.ts +2 -0
  40. package/lib/models/agent-binding.ts +23 -2
  41. package/lib/models/catalog.ts +43 -1
  42. package/lib/models/providers.ts +12 -0
  43. package/lib/models/resolve.ts +3 -7
  44. package/lib/models/session-model.ts +70 -4
  45. package/lib/models/tool-use.ts +78 -0
  46. package/lib/models/usable.ts +45 -0
  47. package/lib/updates/check.ts +32 -0
  48. package/lib/updates/view.ts +2 -1
  49. package/package.json +1 -1
@@ -0,0 +1,16 @@
1
+ import type { TurnEndReason } from "./events";
2
+
3
+ // The ends a Retry button can resume: the model's side failed or went quiet.
4
+ const RETRYABLE: TurnEndReason[] = [
5
+ "error",
6
+ "empty",
7
+ "stalled",
8
+ "compaction_empty",
9
+ "cut_off",
10
+ ];
11
+
12
+ export const retryableEnd = (reason: TurnEndReason) =>
13
+ RETRYABLE.includes(reason);
14
+
15
+ // What Retry sends: the steps that ran stay, the model carries on.
16
+ export const RETRY_TEXT = "continue";
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)})`;
@@ -163,6 +163,38 @@ export async function compare(deps: CheckDeps): Promise<CheckResult> {
163
163
  };
164
164
  }
165
165
 
166
+ /**
167
+ * A cached answer was worked out against whatever HEAD was then. If the
168
+ * checkout has since moved (a pull by hand, a switch to another branch) and
169
+ * now contains the latest commit, there's nothing to update to.
170
+ */
171
+ export async function reconcile(
172
+ check: CheckResult | null,
173
+ deps: Pick<CheckDeps, "git">
174
+ ): Promise<CheckResult | null> {
175
+ if (!check?.available || !check.latest) return check;
176
+ try {
177
+ const head = (await deps.git(["rev-parse", "HEAD"])).trim();
178
+ if (head === check.current?.sha) return check;
179
+ const has = await deps
180
+ .git(["merge-base", "--is-ancestor", check.latest.sha, "HEAD"])
181
+ .then(
182
+ () => true,
183
+ () => false
184
+ );
185
+ return has
186
+ ? {
187
+ ...check,
188
+ available: false,
189
+ behind: 0,
190
+ current: { version: check.latest.version, sha: head },
191
+ }
192
+ : check;
193
+ } catch {
194
+ return check;
195
+ }
196
+ }
197
+
166
198
  let memo: CheckResult | null = null;
167
199
  let inflight: Promise<CheckResult> | null = null;
168
200
 
@@ -2,7 +2,7 @@
2
2
  // whether the caller may start an update.
3
3
 
4
4
  import { isActivePhase, type UpdateView } from "./banner";
5
- import { cachedCheck, checkForUpdate } from "./check";
5
+ import { cachedCheck, checkForUpdate, reconcile } from "./check";
6
6
  import {
7
7
  preflight,
8
8
  readLog,
@@ -40,6 +40,7 @@ export async function updateView(
40
40
  )
41
41
  check = (await checkForUpdate({ force: true, always: true }, deps.check))
42
42
  .result;
43
+ check = await reconcile(check, deps.check);
43
44
  // The log can carry paths and remote URLs; only a device that may update sees it.
44
45
  const run =
45
46
  stored && (stored.phase === "failed" || stored.phase === "built")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.744",
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"