From 36a84ca38b0684f4f4b2b526942db82984f8cd9d Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 29 Apr 2026 11:33:57 -0500 Subject: [PATCH] Wire cheap model profile through agent + issue UI - AgentConfigForm: cheap model section under primary, persisted via runtimeConfig.modelProfiles.cheap.adapterConfig (never primary adapterConfig); only rendered when adapter advertises modelProfiles. - NewIssueDialog: Primary / Cheap / Custom model lane control. Cheap lane sends modelProfile: "cheap"; Custom preserves explicit overrides. - IssueRunLedger: model profile badge with requested/applied/fallback metadata sourced from resultJson.modelProfile. - Adds adapter capability flag + /model-profiles client; tests cover payload generation, runtime patch shape, and run-detail rendering. Co-Authored-By: Paperclip --- packages/adapter-utils/src/types.ts | 8 + ui/src/adapters/use-adapter-capabilities.ts | 15 +- ui/src/api/adapters.ts | 1 + ui/src/api/agents.ts | 14 ++ ui/src/components/AgentConfigForm.tsx | 178 ++++++++++++++++- ui/src/components/IssueRunLedger.test.tsx | 35 ++++ ui/src/components/IssueRunLedger.tsx | 73 +++++++ ui/src/components/NewIssueDialog.tsx | 180 ++++++++++++------ ui/src/lib/agent-config-patch.test.ts | 86 +++++++++ ui/src/lib/agent-config-patch.ts | 54 +++++- ui/src/lib/issue-assignee-overrides.test.ts | 97 ++++++++++ ui/src/lib/issue-assignee-overrides.ts | 60 ++++++ ui/src/lib/new-agent-hire-payload.ts | 2 + ui/src/lib/new-agent-runtime-config.test.ts | 30 +++ ui/src/lib/new-agent-runtime-config.ts | 19 +- ui/src/lib/queryKeys.ts | 2 + ui/src/pages/AdapterManager.tsx | 1 + .../stories/agent-management.stories.tsx | 3 + 18 files changed, 784 insertions(+), 74 deletions(-) create mode 100644 ui/src/lib/issue-assignee-overrides.test.ts create mode 100644 ui/src/lib/issue-assignee-overrides.ts diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index d5b389612eb..54e456a295f 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -447,6 +447,14 @@ export interface CreateConfigValues { promptTemplate: string; model: string; thinkingEffort: string; + /** + * Optional cheap model profile config for new agents on adapters that + * support model profiles. Persisted under + * `runtimeConfig.modelProfiles.cheap.adapterConfig`, never on the primary + * `adapterConfig`. + */ + cheapModel?: string; + cheapModelEnabled?: boolean; chrome: boolean; dangerouslySkipPermissions: boolean; search: boolean; diff --git a/ui/src/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts index 7c16e1c9735..631c01f3d90 100644 --- a/ui/src/adapters/use-adapter-capabilities.ts +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -8,6 +8,7 @@ const ALL_FALSE: AdapterCapabilities = { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }; /** @@ -15,13 +16,13 @@ const ALL_FALSE: AdapterCapabilities = { * return correct values on first render before the /api/adapters call resolves. */ const KNOWN_DEFAULTS: Record = { - claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, - cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true }, - hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false }, + claude_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, + codex_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true }, + cursor: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + gemini_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + opencode_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: true }, + pi_local: { supportsInstructionsBundle: true, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, supportsModelProfiles: false }, + hermes_local: { supportsInstructionsBundle: false, supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: false, supportsModelProfiles: false }, openclaw_gateway: ALL_FALSE, }; diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 18b154efedc..3df1340c0f8 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -9,6 +9,7 @@ export interface AdapterCapabilities { supportsSkills: boolean; supportsLocalAgentJwt: boolean; requiresMaterializedRuntimeSkills: boolean; + supportsModelProfiles: boolean; } export interface AdapterInfo { diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 713adba2417..197a8995523 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -28,6 +28,16 @@ export interface AdapterModel { label: string; } +export type AdapterModelProfileKey = "cheap"; + +export interface AdapterModelProfile { + key: AdapterModelProfileKey; + label: string; + description?: string; + adapterConfig: Record; + source?: "adapter_default" | "discovered"; +} + export interface DetectedAdapterModel { model: string; provider: string; @@ -172,6 +182,10 @@ export const agentsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, ), + adapterModelProfiles: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/model-profiles`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 99b3240a789..5faba1d36d9 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -41,6 +41,7 @@ import { help, adapterLabels, } from "./agent-config-primitives"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { defaultCreateValues } from "./agent-config-defaults"; import { getUIAdapter } from "../adapters"; import { ClaudeLocalAdvancedFields } from "../adapters/claude-local/config-fields"; @@ -117,7 +118,8 @@ function isOverlayDirty(o: AgentConfigOverlay): boolean { o.adapterType !== undefined || Object.keys(o.adapterConfig).length > 0 || Object.keys(o.heartbeat).length > 0 || - Object.keys(o.runtime).length > 0 + Object.keys(o.runtime).length > 0 || + o.modelProfiles?.cheap !== undefined ); } @@ -244,15 +246,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); + type RecordOverlayGroup = "identity" | "adapterConfig" | "heartbeat" | "runtime"; + /** Read effective value: overlay if dirty, else original */ - function eff(group: keyof Omit, field: string, original: T): T { + function eff(group: RecordOverlayGroup, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ - function mark(group: keyof Omit, field: string, value: unknown) { + function mark(group: RecordOverlayGroup, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, @@ -374,8 +378,30 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const [runPolicyAdvancedOpen, setRunPolicyAdvancedOpen] = useState(false); // Popover states const [modelOpen, setModelOpen] = useState(false); + const [cheapModelOpen, setCheapModelOpen] = useState(false); const [thinkingEffortOpen, setThinkingEffortOpen] = useState(false); + // Cheap model profile state — only relevant when the adapter advertises + // `supportsModelProfiles`. Defaults are sourced from the adapter's + // /model-profiles endpoint so the UI does not encode adapter-specific + // cheap defaults. + const supportsModelProfiles = adapterCaps.supportsModelProfiles; + const { data: adapterCheapProfileDefinitions } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.adapterModelProfiles(selectedCompanyId, adapterType) + : ["agents", "none", "adapter-model-profiles", adapterType], + queryFn: () => agentsApi.adapterModelProfiles(selectedCompanyId!, adapterType), + enabled: Boolean(selectedCompanyId) && supportsModelProfiles, + }); + const adapterCheapDefault = useMemo(() => { + return (adapterCheapProfileDefinitions ?? []).find((profile) => profile.key === "cheap") ?? null; + }, [adapterCheapProfileDefinitions]); + const adapterCheapDefaultModel = useMemo(() => { + const adapterConfig = adapterCheapDefault?.adapterConfig ?? {}; + const value = (adapterConfig as Record).model; + return typeof value === "string" ? value : ""; + }, [adapterCheapDefault]); + // Create mode helpers const val = isCreate ? props.values : null; const set = isCreate @@ -516,6 +542,69 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const codexSearchEnabled = adapterType === "codex_local" ? (isCreate ? Boolean(val!.search) : eff("adapterConfig", "search", Boolean(config.search))) : false; + // Cheap profile read/write helpers. Edit-mode values come from + // runtimeConfig.modelProfiles.cheap with overlay overrides on top; create-mode + // values come straight from CreateConfigValues (cheapModel + cheapModelEnabled). + const cheapProfileFromAgent = useMemo(() => { + const profiles = (runtimeConfig.modelProfiles ?? {}) as Record; + const cheap = (profiles.cheap ?? {}) as Record; + const cheapAdapterConfig = (cheap.adapterConfig ?? {}) as Record; + return { + enabled: cheap.enabled !== false, + model: typeof cheapAdapterConfig.model === "string" ? cheapAdapterConfig.model : "", + }; + }, [runtimeConfig]); + const cheapOverlay = !isCreate ? overlay.modelProfiles?.cheap : undefined; + const currentCheapEnabled = isCreate + ? val!.cheapModelEnabled ?? false + : cheapOverlay?.enabled ?? cheapProfileFromAgent.enabled; + const currentCheapModel = isCreate + ? val!.cheapModel ?? "" + : (() => { + const overlayModel = (cheapOverlay?.adapterConfig as Record | undefined)?.model; + if (typeof overlayModel === "string") return overlayModel; + return cheapProfileFromAgent.model; + })(); + + function setCheapEnabled(next: boolean) { + if (isCreate) { + set!({ cheapModelEnabled: next }); + return; + } + setOverlay((prev) => ({ + ...prev, + modelProfiles: { + cheap: { + ...(prev.modelProfiles?.cheap ?? {}), + enabled: next, + }, + }, + })); + } + + function setCheapModel(next: string) { + if (isCreate) { + set!({ cheapModel: next }); + return; + } + setOverlay((prev) => { + const existing = prev.modelProfiles?.cheap ?? {}; + const nextAdapterConfig = { + ...((existing.adapterConfig ?? {}) as Record), + model: next || undefined, + }; + return { + ...prev, + modelProfiles: { + cheap: { + ...existing, + adapterConfig: nextAdapterConfig, + }, + }, + }; + }); + } + const effectiveRuntimeConfig = useMemo(() => { if (isCreate) { return { @@ -720,6 +809,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { setOverlay((prev) => ({ ...prev, adapterType: t, + modelProfiles: { cheap: { cleared: true } }, adapterConfig: { model: t === "codex_local" @@ -854,6 +944,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /> + {supportsModelProfiles && ( +
Primary model
+ )} )} + {supportsModelProfiles && ( + + )} + {showThinkingEffort && ( <> void; + onModelChange: (next: string) => void; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const placeholderHint = adapterDefaultModel + ? `Adapter default · ${adapterDefaultModel}` + : "No adapter default — choose a cheaper model"; + return ( +
+
+
+
Cheap model
+

+ Used when a run requests the cheap profile (e.g. routine summaries). The primary model stays unchanged. +

+
+ +
+ {enabled ? ( + + ) : null} + {enabled && !model && adapterDefaultModel ? ( +

+ No explicit cheap model selected — runtime falls back to {adapterDefaultModel}. +

+ ) : null} + {enabled && !model && !adapterDefaultModel ? ( +

+ No cheap model selected and the adapter has no default. Cheap-lane runs will continue on the primary model with a fallback note. +

+ ) : null} +
+ ); +} + function ThinkingEffortDropdown({ value, options, diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 90d5bbb37a4..94e89e1c797 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -431,6 +431,41 @@ describe("IssueRunLedger", () => { }); }); + it("renders requested/applied model profile and surfaces fallback reasons", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-cheap-applied", + resultJson: { + modelProfile: { + requested: "cheap", + applied: "cheap", + configSource: "agent_runtime", + fallbackReason: null, + }, + }, + }), + createRun({ + runId: "run-cheap-fallback", + createdAt: "2026-04-18T19:50:00.000Z", + resultJson: { + modelProfile: { + requested: "cheap", + applied: "primary", + configSource: null, + fallbackReason: "agent_runtime_profile_disabled", + }, + }, + }), + ], + }); + + expect(container.textContent).toContain("Profile: cheap"); + expect(container.textContent).toContain("Profile: cheap → primary"); + expect(container.textContent).toContain("Cheap profile fell back to primary"); + expect(container.textContent).toContain("agent_runtime_profile_disabled"); + }); + it("hides watchdog decision actions for known non-owner viewers", () => { const onWatchdogDecision = vi.fn(); renderLedger({ diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index 7b0a5b5ee9b..e45bd4ae28b 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -159,6 +159,45 @@ function readString(value: unknown) { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +interface ModelProfileSummary { + requested: string; + applied: string | null; + configSource: string | null; + fallbackReason: string | null; +} + +function modelProfileForRun(run: RunForIssue): ModelProfileSummary | null { + const result = asRecord(run.resultJson); + const profile = asRecord(result?.modelProfile); + if (!profile) return null; + const requested = readString(profile.requested); + if (!requested) return null; + return { + requested, + applied: readString(profile.applied), + configSource: readString(profile.configSource), + fallbackReason: readString(profile.fallbackReason), + }; +} + +function modelProfileBadgeTone(summary: ModelProfileSummary) { + if (summary.applied === summary.requested) { + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + } + if (summary.fallbackReason) { + return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + } + return "border-border bg-background text-muted-foreground"; +} + +function modelProfileTitle(summary: ModelProfileSummary) { + const lines = [`Requested: ${summary.requested}`]; + if (summary.applied) lines.push(`Applied: ${summary.applied}`); + if (summary.configSource) lines.push(`Source: ${summary.configSource}`); + if (summary.fallbackReason) lines.push(`Fallback: ${summary.fallbackReason}`); + return lines.join("\n"); +} + function readNumber(value: unknown) { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -713,6 +752,26 @@ export function IssueRunLedgerContent({ {RUN_OUTPUT_SILENCE_COPY[run.outputSilence.level]?.label} ) : null} + {(() => { + const profile = modelProfileForRun(run); + if (!profile) return null; + const label = profile.applied === profile.requested + ? `Profile: ${profile.requested}` + : profile.applied + ? `Profile: ${profile.requested} → ${profile.applied}` + : `Profile: ${profile.requested} (unavailable)`; + return ( + + {label} + + ); + })()} {relativeTime(item.timestamp)} @@ -749,6 +808,20 @@ export function IssueRunLedgerContent({ ) : null} + {(() => { + const profile = modelProfileForRun(run); + if (!profile?.fallbackReason || profile.applied === profile.requested) return null; + return ( +

+ {profile.requested === "cheap" + ? "Cheap profile fell back to primary" + : `${profile.requested} profile unavailable`} + {": "} + {profile.fallbackReason} +

+ ); + })()} + {run.livenessReason ? (

{run.livenessReason} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index e05d13795a8..41bbb229e92 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { pickTextColorForSolidBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { issuesApi } from "../api/issues"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -77,6 +78,7 @@ interface IssueDraft { assigneeId?: string; projectId: string; projectWorkspaceId?: string; + assigneeModelLane?: IssueModelLane; assigneeModelOverride: string; assigneeThinkingEffort: string; assigneeChrome: boolean; @@ -93,7 +95,12 @@ type StagedIssueFile = { title?: string | null; }; -const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "opencode_local"]); +import { + buildAssigneeAdapterOverrides, + ISSUE_OVERRIDE_ADAPTER_TYPES, + type IssueModelLane, +} from "../lib/issue-assignee-overrides"; + const STAGED_FILE_ACCEPT = "image/*,application/pdf,text/plain,text/markdown,application/json,text/csv,text/html,.md,.markdown"; const ISSUE_THINKING_EFFORT_OPTIONS = { @@ -122,41 +129,6 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { ], } as const; -function buildAssigneeAdapterOverrides(input: { - adapterType: string | null | undefined; - modelOverride: string; - thinkingEffortOverride: string; - chrome: boolean; -}): Record | null { - const adapterType = input.adapterType ?? null; - if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { - return null; - } - - const adapterConfig: Record = {}; - if (input.modelOverride) adapterConfig.model = input.modelOverride; - if (input.thinkingEffortOverride) { - if (adapterType === "codex_local") { - adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; - } else if (adapterType === "opencode_local") { - adapterConfig.variant = input.thinkingEffortOverride; - } else if (adapterType === "claude_local") { - adapterConfig.effort = input.thinkingEffortOverride; - } else if (adapterType === "opencode_local") { - adapterConfig.variant = input.thinkingEffortOverride; - } - } - if (adapterType === "claude_local" && input.chrome) { - adapterConfig.chrome = true; - } - - const overrides: Record = {}; - if (Object.keys(adapterConfig).length > 0) { - overrides.adapterConfig = adapterConfig; - } - return Object.keys(overrides).length > 0 ? overrides : null; -} - function loadDraft(): IssueDraft | null { try { const raw = localStorage.getItem(DRAFT_KEY); @@ -406,6 +378,7 @@ export function NewIssueDialog() { const [projectId, setProjectId] = useState(""); const [projectWorkspaceId, setProjectWorkspaceId] = useState(""); const [assigneeOptionsOpen, setAssigneeOptionsOpen] = useState(false); + const [assigneeModelLane, setAssigneeModelLane] = useState("primary"); const [assigneeModelOverride, setAssigneeModelOverride] = useState(""); const [assigneeThinkingEffort, setAssigneeThinkingEffort] = useState(""); const [assigneeChrome, setAssigneeChrome] = useState(false); @@ -496,6 +469,25 @@ export function NewIssueDialog() { const supportsAssigneeOverrides = Boolean( assigneeAdapterType && ISSUE_OVERRIDE_ADAPTER_TYPES.has(assigneeAdapterType), ); + const getAdapterCapabilities = useAdapterCapabilities(); + const assigneeAdapterCapabilities = assigneeAdapterType + ? getAdapterCapabilities(assigneeAdapterType) + : null; + const assigneeSupportsCheapLane = Boolean( + supportsAssigneeOverrides && assigneeAdapterCapabilities?.supportsModelProfiles, + ); + + const { data: assigneeCheapProfiles } = useQuery({ + queryKey: effectiveCompanyId && assigneeAdapterType + ? queryKeys.agents.adapterModelProfiles(effectiveCompanyId, assigneeAdapterType) + : ["agents", "none", "adapter-model-profiles", assigneeAdapterType ?? "none"], + queryFn: () => agentsApi.adapterModelProfiles(effectiveCompanyId!, assigneeAdapterType!), + enabled: Boolean(effectiveCompanyId) && newIssueOpen && assigneeSupportsCheapLane, + }); + const assigneeCheapProfile = useMemo( + () => (assigneeCheapProfiles ?? []).find((profile) => profile.key === "cheap") ?? null, + [assigneeCheapProfiles], + ); const mentionOptions = useMemo(() => { return buildMarkdownMentionOptions({ agents, @@ -612,6 +604,7 @@ export function NewIssueDialog() { approverValue, projectId, projectWorkspaceId, + assigneeModelLane, assigneeModelOverride, assigneeThinkingEffort, assigneeChrome, @@ -663,6 +656,7 @@ export function NewIssueDialog() { approverValue, projectId, projectWorkspaceId, + assigneeModelLane, assigneeModelOverride, assigneeThinkingEffort, assigneeChrome, @@ -700,6 +694,7 @@ export function NewIssueDialog() { setProjectId(defaultProjectId); setProjectWorkspaceId(defaultProjectWorkspaceId); setAssigneeValue(assigneeValueFromSelection(newIssueDefaults)); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -744,6 +739,7 @@ export function NewIssueDialog() { setShowApproverRow(!!(draft.approverValue)); setProjectId(restoredProjectId); setProjectWorkspaceId(draft.projectWorkspaceId ?? defaultProjectWorkspaceIdForProject(restoredProject)); + setAssigneeModelLane(draft.assigneeModelLane ?? "primary"); setAssigneeModelOverride(draft.assigneeModelOverride ?? ""); setAssigneeThinkingEffort(draft.assigneeThinkingEffort ?? ""); setAssigneeChrome(draft.assigneeChrome ?? false); @@ -780,11 +776,15 @@ export function NewIssueDialog() { useEffect(() => { if (!supportsAssigneeOverrides) { setAssigneeOptionsOpen(false); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); return; } + if (!assigneeSupportsCheapLane && assigneeModelLane === "cheap") { + setAssigneeModelLane("primary"); + } const validThinkingValues = assigneeAdapterType === "codex_local" @@ -795,7 +795,13 @@ export function NewIssueDialog() { if (!validThinkingValues.some((option) => option.value === assigneeThinkingEffort)) { setAssigneeThinkingEffort(""); } - }, [supportsAssigneeOverrides, assigneeAdapterType, assigneeThinkingEffort]); + }, [ + supportsAssigneeOverrides, + assigneeAdapterType, + assigneeThinkingEffort, + assigneeSupportsCheapLane, + assigneeModelLane, + ]); // Cleanup timer on unmount useEffect(() => { @@ -816,6 +822,7 @@ export function NewIssueDialog() { setProjectId(""); setProjectWorkspaceId(""); setAssigneeOptionsOpen(false); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -841,6 +848,7 @@ export function NewIssueDialog() { setShowApproverRow(false); setProjectId(""); setProjectWorkspaceId(""); + setAssigneeModelLane("primary"); setAssigneeModelOverride(""); setAssigneeThinkingEffort(""); setAssigneeChrome(false); @@ -858,8 +866,14 @@ export function NewIssueDialog() { const currentTitle = titleRef.current.trim(); const currentDescription = descriptionRef.current.trim(); if (!effectiveCompanyId || !currentTitle || createIssue.isPending) return; + const effectiveLane = assigneeSupportsCheapLane + ? assigneeModelLane + : assigneeModelLane === "cheap" + ? "primary" + : assigneeModelLane; const assigneeAdapterOverrides = buildAssigneeAdapterOverrides({ adapterType: assigneeAdapterType, + lane: effectiveLane, modelOverride: assigneeModelOverride, thinkingEffortOverride: assigneeThinkingEffort, chrome: assigneeChrome, @@ -1557,36 +1571,84 @@ export function NewIssueDialog() { {assigneeOptionsOpen && (

-
Model
- -
-
-
Thinking effort
-
- {thinkingEffortOptions.map((option) => ( +
Model lane
+
+ {(["primary", ...(assigneeSupportsCheapLane ? (["cheap"] as const) : ([] as const)), "custom"] as const).map((lane) => ( ))}
+ {assigneeModelLane === "cheap" && ( +

+ Sends modelProfile: "cheap"{" "} + {assigneeCheapProfile?.adapterConfig && typeof (assigneeCheapProfile.adapterConfig as Record).model === "string" + ? <>· adapter default {String((assigneeCheapProfile.adapterConfig as Record).model)} + : assigneeCheapProfile + ? <>· uses the agent's configured cheap profile + : <>· falls back to the primary model if no cheap profile is configured} +

+ )} + {assigneeModelLane === "primary" && ( +

Runs on the agent's primary model.

+ )} + {assigneeModelLane === "custom" && ( +

Override the model and effort for this issue only.

+ )}
- {assigneeAdapterType === "claude_local" && ( + {assigneeModelLane === "custom" && ( +
+
Model
+ +
+ )} + {assigneeModelLane === "custom" && ( +
+
Thinking effort
+
+ {thinkingEffortOptions.map((option) => ( + + ))} +
+
+ )} + {assigneeAdapterType === "claude_local" && assigneeModelLane === "custom" && (
Enable Chrome (--chrome)
{ }); }); + it("writes the cheap profile under runtimeConfig.modelProfiles, never on primary adapterConfig", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }), + ); + + expect(patch).toEqual({ + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 300, + }, + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }, + }); + // The primary adapterConfig is untouched. + expect(patch.adapterConfig).toBeUndefined(); + }); + + it("merges cheap profile changes onto existing runtimeConfig.modelProfiles state", () => { + const agent = makeAgent(); + agent.runtimeConfig = { + heartbeat: { enabled: true, intervalSec: 300 }, + modelProfiles: { + cheap: { + enabled: false, + adapterConfig: { model: "old-cheap" }, + }, + }, + }; + + const patch = buildAgentUpdatePatch( + agent, + makeOverlay({ + modelProfiles: { + cheap: { + enabled: true, + }, + }, + }), + ); + + expect((patch.runtimeConfig as Record).modelProfiles).toEqual({ + cheap: { + enabled: true, + adapterConfig: { model: "old-cheap" }, + }, + }); + }); + + it("clears the cheap profile when the overlay marks it cleared", () => { + const agent = makeAgent(); + agent.runtimeConfig = { + heartbeat: { enabled: true, intervalSec: 300 }, + modelProfiles: { + cheap: { + enabled: true, + adapterConfig: { model: "claude-haiku-4-5" }, + }, + }, + }; + + const patch = buildAgentUpdatePatch( + agent, + makeOverlay({ + modelProfiles: { cheap: { cleared: true } }, + }), + ); + + expect(patch.runtimeConfig).toEqual({ + heartbeat: { enabled: true, intervalSec: 300 }, + }); + }); + it("preserves adapter-agnostic keys when changing adapter types", () => { const patch = buildAgentUpdatePatch( makeAgent(), diff --git a/ui/src/lib/agent-config-patch.ts b/ui/src/lib/agent-config-patch.ts index 323f9bf59db..e57e0c05ab3 100644 --- a/ui/src/lib/agent-config-patch.ts +++ b/ui/src/lib/agent-config-patch.ts @@ -1,11 +1,22 @@ import type { Agent } from "@paperclipai/shared"; +export interface AgentModelProfileOverlay { + enabled?: boolean; + adapterConfig?: Record; + /** + * Mark the cheap profile for clearing. When true, the patch removes + * `runtimeConfig.modelProfiles.cheap` instead of merging into it. + */ + cleared?: boolean; +} + export interface AgentConfigOverlay { identity: Record; adapterType?: string; adapterConfig: Record; heartbeat: Record; runtime: Record; + modelProfiles?: { cheap?: AgentModelProfileOverlay }; } const ADAPTER_AGNOSTIC_KEYS = [ @@ -56,10 +67,47 @@ export function buildAgentUpdatePatch(agent: Agent, overlay: AgentConfigOverlay) patch.replaceAdapterConfig = true; } - if (Object.keys(overlay.heartbeat).length > 0) { + const cheapOverlay = overlay.modelProfiles?.cheap; + const hasModelProfileChange = cheapOverlay !== undefined; + + if (Object.keys(overlay.heartbeat).length > 0 || hasModelProfileChange) { const existingRc = (agent.runtimeConfig ?? {}) as Record; - const existingHb = (existingRc.heartbeat ?? {}) as Record; - patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; + const nextRuntimeConfig: Record = (patch.runtimeConfig as Record | undefined) + ?? { ...existingRc }; + + if (Object.keys(overlay.heartbeat).length > 0) { + const existingHb = (existingRc.heartbeat ?? {}) as Record; + nextRuntimeConfig.heartbeat = { ...existingHb, ...overlay.heartbeat }; + } + + if (hasModelProfileChange) { + const existingProfiles = ((existingRc.modelProfiles ?? {}) as Record); + const existingCheap = ((existingProfiles.cheap ?? {}) as Record); + const nextProfiles = { ...existingProfiles }; + + if (cheapOverlay?.cleared) { + delete nextProfiles.cheap; + } else if (cheapOverlay) { + const mergedAdapterConfig = { + ...((existingCheap.adapterConfig ?? {}) as Record), + ...(cheapOverlay.adapterConfig ?? {}), + }; + const enabled = cheapOverlay.enabled ?? (existingCheap.enabled !== false); + nextProfiles.cheap = { + ...existingCheap, + enabled, + adapterConfig: mergedAdapterConfig, + }; + } + + if (Object.keys(nextProfiles).length === 0) { + delete nextRuntimeConfig.modelProfiles; + } else { + nextRuntimeConfig.modelProfiles = nextProfiles; + } + } + + patch.runtimeConfig = nextRuntimeConfig; } if (Object.keys(overlay.runtime).length > 0) { diff --git a/ui/src/lib/issue-assignee-overrides.test.ts b/ui/src/lib/issue-assignee-overrides.test.ts new file mode 100644 index 00000000000..bcc3f0baa0d --- /dev/null +++ b/ui/src/lib/issue-assignee-overrides.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; +import { buildAssigneeAdapterOverrides } from "./issue-assignee-overrides"; + +describe("buildAssigneeAdapterOverrides", () => { + it("returns null for adapters that do not accept issue overrides", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "process", + lane: "custom", + modelOverride: "anything", + thinkingEffortOverride: "high", + chrome: true, + }), + ).toBeNull(); + }); + + it("primary lane sends nothing", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "claude_local", + lane: "primary", + modelOverride: "", + thinkingEffortOverride: "", + chrome: false, + }), + ).toBeNull(); + }); + + it("cheap lane sends modelProfile=cheap and no adapterConfig", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "cheap", + modelOverride: "ignored", + thinkingEffortOverride: "high", + chrome: false, + }), + ).toEqual({ modelProfile: "cheap" }); + }); + + it("custom lane preserves explicit model + thinking effort + chrome overrides", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "claude_local", + lane: "custom", + modelOverride: "claude-haiku-4-5", + thinkingEffortOverride: "high", + chrome: true, + }), + ).toEqual({ + adapterConfig: { + model: "claude-haiku-4-5", + effort: "high", + chrome: true, + }, + }); + }); + + it("custom lane returns null when no fields are set", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "", + chrome: false, + }), + ).toBeNull(); + }); + + it("custom lane uses adapter-specific keys for thinking effort", () => { + expect( + buildAssigneeAdapterOverrides({ + adapterType: "codex_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "minimal", + chrome: false, + }), + ).toEqual({ + adapterConfig: { modelReasoningEffort: "minimal" }, + }); + expect( + buildAssigneeAdapterOverrides({ + adapterType: "opencode_local", + lane: "custom", + modelOverride: "", + thinkingEffortOverride: "max", + chrome: false, + }), + ).toEqual({ + adapterConfig: { variant: "max" }, + }); + }); +}); diff --git a/ui/src/lib/issue-assignee-overrides.ts b/ui/src/lib/issue-assignee-overrides.ts new file mode 100644 index 00000000000..bdbcfc0377e --- /dev/null +++ b/ui/src/lib/issue-assignee-overrides.ts @@ -0,0 +1,60 @@ +export const ISSUE_OVERRIDE_ADAPTER_TYPES = new Set([ + "claude_local", + "codex_local", + "opencode_local", +]); + +export type IssueModelLane = "primary" | "cheap" | "custom"; + +export interface BuildAssigneeAdapterOverridesInput { + adapterType: string | null | undefined; + lane: IssueModelLane; + modelOverride: string; + thinkingEffortOverride: string; + chrome: boolean; +} + +/** + * Build the `assigneeAdapterOverrides` payload sent to the issue create API. + * + * Lane semantics: + * - "primary" → no overrides, runs on the agent's primary model. + * - "cheap" → `modelProfile: "cheap"` only; the runtime resolves the actual + * adapter config from the agent's runtimeConfig + adapter default. + * - "custom" → preserves the legacy explicit override path + * (`adapterConfig.model`, thinking effort, chrome). + */ +export function buildAssigneeAdapterOverrides( + input: BuildAssigneeAdapterOverridesInput, +): Record | null { + const adapterType = input.adapterType ?? null; + if (!adapterType || !ISSUE_OVERRIDE_ADAPTER_TYPES.has(adapterType)) { + return null; + } + + if (input.lane === "primary") { + return null; + } + + if (input.lane === "cheap") { + return { modelProfile: "cheap" }; + } + + const adapterConfig: Record = {}; + if (input.modelOverride) adapterConfig.model = input.modelOverride; + if (input.thinkingEffortOverride) { + if (adapterType === "codex_local") { + adapterConfig.modelReasoningEffort = input.thinkingEffortOverride; + } else if (adapterType === "opencode_local") { + adapterConfig.variant = input.thinkingEffortOverride; + } else if (adapterType === "claude_local") { + adapterConfig.effort = input.thinkingEffortOverride; + } + } + if (adapterType === "claude_local" && input.chrome) { + adapterConfig.chrome = true; + } + + if (Object.keys(adapterConfig).length === 0) return null; + return { adapterConfig }; +} diff --git a/ui/src/lib/new-agent-hire-payload.ts b/ui/src/lib/new-agent-hire-payload.ts index ce699de0386..2fbe6d3cd28 100644 --- a/ui/src/lib/new-agent-hire-payload.ts +++ b/ui/src/lib/new-agent-hire-payload.ts @@ -32,6 +32,8 @@ export function buildNewAgentHirePayload(input: { runtimeConfig: buildNewAgentRuntimeConfig({ heartbeatEnabled: configValues.heartbeatEnabled, intervalSec: configValues.intervalSec, + cheapModel: configValues.cheapModel, + cheapModelEnabled: configValues.cheapModelEnabled, }), budgetMonthlyCents: 0, }; diff --git a/ui/src/lib/new-agent-runtime-config.test.ts b/ui/src/lib/new-agent-runtime-config.test.ts index 7c38437e278..e44fc0248ce 100644 --- a/ui/src/lib/new-agent-runtime-config.test.ts +++ b/ui/src/lib/new-agent-runtime-config.test.ts @@ -31,4 +31,34 @@ describe("buildNewAgentRuntimeConfig", () => { }, }); }); + + it("stores cheap model under modelProfiles.cheap, not primary adapterConfig", () => { + const config = buildNewAgentRuntimeConfig({ + heartbeatEnabled: true, + intervalSec: 600, + cheapModel: "claude-sonnet-4-6", + }); + + expect(config.modelProfiles).toEqual({ + cheap: { + enabled: true, + adapterConfig: { model: "claude-sonnet-4-6" }, + }, + }); + // primary heartbeat config still present + expect(config.heartbeat).toMatchObject({ enabled: true, intervalSec: 600 }); + }); + + it("omits modelProfiles when no cheap model is configured", () => { + const config = buildNewAgentRuntimeConfig({ heartbeatEnabled: false }); + expect(config.modelProfiles).toBeUndefined(); + }); + + it("omits modelProfiles when cheap model is set but explicitly disabled", () => { + const config = buildNewAgentRuntimeConfig({ + cheapModel: "claude-sonnet-4-6", + cheapModelEnabled: false, + }); + expect(config.modelProfiles).toBeUndefined(); + }); }); diff --git a/ui/src/lib/new-agent-runtime-config.ts b/ui/src/lib/new-agent-runtime-config.ts index 2f2e452a55f..4eb71f68a8a 100644 --- a/ui/src/lib/new-agent-runtime-config.ts +++ b/ui/src/lib/new-agent-runtime-config.ts @@ -4,8 +4,10 @@ import { defaultCreateValues } from "../components/agent-config-defaults"; export function buildNewAgentRuntimeConfig(input?: { heartbeatEnabled?: boolean; intervalSec?: number; -}) { - return { + cheapModel?: string; + cheapModelEnabled?: boolean; +}): Record { + const config: Record = { heartbeat: { enabled: input?.heartbeatEnabled ?? defaultCreateValues.heartbeatEnabled, intervalSec: input?.intervalSec ?? defaultCreateValues.intervalSec, @@ -14,4 +16,17 @@ export function buildNewAgentRuntimeConfig(input?: { maxConcurrentRuns: AGENT_DEFAULT_MAX_CONCURRENT_RUNS, }, }; + + const cheapModel = input?.cheapModel?.trim() ?? ""; + const cheapEnabled = input?.cheapModelEnabled !== false; + if (cheapModel && cheapEnabled) { + config.modelProfiles = { + cheap: { + enabled: true, + adapterConfig: { model: cheapModel }, + }, + }; + } + + return config; } diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 25d73a1dcde..7290c28a9ea 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + adapterModelProfiles: (companyId: string, adapterType: string) => + ["agents", companyId, "adapter-model-profiles", adapterType] as const, detectModel: (companyId: string, adapterType: string) => ["agents", companyId, "detect-model", adapterType] as const, }, diff --git a/ui/src/pages/AdapterManager.tsx b/ui/src/pages/AdapterManager.tsx index acda68778f9..94532a19f9f 100644 --- a/ui/src/pages/AdapterManager.tsx +++ b/ui/src/pages/AdapterManager.tsx @@ -615,6 +615,7 @@ export function AdapterManager() { supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }, }} canRemove={false} diff --git a/ui/storybook/stories/agent-management.stories.tsx b/ui/storybook/stories/agent-management.stories.tsx index a77ed84e56b..9e9ead37dba 100644 --- a/ui/storybook/stories/agent-management.stories.tsx +++ b/ui/storybook/stories/agent-management.stories.tsx @@ -291,6 +291,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, + supportsModelProfiles: true, }, }, { @@ -305,6 +306,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: true, supportsLocalAgentJwt: true, requiresMaterializedRuntimeSkills: true, + supportsModelProfiles: true, }, }, { @@ -319,6 +321,7 @@ const adapterFixtures: AdapterInfo[] = [ supportsSkills: false, supportsLocalAgentJwt: false, requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: false, }, }, ];