Q167: the model in effect and what a default change reaches are shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8722cd815
commit
26c3365896
10 files changed
+97
-19
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { agentModel } from "../../shared/agent-model";
|
||||
import { TASK_APPROVAL_KINDS_SQL, TASK_SNAPSHOT_SQL } from "./task-approval";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { APPROVAL_STATE, RISK_LEVEL } from "../../shared/domain";
|
||||
@@ -73,7 +74,7 @@ export function assertModelSelectable(providerKey: string, agentModelId: string
|
||||
|
||||
/** The model a run calls: the agent's own choice, or else its provider's default. */
|
||||
export function effectiveModel(agentModelId: string | null, providerModel: string | null): string | null {
|
||||
return agentModelId?.trim() || providerModel;
|
||||
return agentModel(agentModelId, providerModel).model;
|
||||
}
|
||||
|
||||
export function requireAgentProvider(db: DatabaseSync, orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow {
|
||||
|
||||
@@ -678,4 +678,8 @@ export const zh: Record<string, string> = {
|
||||
"{passed}/{total} passed": "{passed}/{total} 通过",
|
||||
"{pending} pending · {approved} approved this week": "{pending} 待处理 · 本周已批准 {approved}",
|
||||
"{percent}% of cap": "上限的 {percent}%",
|
||||
"Leave the model blank to use the provider's default. A changed model applies from the agent's next session.": "模型留空即使用 Provider 的默认模型。修改后的模型从该 Agent 的下一次会话起生效。",
|
||||
"{model} (provider default)": "{model}(Provider 默认)",
|
||||
"Chosen when the session starts": "会话开始时确定",
|
||||
"The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.": "默认模型作用于 {following} 个未单独设置模型的 Agent;{own} 个保留自己的模型。已开始的会话继续使用开始时的模型。",
|
||||
};
|
||||
@@ -204,7 +204,8 @@ function RunDetail({ run, tasks, agents, providers, actions, onOpenTask }: {
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "8px 16px", fontSize: 14, marginBottom: 20 }}>
|
||||
<MetaLabel>{t("Agent")}</MetaLabel><div>{agent?.name ?? "—"} ({agent?.role ?? "—"})</div>
|
||||
<MetaLabel>{t("Provider")}</MetaLabel><div>{provider?.label ?? "—"} · {provider?.model ?? "—"}</div>
|
||||
<MetaLabel>{t("Provider")}</MetaLabel><div>{provider?.label ?? "—"}</div>
|
||||
<MetaLabel>{t("Model")}</MetaLabel><div>{run.runModel ?? t("Chosen when the session starts")}</div>
|
||||
<MetaLabel>{t("Trigger")}</MetaLabel><div>{run.trigger}</div>
|
||||
<MetaLabel>{t("Created")}</MetaLabel><div>{formatRelative(run.createdAt)}</div>
|
||||
{run.startedAt && (<><MetaLabel>{t("Started")}</MetaLabel><div>{formatRelative(run.startedAt)}</div></>)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentModel } from "../../../shared/agent-model";
|
||||
import { useMemo, useState, type FormEvent } from "react";
|
||||
import { Avatar, Button, Field, Icon, IconButton } from "../shared/ui";
|
||||
import { AvatarField } from "../shared/AvatarField";
|
||||
@@ -40,21 +41,20 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
const openHireDialog = () => {
|
||||
const provider = providers[0] ?? null;
|
||||
setHireProviderId(provider?.id ?? "");
|
||||
setHireModelId(provider?.model ?? "");
|
||||
setHireModelId("");
|
||||
setHireOpen(true);
|
||||
};
|
||||
|
||||
const updateHireProvider = (providerId: string) => {
|
||||
const provider = providers.find((item) => item.id === providerId) ?? null;
|
||||
setHireProviderId(providerId);
|
||||
setHireModelId(provider?.model ?? "");
|
||||
setHireModelId("");
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const selectedProvider = providers.find((provider) => provider.id === hireProviderId) ?? null;
|
||||
const modelId = selectedProvider ? hireModelId.trim() || selectedProvider.model : null;
|
||||
const modelId = selectedProvider ? hireModelId.trim() || null : null;
|
||||
void actions.run(() => window.agentCompany.createAgent({
|
||||
orgId,
|
||||
name: text(form, "name"),
|
||||
@@ -148,8 +148,9 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" list="agent-model-suggestions" value={hireModelId} onChange={(event) => setHireModelId(event.currentTarget.value)} />
|
||||
<input className="input" name="modelId" list="agent-model-suggestions" value={hireModelId} placeholder={providers.find((provider) => provider.id === hireProviderId)?.model ?? ""} onChange={(event) => setHireModelId(event.currentTarget.value)} />
|
||||
</Field>
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
|
||||
<Field label={t("Manager")}>
|
||||
<select className="input" name="managerAgentId" defaultValue="">
|
||||
<option value="">{t("None")}</option>
|
||||
@@ -174,15 +175,8 @@ export function TeamPage({ snapshot, actions }: PageProps) {
|
||||
<Field label={t("Title")}><input className="input" name="title" defaultValue={editingAgent.title} /></Field>
|
||||
<Field label={t("Department")}><input className="input" name="department" defaultValue={editingAgent.department} required /></Field>
|
||||
<Field label={t("Monthly budget")}><input className="input" name="budget" type="number" min="0" step="1" defaultValue={editingAgent.budgetCents / 100} /></Field>
|
||||
<Field label={t("Provider")}>
|
||||
<select className="input" name="modelProviderId" defaultValue={editingAgent.modelProviderId ?? ""}>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" defaultValue={editingAgent.modelId ?? ""} list="agent-model-suggestions" />
|
||||
</Field>
|
||||
<EditModelFields agent={editingAgent} providers={providers} />
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>{t("Leave the model blank to use the provider's default. A changed model applies from the agent's next session.")}</p>
|
||||
<Field label={t("Seniority")}>
|
||||
{/* The CEO's rank carries company-wide powers, so it is shown, never picked. */}
|
||||
{editingAgent.roleLevel === "c_suite" ? (
|
||||
@@ -251,12 +245,13 @@ function buildRoster(
|
||||
const cap = agents.reduce((max, agent) => (agent.budgetCents > max ? agent.budgetCents : max), 0);
|
||||
for (const agent of agents) {
|
||||
const provider = agent.modelProviderId ? providers.find((entry) => entry.id === agent.modelProviderId) : null;
|
||||
const inEffect = agentModel(agent.modelId, provider?.model ?? null);
|
||||
rows.push({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
role: agent.role,
|
||||
dept: agent.department || "—",
|
||||
model: agent.modelId ?? (provider?.model ?? "—"),
|
||||
model: inEffect.source === "provider_default" ? t("{model} (provider default)", { model: inEffect.model ?? "" }) : inEffect.model ?? "—",
|
||||
providerLabel: provider?.label ?? "—",
|
||||
budgetCents: agent.budgetCents,
|
||||
budgetCapCents: cap > 0 ? cap : null,
|
||||
@@ -267,6 +262,24 @@ function buildRoster(
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The placeholder names the default of the provider picked now, which is the model a blank field will follow. */
|
||||
function EditModelFields({ agent, providers }: { agent: AgentRecord; providers: ProviderRecord[] }) {
|
||||
const [providerId, setProviderId] = useState(agent.modelProviderId ?? "");
|
||||
return (
|
||||
<>
|
||||
<Field label={t("Provider")}>
|
||||
<select className="input" name="modelProviderId" value={providerId} onChange={(event) => setProviderId(event.currentTarget.value)}>
|
||||
<option value="">{t("Unassigned")}</option>
|
||||
{providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("Model")}>
|
||||
<input className="input" name="modelId" defaultValue={agent.modelId ?? ""} placeholder={providers.find((provider) => provider.id === providerId)?.model ?? ""} list="agent-model-suggestions" />
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RosterRow({ row, last, onEdit }: { row: RosterEntry; last: boolean; onEdit: (() => void) | null }) {
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defaultModelReach } from "../../../../shared/agent-model";
|
||||
import { ScopeNote } from "./ScopeNote";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button, Pill } from "../../shared/ui";
|
||||
@@ -121,6 +122,11 @@ export function ProvidersPanel({ orgId, companyName, employeeId, providers, auth
|
||||
<ProviderConnectFields provider={active} entry={activeCatalogEntry} />
|
||||
)
|
||||
)}
|
||||
{active && (
|
||||
<p style={{ fontSize: 12, color: "var(--text-3)", margin: 0 }}>
|
||||
{t("The default model reaches {following} agents without their own model; {own} keep their own. Sessions already started keep the model they began with.", { following: defaultModelReach(agents, active.id).following, own: defaultModelReach(agents, active.id).ownModel })}
|
||||
</p>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Where an agent's model comes from: its own setting, else its provider's default. */
|
||||
export type ModelSource = "agent" | "provider_default" | "none";
|
||||
|
||||
/** The model a new session of the agent starts with; a running session keeps the one it pinned. */
|
||||
export function agentModel(agentModelId: string | null, providerModel: string | null): { model: string | null; source: ModelSource } {
|
||||
const own = agentModelId?.trim();
|
||||
if (own) return { model: own, source: "agent" };
|
||||
if (providerModel) return { model: providerModel, source: "provider_default" };
|
||||
return { model: null, source: "none" };
|
||||
}
|
||||
|
||||
/** Agents a change to this provider's default model reaches, and those that keep their own model. */
|
||||
export function defaultModelReach(agents: Array<{ modelProviderId: string | null; modelId: string | null }>, providerId: string): { following: number; ownModel: number } {
|
||||
const bound = agents.filter((agent) => agent.modelProviderId === providerId);
|
||||
const ownModel = bound.filter((agent) => agent.modelId?.trim()).length;
|
||||
return { following: bound.length - ownModel, ownModel };
|
||||
}
|
||||
@@ -211,6 +211,8 @@ export interface TaskRunRecord {
|
||||
contextJson: string | null;
|
||||
/** Capabilities the run could not load, one per line, so the user sees what it worked without. */
|
||||
capabilityWarnings: string | null;
|
||||
/** The model the session pinned when it started; null until then. */
|
||||
runModel: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalRecord {
|
||||
|
||||
@@ -89,7 +89,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
};
|
||||
});
|
||||
expect(hiredAgent.modelProviderId).toBeTruthy();
|
||||
expect(hiredAgent.modelId).toBe("gpt-5.4");
|
||||
// A hire without its own model follows the provider default, and the roster says so.
|
||||
expect(hiredAgent.modelId).toBeNull();
|
||||
await expect(page.getByText("gpt-5.4 (provider default)").first()).toBeVisible();
|
||||
expect(hiredAgent.workspaceId).toBe(hiredAgent.primaryWorkspaceId);
|
||||
expect(hiredAgent.heartbeatEnabled).toBe(true);
|
||||
expect(hiredAgent.heartbeatIntervalSec).toBe(120);
|
||||
@@ -98,6 +100,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
|
||||
await page.getByRole("button", { name: "Edit Alex Chen" }).click();
|
||||
await page.locator(".modal").getByLabel("Name", { exact: true }).fill("Alex Turing");
|
||||
await page.getByLabel("Avatar emoji").fill("🚀");
|
||||
// The blank model field names the default of whichever provider is picked now.
|
||||
const editModel = page.locator(".modal").getByLabel("Model", { exact: true });
|
||||
await expect(editModel).toHaveAttribute("placeholder", "gpt-5.4");
|
||||
const editProvider = page.locator(".modal select[name=modelProviderId]");
|
||||
const savedProvider = await editProvider.inputValue();
|
||||
await editProvider.selectOption("");
|
||||
await expect(editModel).toHaveAttribute("placeholder", "");
|
||||
await editProvider.selectOption(savedProvider);
|
||||
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Alex Turing").first()).toBeVisible();
|
||||
await expect(page.locator(".table-row", { hasText: "Alex Turing" }).locator(".avatar", { hasText: "🚀" })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentModel, defaultModelReach } from "../../src/shared/agent-model";
|
||||
import { effectiveModel } from "../../src/main/services/task-run-preparation";
|
||||
|
||||
describe("agent model", () => {
|
||||
it("names the model in effect and where it comes from", () => {
|
||||
expect(agentModel("gpt-6", "gpt-5")).toEqual({ model: "gpt-6", source: "agent" });
|
||||
expect(agentModel(" ", "gpt-5")).toEqual({ model: "gpt-5", source: "provider_default" });
|
||||
expect(agentModel(null, null)).toEqual({ model: null, source: "none" });
|
||||
// Runs start with exactly the model the UI shows.
|
||||
expect(effectiveModel(" gpt-6 ", "gpt-5")).toBe("gpt-6");
|
||||
expect(effectiveModel(null, "gpt-5")).toBe("gpt-5");
|
||||
});
|
||||
|
||||
it("counts the agents a default-model change reaches", () => {
|
||||
const agents = [
|
||||
{ modelProviderId: "p1", modelId: null },
|
||||
{ modelProviderId: "p1", modelId: "" },
|
||||
{ modelProviderId: "p1", modelId: "own" },
|
||||
{ modelProviderId: "p2", modelId: null },
|
||||
];
|
||||
expect(defaultModelReach(agents, "p1")).toEqual({ following: 2, ownModel: 1 });
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ function run(agentId: string | null, contextJson: string | null): TaskRunRecord
|
||||
return {
|
||||
id: `run_${Math.random()}`, orgId: "org", taskId: "tsk", agentId, providerId: null, providerRequestId: null,
|
||||
executionContextId: null, serviceIdentityId: null, workspaceMountJson: "[]", secretScopeJson: "{}",
|
||||
trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null,
|
||||
trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null, runModel: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user