diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 87d14c80ae..963d76915f 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -712,6 +712,7 @@ export async function createChatSession( agentId?: string, mode: "resume_latest" | "new" = "resume_latest", signal?: AbortSignal, + agentModelId?: string, ) { return requestJson<{ agent_id: string; @@ -727,8 +728,23 @@ export async function createChatSession( // silently re-point the steward channel away from its configured executor. // A project Session carries only the host-issued reference, never a Goal. body: JSON.stringify(context.kind === "project" - ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, mode } - : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, mode, context_kind: context.kind }), + ? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, agent_model_id: agentModelId, mode } + : { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, agent_model_id: agentModelId, mode, context_kind: context.kind }), + }); +} + +export type AgentModelOption = { value: string; label: string }; +export type AgentModelDiscovery = { + ok: true; + schema_version: "loopx_chat_agent_model_options_v1"; + agent_id: string; + supported: boolean; + model: { id: string; label: string; selected: string; options: AgentModelOption[] } | null; +}; + +export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal) { + return requestJson("/api/chat/agent-model-options", { + method: "POST", signal, body: JSON.stringify({agent_id: agentId}), }); } @@ -745,6 +761,7 @@ export type ChatSessionSummary = { goal_id: string | null; project_ref?: string | null; agent_id: string; + agent_model_id?: string | null; adapter_kind: string; channel_id?: string; status: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 1e50839992..b7e08c9800 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -5,6 +5,8 @@ import { useWorkspaceI18n } from "./i18n"; import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat"; import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model"; import { WorkspaceSelect } from "./workspace-select"; +import { discoverAgentModelOptions, type AgentModelDiscovery } from "../../data/chat"; +import { useEffect, useState } from "react"; export function ChannelHeader({ agents, @@ -26,11 +28,13 @@ export function ChannelHeader({ onOpenNavigation, onSelectGoalTab, onSelectAgent, + onSelectAgentModel, onReturnManagerHome, refreshState, readOnlySourceLabel, resumeReviewed, selectedAgentId, + selectedAgentModelId, selectedGoal, selectedGoalTab, workspaceGrantLabel, @@ -58,16 +62,49 @@ export function ChannelHeader({ onOpenNavigation?: () => void; onSelectGoalTab: (tab: WorkspaceGoalTab) => void; onSelectAgent: (agentId: string) => void; + onSelectAgentModel?: (modelId: string) => void; onReturnManagerHome?: () => void; refreshState?: "idle" | "loading" | "done" | "error"; readOnlySourceLabel?: string; /** Resume opens the owner's reviewed preview instead of a local confirmation. */ resumeReviewed?: boolean; selectedAgentId: string; + selectedAgentModelId?: string; selectedGoal: WorkspaceGoal | null; selectedGoalTab: WorkspaceGoalTab; }) { const { locale, t } = useWorkspaceI18n(); + const [modelDiscovery, setModelDiscovery] = useState(null); + const [modelDiscoveryState, setModelDiscoveryState] = useState<"idle" | "loading" | "error">("idle"); + useEffect(() => { + if (readOnlySourceLabel) return; + const controller = new AbortController(); + setModelDiscovery(null); + setModelDiscoveryState("loading"); + void discoverAgentModelOptions(selectedAgentId, controller.signal) + .then((result) => { if (!controller.signal.aborted) { setModelDiscovery(result); setModelDiscoveryState("idle"); } }) + .catch(() => { if (!controller.signal.aborted) setModelDiscoveryState("error"); }); + return () => controller.abort(); + }, [readOnlySourceLabel, selectedAgentId]); + const model = modelDiscovery?.model; + const selectedModel = selectedAgentModelId === undefined ? model?.selected || model?.options[0]?.value || "" : selectedAgentModelId; + const modelControl = model && model.options.length ? ( + onSelectAgentModel?.(value)} + options={[ + ...(!selectedModel ? [{ disabled: true, label: locale === "zh-CN" ? "当前模型未知" : "Current model unknown", value: "" }] : []), + ...(selectedModel && !model.options.some((option) => option.value === selectedModel) + ? [{ disabled: true, label: `${locale === "zh-CN" ? "当前模型" : "Current model"}: ${selectedModel}`, value: selectedModel }] : []), + ...model.options.map((option) => ({ label: option.label, value: option.value })), + ]} + prefixLabel={locale === "zh-CN" ? "模型" : "Model"} + value={selectedModel} + /> + ) : selectedAgentModelId === "" ? {locale === "zh-CN" ? "当前模型未知" : "Current model unknown"} + : modelDiscoveryState === "loading" ? {locale === "zh-CN" ? "正在读取模型…" : "Reading models…"} + : modelDiscoveryState === "error" ? {locale === "zh-CN" ? "无法读取模型" : "Models unavailable"} : null; // The chip reports the selected executor, whose credential pays for it, and // the resolved model, so an executor and a model that disagree are visible // instead of arriving as one silent configuration. @@ -138,9 +175,9 @@ export function ChannelHeader({ : null; const runtimeControl = readOnlySourceLabel ? ( - {readOnlySourceLabel}{t("common.readOnly")} - ) : ( - {readOnlySourceLabel}{t("common.readOnly")} + ) : <> + } @@ -152,8 +189,9 @@ export function ChannelHeader({ }))} prefixLabel={t("header.chatRuntime")} value={selectedAgentId} - /> - ); + /> + {modelControl} + ; const executionChipClass = managerExecutionUnavailable ? "personal-execution-chip is-unavailable" : "personal-execution-chip"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 2b433edd93..dfdd930c31 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -554,6 +554,7 @@ export type PersonalWorkspaceCallbacks = { onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string, settings?: LoopXModeSettings) => void; onSelectAgent?: (agentId: string) => void; + onSelectAgentModel?: (agentId: string, modelId: string) => void; onSelectChannel?: (channel: WorkspaceChannel) => void; onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void; onSelectTodo?: (todoId: string | null) => void; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 5200c7f473..0439e2c3ea 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -820,6 +820,7 @@ export function PersonalWorkspacePage({ readOnly = false, typedActionsRevision = 0, selectedAgentId: controlledAgentId, + selectedAgentModelId, selectedGoalId: controlledGoalId, statusSourceControl, serviceNotice, @@ -847,6 +848,7 @@ export function PersonalWorkspacePage({ // re-reads the store instead of waiting for the next mount. typedActionsRevision?: number; selectedAgentId?: string; + selectedAgentModelId?: string; selectedGoalId?: string | null; statusSourceControl?: StatusSourceControl; serviceNotice?: ReactNode; @@ -2151,6 +2153,7 @@ export function PersonalWorkspacePage({ else setSelectedGoalTab(tab); }} onSelectAgent={selectAgent} + onSelectAgentModel={(modelId) => callbacks.onSelectAgentModel?.(selectedAgentId, modelId)} onReturnManagerHome={() => { if (selectedWorkspaceRef) workspaceConversations?.onSelect(null); setSelectedGoalTab("overview"); @@ -2158,6 +2161,7 @@ export function PersonalWorkspacePage({ window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 })); }} selectedAgentId={selectedAgentId} + selectedAgentModelId={selectedAgentModelId} refreshState={refreshState} readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined} selectedGoal={selectedGoal} diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 3fe38f9217..3ede6995c1 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -603,6 +603,10 @@ function readPersonalAgentSelections() { } } +function agentModelSelectionKey(contextId: string, agentId: string) { + return `${contextId}:${agentId}`; +} + type PersonalManagerAnswer = { lines: string[]; text: string; @@ -1406,6 +1410,7 @@ function PersonalGoalHome({ const agentDefaultForContext = (targetContextId: string) => targetContextId === "manager" ? stewardExecutorAgentId ?? defaultAgentId : defaultAgentId; const [selectedAgents, setSelectedAgents] = useState>(readPersonalAgentSelections); + const [selectedAgentModels, setSelectedAgentModels] = useState>({}); const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId); const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId); const [agentMenuOpen, setAgentMenuOpen] = useState(false); @@ -1584,7 +1589,11 @@ function PersonalGoalHome({ return () => { controller.abort(); clearTimeout(timer); }; }, [readOnly, conversationReturnSessionKey]); - function recordSessionAdmission(session: ChatSessionSummary) { + function recordSessionAdmission(targetContextId: string, session: ChatSessionSummary) { + const key = agentModelSelectionKey(targetContextId, session.agent_id); + const currentModel = session.agent_model_id ?? ""; + setSelectedAgentModels((current) => current[key] === currentModel + ? current : { ...current, [key]: currentModel }); const queues = chatSessionQueuesFollowUps(session); const supportsSteering = chatSessionSupportsSteering(session); setSteeringSessionIds((current) => { @@ -1712,6 +1721,7 @@ function PersonalGoalHome({ if (cancelled) return; const latest = currentChannelSession(history, selectedAgent.agentId); latestDiscoveredSessionId = latest?.session_id ?? null; + if (latest) recordSessionAdmission(targetContextId, latest); if (latest && !latest.resumable) { newSessionRequired.current.add(sessionKey); recordRuntimeBinding(targetContextId, { @@ -1726,12 +1736,13 @@ function PersonalGoalHome({ // actually made for this context is sent. const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId; - const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest"); + const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest", undefined, + latest?.agent_model_id ?? selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedAgent.agentId)]); if (cancelled) return; if (targetContext.kind === "manager" && created.session.manager_runtime) { setManagerRuntime(created.session.manager_runtime); } - recordSessionAdmission(created.session); + recordSessionAdmission(targetContextId, created.session); sessionIds.current.set(sessionKey, created.session_id); const activeSnapshot = history.snapshots.find( (snapshot) => snapshot.session.session_id === created.session_id, @@ -2216,8 +2227,8 @@ function PersonalGoalHome({ const key = `${goalId}:${agentId}`; const existing = sessionIds.current.get(key); if (existing) return existing; - const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal); - recordSessionAdmission(session.session); + const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal, selectedAgentModels[key]); + recordSessionAdmission(goalId, session.session); sessionIds.current.set(key, session.session_id); newSessionRequired.current.delete(key); recordRuntimeBinding(goalId, {agentId, resumable: true, sessionId: session.session_id, status: session.session.status}); @@ -2310,11 +2321,12 @@ function PersonalGoalHome({ const mode = newSessionRequired.current.has(sessionKey) ? "new" : "resume_latest"; const sessionEndpoint = targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId; - const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal); + const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal, + selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedRoute.agentId)]); if (targetContextId === "manager" && session.session.manager_runtime) { setManagerRuntime(session.session.manager_runtime); } - recordSessionAdmission(session.session); + recordSessionAdmission(targetContextId, session.session); sessionId = session.session_id; sessionIds.current.set(sessionKey, sessionId); recordRuntimeBinding(targetContextId, { @@ -2392,16 +2404,20 @@ function PersonalGoalHome({ // stored identity before offering a link; a failed read never hides the // visible answer or retries the model Turn. const completedMessageId = streamingMessageId; - if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) { - void fetchChatSession(sessionId).then((stored) => { + void fetchChatSession(sessionId).then((stored) => { + const key = `${targetContextId}:${stored.session.agent_id}`; + if (sessionIds.current.get(key) === sessionId && !newSessionRequired.current.has(key)) { + recordSessionAdmission(targetContextId, stored.session); + } + if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) { const answer = stored.messages.find((item) => item.turn_id === streamed.turnId && ["agent", "assistant"].includes(item.role)); if (answer) updateConversationMessage(targetContextId, completedMessageId, { sourceMessageId: answer.message_id, sourceSessionId: sessionId, collaboration: answer.collaboration, returnDelivery: answer.return_delivery, }); - }).catch(() => { /* The original conversation remains readable. */ }); - } + } + }).catch(() => { /* The original conversation remains readable. */ }); const todoProposals = response.proposals.filter(isTodoProposal); // The channel already states where a team plan is confirmed: its answer // names the Goal whose workspace holds the card, so a manager-channel @@ -2585,7 +2601,7 @@ function PersonalGoalHome({ if (!sessionId) return; try { const restored = await resumeChatSession(sessionId); - recordSessionAdmission(restored.session); + recordSessionAdmission(targetContextId, restored.session); sessionIds.current.set(sessionKey, sessionId); newSessionRequired.current.delete(sessionKey); recordRuntimeBinding(targetContextId, { @@ -2633,6 +2649,16 @@ function PersonalGoalHome({ setAgentMenuOpen(false); } + function chooseAgentModel(agentId: string, modelId: string) { + const selectionKey = agentModelSelectionKey(contextId, agentId); + if (selectedAgentModels[selectionKey] === modelId) return; + setSelectedAgentModels((current) => current[selectionKey] === modelId ? current : { ...current, [selectionKey]: modelId }); + const key = `${contextId}:${agentId}`; + sessionIds.current.delete(key); + newSessionRequired.current.add(key); + recordRuntimeBinding(contextId, null); + } + function openGoalChat(goalId: string) { onSelectGoal(goalId, "chat"); setMobilePanel("chat"); @@ -3094,6 +3120,7 @@ function PersonalGoalHome({ }, onRetryResumeRun: retryManagerSession, onSelectAgent: chooseAgent, + onSelectAgentModel: chooseAgentModel, onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); }, onSelectView, onSelectTodo, @@ -3118,6 +3145,7 @@ function PersonalGoalHome({ } : undefined} readOnly={readOnly} selectedAgentId={selectedAgent.agentId} + selectedAgentModelId={selectedAgentModels[agentModelSelectionKey(contextId, selectedAgent.agentId)]} selectedGoalId={selectedGoal?.goalId ?? null} serviceNotice={executionDiscoveryError ?

{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}

diff --git a/examples/loopx-chat-acp-adapter-smoke.py b/examples/loopx-chat-acp-adapter-smoke.py index a9163f08b1..0c47bba404 100644 --- a/examples/loopx-chat-acp-adapter-smoke.py +++ b/examples/loopx-chat-acp-adapter-smoke.py @@ -15,6 +15,7 @@ sys.path.insert(0, str(REPO_ROOT)) from loopx.chat_acp import ACPStdioAdapter # noqa: E402 +from loopx.chat_agent import CodexChatAgentError # noqa: E402 from loopx.chat_endpoints import AgentEndpointRegistry # noqa: E402 from loopx.chat_runtime import ChatRuntimeController # noqa: E402 from loopx.chat_store import ChatSessionStore # noqa: E402 @@ -30,6 +31,13 @@ active_prompt_id = None session_cwd = "" client_initialized = False +current_model = "fixture-default" +def config_options(): + return [{"id": "engine", "name": "Model", "category": "model", "type": "select", "currentValue": current_model, + "options": [{"group": "fixtures", "name": "Fixtures", "options": [ + {"value": "fixture-default", "name": "Fixture default"}, + {"value": "fixture/fast", "name": "Fixture fast"}, + ]}]}] for line in sys.stdin: request = json.loads(line) assert request.get("jsonrpc") == "2.0", request @@ -53,11 +61,12 @@ assert client_initialized, "client must send initialized before session/new" assert request["params"]["mcpServers"] == [] session_cwd = request["params"]["cwd"] - result = {"sessionId": session_id} + result = {"sessionId": session_id, "configOptions": config_options()} elif method == "session/load": assert request["params"]["sessionId"] == session_id session_cwd = request["params"]["cwd"] - result = {} + current_model = "fixture-default" + result = {} if "--omit-config-on-load" in sys.argv else {"configOptions": config_options()} elif method == "session/prompt": active_prompt_id = request_id prompt = request["params"]["prompt"][0]["text"] @@ -71,6 +80,11 @@ assert "Do not edit files" in prompt, prompt if "wait for cancel" in prompt: continue + if "provider fallback" in prompt: + current_model = "fixture-default" + print(json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, "update": {"sessionUpdate": "config_option_update", "configOptions": config_options()} + }}), flush=True) if "activity renew" in prompt: for _ in range(4): time.sleep(0.06) @@ -114,13 +128,14 @@ permission = json.loads(sys.stdin.readline()) assert permission["id"] == 900 assert permission["result"]["outcome"]["outcome"] == "cancelled" + answer = "ACP model: " + current_model if "report model" in prompt else "ACP 回答。" envelope = '' + json.dumps({ "schema_version": "loopx_chat_agent_response_v0", - "message": "ACP 回答。", + "message": answer, "proposals": [], "gate": None, }, ensure_ascii=False) + '' - response = "ACP 回答。\n" + envelope + response = answer + "\n" + envelope marker_split = response.index("") + 5 for chunk in (response[:marker_split], response[marker_split:]): print(json.dumps({ @@ -132,6 +147,10 @@ }}, }, ensure_ascii=False), flush=True) result = {"stopReason": "end_turn"} + elif method == "session/set_config_option": + assert request["params"] == {"sessionId": session_id, "configId": "engine", "type": "id", "value": "fixture/fast"} + current_model = "fixture/fast" + result = {"configOptions": config_options()} elif method == "session/cancel": if active_prompt_id is not None: print(json.dumps({"jsonrpc": "2.0", "id": active_prompt_id, "result": {"stopReason": "cancelled"}}), flush=True) @@ -152,9 +171,13 @@ def main() -> None: fake = root / "fake-acp" fake.write_text(FAKE_ACP, encoding="utf-8") fake.chmod(fake.stat().st_mode | stat.S_IXUSR) - adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root) + adapter = ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "fixture/fast"}) assert adapter.upstream_thread_id == "acp:fixture/session" assert adapter.capabilities()["resume"] is True + assert adapter.model_options() == { + "id": "engine", "label": "Model", "selected": "fixture/fast", + "options": [{"value": "fixture-default", "label": "Fixture default"}, {"value": "fixture/fast", "label": "Fixture fast"}], + } events: list[tuple[str, dict[str, object]]] = [] response = adapter.start_turn("检查状态", lambda kind, payload: events.append((kind, payload))) assert response["message"] == "ACP 回答。", response @@ -170,6 +193,20 @@ def main() -> None: assert not any("private thought" in str(payload) for _, payload in events), events adapter.close_session() + try: + ACPStdioAdapter.start(command=(str(fake),), work_dir=root, config_values={"model": "not-offered"}) + except CodexChatAgentError as exc: + assert exc.error_code == "unsupported_configuration", exc + else: + raise AssertionError("an unknown model was accepted") + + unknown = ACPStdioAdapter.start( + command=(str(fake), "--omit-config-on-load"), work_dir=root, + resume_thread_id="acp:fixture/session", + ) + assert unknown.current_model_id is None + unknown.close_session() + renewed = ACPStdioAdapter.start( command=(str(fake),), work_dir=root, @@ -214,6 +251,9 @@ def main() -> None: codex_bin="missing-codex-for-fixture", endpoint_registry=endpoint_registry, ) + discovered = first.inspect_agent_model_options("fixture-acp", work_dir=root) + assert discovered["model"]["id"] == "engine", discovered + assert {item["value"] for item in discovered["model"]["options"]} == {"fixture-default", "fixture/fast"} session, was_resumed = first.open_session( goal_id="fixture-goal", agent_id="fixture-acp", @@ -221,13 +261,15 @@ def main() -> None: objective="Exercise the ACP route.", mode="resume_latest", channel_id="task.fixture-acp", + agent_model_id="fixture/fast", ) assert was_resumed is False assert session["upstream_thread_id"] == "acp:fixture/session" + assert session["agent_model_id"] == "fixture/fast" turn, created = first.submit_turn( session_id=str(session["session_id"]), client_turn_id="fixture-turn", - message="verify execution mode", + message="verify execution mode; report model", work_dir=root, objective="Exercise the ACP route.", ) @@ -238,6 +280,7 @@ def main() -> None: timeout_sec=3, ) assert completed["status"] == "completed", completed + assert completed["response"]["message"] == "ACP model: fixture/fast", completed first.close() second = ChatRuntimeController( store=store, @@ -254,6 +297,14 @@ def main() -> None: ) assert was_resumed is True assert restored["session_id"] == session["session_id"] + assert restored["agent_model_id"] == "fixture-default", restored + fallback_turn, _ = second.submit_turn( + session_id=str(session["session_id"]), client_turn_id="fallback-turn", + message="provider fallback; report model", work_dir=root, objective="Exercise the ACP route.", + ) + fallback = second.wait_for_turn(session_id=str(session["session_id"]), turn_id=str(fallback_turn["turn_id"]), timeout_sec=3) + assert fallback["response"]["message"] == "ACP model: fixture-default", fallback + assert store.load_session(str(session["session_id"]))["agent_model_id"] == "fixture-default" second.close() # Kiro CLI is a built-in ACP agent, not an owner-registered endpoint: diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 886c50164a..c075e28554 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import {goalWorkspaceCreateScenario} from "./personal-workspace-browser/goal-workspace-create.mjs"; +import {agentModelSelectionScenario} from "./personal-workspace-browser/agent-model-selection.mjs"; import {goalWorkspaceM2Scenario} from "./personal-workspace-browser/goal-workspace-m2.mjs"; import {goalWorkspaceSelectionScenario} from "./personal-workspace-browser/goal-workspace-selection.mjs"; import { replanCadenceScenario } from "./personal-workspace-browser/replan-cadence.mjs"; @@ -106,6 +107,7 @@ scenarioCatalog.push(goalWorkspaceM2Scenario); scenarioCatalog.push(goalWorkspaceCreateScenario); scenarioCatalog.push(goalWorkspaceSelectionScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; +scenarioCatalog.push(agentModelSelectionScenario); const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) : scenarioCatalog; diff --git a/examples/personal-workspace-browser/agent-model-selection.mjs b/examples/personal-workspace-browser/agent-model-selection.mjs new file mode 100644 index 0000000000..3639bbf305 --- /dev/null +++ b/examples/personal-workspace-browser/agent-model-selection.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { openWorkspacePage } from "./scenario-context.mjs"; + +// Compiled UI with synthetic HTTP Session storage; ACP adoption has its own stdio smoke. +export const agentModelSelectionScenario = { + id: "agent-model-selection", + async run({ browser, collectCoverage, url }) { + let createdCount = 0; + const context = await openWorkspacePage(browser, url, { + collectCoverage, + beforeGoto: async (_api, page) => { + await page.route("**/api/chat/agent-model-options", route => route.fulfill({ json: { + ok: true, schema_version: "loopx_chat_agent_model_options_v1", agent_id: "codex", supported: true, + model: { id: "engine", label: "Model", selected: "safe", options: [ + { value: "safe", label: "Safe" }, { value: "vendor/model fast", label: "Fast" }, + ] }, + } })); + await page.route("**/api/chat/sessions", async route => { + if (route.request().method() !== "POST") return route.fallback(); + const body = route.request().postDataJSON(); + const runtime = page.__loopxRuntime; + const channel = body.context_kind === "manager" ? "manager" : `goal.${body.goal_id}`; + const existing = body.mode === "resume_latest" ? [...runtime.sessions.values()].find(session => + session.channel_id === channel && session.agent_id === body.agent_id) : null; + const session = existing ?? { + session_id: `model-session-${++createdCount}`, goal_id: body.goal_id, agent_id: body.agent_id, + agent_model_id: body.agent_model_id ?? "safe", adapter_kind: "codex_app_server", channel_id: channel, + status: "ready", active_turn_id: null, last_error_code: null, resumable: true, + created_at: "2026-08-13T01:00:00Z", updated_at: "2026-08-13T01:00:00Z", + }; + runtime.sessions.set(session.session_id, session); + runtime.messages.set(session.session_id, runtime.messages.get(session.session_id) ?? []); + await route.fulfill({ status: existing ? 200 : 201, json: { + ok: true, session_id: session.session_id, session, resumed: Boolean(existing), agent_id: session.agent_id, + } }); + }); + }, + }); + const { page, api } = context; + try { + await page.locator(".personal-goal-link").first().click(); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Chat|对话)$/ }).click(); + const selectModel = async label => { + await page.getByRole("combobox", { name: "选择模型" }).click(); + await page.getByRole("option", { name: label, exact: true }).click(); + }; + const send = async message => { + const count = api.turnRequests.length; + await page.getByLabel("向 LoopX 发送消息").fill(message); + await page.getByRole("button", { name: "发送", exact: true }).click(); + const deadline = Date.now() + 5000; + while (api.turnRequests.length === count && Date.now() < deadline) await page.waitForTimeout(25); + assert.equal(api.turnRequests.length, count + 1); + await page.locator(".personal-message-pending").waitFor({ state: "hidden" }); + return api.turnRequests.at(-1).sessionId; + }; + await selectModel("Safe"); + const original = await send("解释模型选择如何保持当前对话。"); + await page.getByRole("combobox", { name: "选择模型" }).filter({ hasText: "Safe" }).waitFor(); + const before = createdCount; + await selectModel("Safe"); + assert.equal(await send("继续同一个对话。"), original); + assert.equal(createdCount, before, "reselecting the current model must not create another Session"); + await selectModel("Fast"); + const changed = await send("使用新模型继续分析。"); + assert.notEqual(changed, original); + assert.equal(createdCount, before + 1, "a real model change creates exactly one distinct Session"); + assert.equal(page.__loopxRuntime.sessions.get(changed).agent_model_id, "vendor/model fast"); + assert.ok(page.__loopxRuntime.sessions.has(original), "prior history remains addressable"); + return { coverageEntries: context.coverageEntries, note: "Same-model selection preserves Session identity; a changed exact model value creates one distinct Session through the compiled UI and synthetic HTTP store." }; + } finally { + await context.close(); + } + }, +}; diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 0fc6ff3c7f..4bf45b00fe 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -31,14 +31,17 @@ "dict_any_count": 0 }, "loopx/chat_runtime.py": { - "any_count": 36, + "any_count": 37, "dict_any_count": 0, - "lines": 2137 + "lines": 2210 }, "loopx/chat_server.py": { "any_count": 25, "dict_any_count": 0 }, + "loopx/chat_store.py": { + "lines": 2015 + }, "loopx/codex_cli_probe.py": { "any_count": 37, "dict_any_count": 0 diff --git a/loopx/chat_acp.py b/loopx/chat_acp.py index 0a42a97da6..7b5f993e76 100644 --- a/loopx/chat_acp.py +++ b/loopx/chat_acp.py @@ -61,6 +61,7 @@ class ACPStdioAdapter: work_dir: Path agent_work_dir: Path agent_capabilities: dict[str, Any] + session_config_options: dict[str, dict[str, Any]] = field(default_factory=dict) execution_mode: bool = False startup_timeout_sec: float = 30.0 idle_timeout_sec: float = 180.0 @@ -90,6 +91,7 @@ def start( idle_timeout_sec: float = 180.0, hard_timeout_sec: float = 900.0, execution_mode: bool = False, + config_values: dict[str, str] | None = None, ) -> "ACPStdioAdapter": if not command: raise ValueError("ACP command is required") @@ -200,6 +202,12 @@ def start( adapter.session_id = str(result.get("sessionId") or "") if not adapter.session_id: raise adapter._runtime_error("ACP Agent did not return a session id.") + adapter.session_config_options = adapter._session_config_options(result) + # ACP session recovery restores the upstream configuration. Some + # providers omit configOptions from session/load, so reapplying the + # stored pick here would turn a valid recovery into a false error. + if config_values and not resume_thread_id: + adapter.set_config_values(config_values) return adapter except Exception: adapter._terminate() @@ -236,6 +244,81 @@ def capabilities(self) -> dict[str, Any]: "tool_calls": True, } + @staticmethod + def _session_config_options(result: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Normalize ACP's optional session configuration readback. + + Values are retained only in the live adapter. The dashboard receives a + deliberately small projection, so provider-specific response fields + and any local launch details cannot leak through the discovery API. + """ + raw = result.get("configOptions") + if not isinstance(raw, list): + return {} + options: dict[str, dict[str, Any]] = {} + for item in raw: + if not isinstance(item, dict): + continue + config_id = str(item.get("id") or "") + if not config_id.strip() or len(config_id) > 80: + continue + if item.get("type", "select") != "select": + continue + choices: list[dict[str, str]] = [] + raw_choices = item.get("options") if isinstance(item.get("options"), list) else [] + for entry in raw_choices: + group_choices = entry.get("options") if isinstance(entry, dict) else None + candidates = group_choices if isinstance(group_choices, list) else [entry] + for choice in candidates: + if not isinstance(choice, dict): + continue + value = choice.get("value") + if not isinstance(value, str) or not value or len(value) > 160: + continue + label = _compact_label(choice.get("name") or choice.get("label") or value, fallback=value) + choices.append({"value": value, "label": label}) + selected = str(item.get("currentValue") or item.get("value") or "") + options[config_id] = { + "label": _compact_label(item.get("name") or item.get("label") or config_id, fallback=config_id), + "category": str(item.get("category") or ""), + "selected": selected if len(selected) <= 160 else "", + "options": choices, + } + return options + + def model_options(self) -> dict[str, Any] | None: + for config_id, option in self.session_config_options.items(): + if option["category"] == "model": + return {"id": config_id, "label": option["label"], "selected": option["selected"], "options": option["options"]} + if "model" in self.session_config_options: + option = self.session_config_options["model"] + return {"id": "model", "label": option["label"], "selected": option["selected"], "options": option["options"]} + return None + + @property + def current_model_id(self) -> str | None: + model = self.model_options() + return (str(model["selected"]) or None) if model else None + + def set_config_values(self, values: dict[str, str]) -> None: + """Apply caller-selected ACP options after the session exists.""" + for config_id, value in values.items(): + if config_id == "model": + model = self.model_options() + config_id = str(model["id"]) if model else config_id + known = self.session_config_options.get(config_id) + if known is None: + raise self._runtime_error("ACP Agent does not offer the selected configuration.", error_code="unsupported_configuration") + allowed = {str(item["value"]) for item in known["options"]} + if value not in allowed: + raise self._runtime_error("ACP Agent rejected an unavailable configuration value.", error_code="unsupported_configuration") + result = self._request( + "session/set_config_option", + {"sessionId": self.session_id, "configId": config_id, "type": "id", "value": value}, + timeout_sec=self.startup_timeout_sec, + ) + self.session_config_options = self._session_config_options(result) + def _write(self, payload: dict[str, Any]) -> None: if self.process.stdin is None: raise self._runtime_error("ACP Agent input stream is closed.") @@ -359,7 +442,10 @@ def on_notification(payload: dict[str, Any]) -> None: if not isinstance(update, dict): return kind = str(update.get("sessionUpdate") or "") - if kind == "agent_message_chunk": + if kind == "config_option_update": + self.session_config_options = self._session_config_options(update) + event_sink("agent.model_config", {"model_id": self.current_model_id}) + elif kind == "agent_message_chunk": content = update.get("content") if isinstance(content, dict) and content.get("type") == "text": chunk = str(content.get("text") or "") diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index 2f7b0ac0d6..65eefba288 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -888,6 +888,7 @@ def open_manager_session( mode: str = "resume_latest", provider: str = "", audience: str = "", + agent_model_id: str | None = None, ) -> tuple[dict[str, Any], bool]: """Open the steward channel's Session through its own endpoint owner. @@ -926,6 +927,7 @@ def open_manager_session( channel_id=channel_id, agent_goal_id=MANAGER_AGENT_GOAL_ID, manager_executor_allocation=allocation, + agent_model_id=agent_model_id, ) diff --git a/loopx/chat_providers.py b/loopx/chat_providers.py index a0249515d6..b93bff8125 100644 --- a/loopx/chat_providers.py +++ b/loopx/chat_providers.py @@ -22,6 +22,34 @@ _PRIVATE_PARTS = {".git", ".env", ".ssh", ".aws", ".config", "node_modules"} +def codex_model_options(codex_bin: str) -> dict[str, Any] | None: + """Project the local Codex catalog without exposing its instructions.""" + executable = codex_bin if Path(codex_bin).is_absolute() else shutil.which(codex_bin) + if not executable: + return None + try: + result = subprocess.run( + [str(executable), "debug", "models"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + text=True, encoding="utf-8", errors="replace", timeout=15, check=False, + ) + payload = json.loads(result.stdout) if result.returncode == 0 else {} + except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError): + return None + rows = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(rows, list): + return None + options = [ + {"value": slug, "label": " ".join(str(row.get("display_name") or slug).split())[:100]} + for row in rows + if isinstance(row, dict) + and isinstance((slug := str(row.get("slug") or "").strip()), str) + and slug and len(slug) <= 160 + and str(row.get("visibility") or "list") == "list" + ] + return {"id": "model", "label": "Model", "selected": options[0]["value"] if options else "", "options": options} if options else None + + def _read_only_tools(provider: str) -> list[dict[str, Any]]: definitions = [ ("list_files", "List public project files matching a bounded glob.", { @@ -120,6 +148,7 @@ class ClaudeCodeAdapter: session_id: str tool_scope: str = "read_only" context_summary: str = "" + model: str | None = None resumed: bool = False current_process: subprocess.Popen[str] | None = field(default=None, repr=False) lock: threading.RLock = field(default_factory=threading.RLock, repr=False) @@ -137,6 +166,7 @@ def start( resume_thread_id: str | None = None, tool_scope: str = "read_only", context_summary: str = "", + model: str | None = None, ) -> "ClaudeCodeAdapter": resolved = shutil.which(claude_bin) if not resolved: @@ -152,6 +182,7 @@ def start( session_id=resume_thread_id or str(uuid.uuid4()), tool_scope=tool_scope, context_summary=context_summary, + model=model, resumed=bool(resume_thread_id), ) @@ -183,6 +214,8 @@ def start_turn(self, message: str, event_sink: EventSink) -> dict[str, Any]: command.extend(["--resume", self.session_id]) else: command.extend(["--session-id", self.session_id]) + if self.model: + command.extend(["--model", self.model]) command.append(_turn_prompt(message, context_summary=self.context_summary)) event_sink("turn.started", {"upstream_turn_id": self.session_id}) event_sink("agent.phase", {"label": "正在连接 Claude Code"}) diff --git a/loopx/chat_runtime.py b/loopx/chat_runtime.py index 966d3cb38c..30a8176e09 100644 --- a/loopx/chat_runtime.py +++ b/loopx/chat_runtime.py @@ -55,9 +55,10 @@ CHAT_SESSION_MODE_ATTACHED, TERMINAL_TURN_STATES, ChatSessionStore, + normalize_agent_model_id, utc_now, ) -from .chat_providers import ClaudeCodeAdapter, direct_model_from_environment +from .chat_providers import ClaudeCodeAdapter, codex_model_options, direct_model_from_environment from .attached_session import ( enqueue_attached_agent_turn, require_current_attached_session, @@ -390,6 +391,44 @@ def capabilities(self) -> list[dict[str, Any]]: ) return [*builtins, *(endpoint.public_summary() for endpoint in self.endpoint_registry.list())] + def inspect_agent_model_options(self, agent_id: str, *, work_dir: Path) -> dict[str, Any]: + """Read model choices from one selected, available ACP endpoint. + + This is intentionally not a PATH scan: the controller launches only an + endpoint already present in the built-in catalog or owner-local registry. + The temporary upstream session is closed before returning. + """ + capability = next((row for row in self.capabilities() if row["agent_id"] == agent_id), None) + if capability is None or not capability.get("available"): + raise agent_endpoint_error(agent_id, reason=str((capability or {}).get("unavailable_reason") or "")) + if agent_id == "codex": + return {"agent_id": agent_id, "model": codex_model_options(self.codex_bin), "supported": True} + if agent_id == "claude-code": + return { + "agent_id": agent_id, + "supported": True, + "model": { + "id": "model", "label": "Model", "selected": "sonnet", + "options": [ + {"value": "sonnet", "label": "Sonnet"}, + {"value": "opus", "label": "Opus"}, + {"value": "fable", "label": "Fable"}, + ], + }, + } + if str(capability.get("adapter_kind")) != "acp": + return {"agent_id": agent_id, "model": None, "supported": False} + adapter = self._start_adapter( + agent_id=agent_id, + work_dir=work_dir, + goal_id=None, + objective="", + ) + try: + return {"agent_id": agent_id, "model": adapter.model_options(), "supported": True} + finally: + adapter.close_session() + @staticmethod def _managed_upstream_mode(session: dict[str, Any]) -> str: if session.get("agent_id") == "codex": @@ -437,6 +476,7 @@ def _start_adapter( loopx_tools: bool = False, executor_model: Mapping[str, str | None] | None = None, project_context: dict[str, str] | None = None, + agent_config: dict[str, str] | None = None, ) -> ChatRuntimeAdapter: if project_context is not None: project_context_policy.validate_project_native_executor(agent_id, project_context) @@ -479,6 +519,8 @@ def _start_adapter( if goal_id == MANAGER_AGENT_GOAL_ID and not execution_mode else executor_model or {} ) + if agent_config and agent_config.get("model"): + model_config = {**model_config, "model": agent_config["model"]} return CodexAppServerAdapter.start( codex_bin=self.codex_bin, codex_home=self.codex_home, @@ -520,6 +562,7 @@ def _start_adapter( resume_thread_id=resume_thread_id, tool_scope="read_only", context_summary=f"{goal_id}: {objective}".strip() if goal_id is not None else objective.strip(), + model=agent_config.get("model") if agent_config else None, ) if agent_id == MANAGED_TURN_HOST: # The managed host has no interactive session transport, so this @@ -565,6 +608,7 @@ def _start_adapter( idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, + config_values=agent_config, ) endpoint = self.endpoint_registry.get(agent_id) if endpoint is not None: @@ -577,6 +621,7 @@ def _start_adapter( idle_timeout_sec=self.idle_timeout_sec, hard_timeout_sec=self.hard_timeout_sec, execution_mode=execution_mode, + config_values=agent_config, ) raise ValueError(f"unknown Agent endpoint: {agent_id}") @@ -595,7 +640,10 @@ def open_session( project_ref: str | None = None, conversation_binding_id: str | None = None, source_context: dict[str, Any] | None = None, + agent_model_id: str | None = None, ) -> tuple[dict[str, Any], bool]: + if agent_model_id: + agent_model_id = normalize_agent_model_id(agent_model_id) capability = next((item for item in self.capabilities() if item["agent_id"] == agent_id), None) if mode not in {"resume_latest", "new"}: raise ValueError("mode must be resume_latest or new") @@ -680,6 +728,11 @@ def open_session( latest = None if latest is not None and goal_instance_id is not None and latest.get("goal_instance_id") != goal_instance_id: latest = None + if latest is not None and agent_model_id and latest.get("agent_model_id") != agent_model_id: + # A model is a session binding for ACP. Never silently + # retarget an existing conversation when the user chose a + # different one; a new session makes that boundary visible. + latest = None if latest is not None and latest.get("session_mode") == CHAT_SESSION_MODE_ATTACHED: return latest, True if capability is None: @@ -702,20 +755,26 @@ def open_session( manager_runtime=manager_runtime, project_context=project_context, executor_model=alloc.manager_executor_model(manager_executor_allocation), + agent_config={"model": agent_model_id} if agent_model_id else None, ) - persisted = self.store.create_session( - goal_id=goal_id, - goal_instance_id=goal_instance_id, - agent_id=agent_id, - executor_endpoint_id=agent_id, - adapter_kind=str(capability["adapter_kind"]), - upstream_thread_id=adapter.upstream_thread_id, - upstream_mode="chat" if agent_id == "codex" else "default", - channel_id=selected_channel, - codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, - project_context=project_context, - steward_context=steward_context, - ) + try: + persisted = self.store.create_session( + goal_id=goal_id, + goal_instance_id=goal_instance_id, + agent_id=agent_id, + executor_endpoint_id=agent_id, + adapter_kind=str(capability["adapter_kind"]), + upstream_thread_id=adapter.upstream_thread_id, + upstream_mode="chat" if agent_id == "codex" else "default", + channel_id=selected_channel, + codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None, + project_context=project_context, + steward_context=steward_context, + agent_model_id=adapter.current_model_id if isinstance(adapter, ACPStdioAdapter) else agent_model_id, + ) + except Exception: + adapter.close_session() + raise persisted = self.project_contexts.initialize_bound_scope(self.store, persisted, runtime_root=self.coordination_runtime_root) if is_manager_channel(selected_channel): assert manager_runtime is not None @@ -945,6 +1004,7 @@ def _ensure_adapter_locked( if model_allocation is not None else alloc.restored_executor_model(session)), manager_runtime=manager_runtime, project_context=session.get("project_context"), + agent_config={"model": str(session["agent_model_id"])} if session.get("agent_model_id") else None, ) if session.get("upstream_mode") == CODEX_GOAL_CHAT_MODE: try: @@ -970,6 +1030,8 @@ def _ensure_adapter_locked( with self.lock: self.adapters[session_id] = adapter try: + if isinstance(adapter, ACPStdioAdapter): + self.store.update_session(session_id, agent_model_id=adapter.current_model_id) if session.get("agent_id") == "codex" and session.get("codex_home") is None: # A legacy session is bound only after successful upstream resume, # not when a service happens to start in a new environment. @@ -1584,6 +1646,9 @@ def event_sink(kind: str, payload: dict[str, Any]) -> None: expected_statuses={"starting", "running", "interrupting"}, upstream_turn_id=payload["upstream_turn_id"], ) + if kind == "agent.model_config": + self.store.update_session(session_id, agent_model_id=payload.get("model_id")) + return with self.lock: if (session_id, turn_id) in self.cancelled_turns: return diff --git a/loopx/chat_server.py b/loopx/chat_server.py index ccf28c288b..0cbf14c3da 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -47,7 +47,7 @@ ) from .chat_session_open import open_chat_session from .chat_ssh_source_api import SshSourceRequestMixin -from .chat_store import ChatSessionStore +from .chat_store import ChatSessionStore, normalize_agent_model_id from .extensions.lark.private_conversation_api import PrivateConversationRequestMixin, PRIVATE_CONVERSATIONS_PATH from .extensions.lark.conversation_identity import observe_lark_conversation_identity from .extensions.lark.private_conversations import LarkPrivateConversations @@ -116,6 +116,7 @@ DEFAULT_CHAT_STATUS_PATH = "/status.json" CHAT_CAPABILITIES_PATH = "/api/chat/capabilities" CHAT_ENDPOINTS_PATH = "/api/chat/endpoints" +CHAT_AGENT_MODEL_OPTIONS_PATH = "/api/chat/agent-model-options" CHAT_SESSIONS_PATH = "/api/chat/sessions" CHAT_ATTACH_SESSION_PATH = f"{CHAT_SESSIONS_PATH}/attach" CHAT_PROJECTION_MESSAGES_PATH = "/api/chat/projection-messages" @@ -582,7 +583,7 @@ def _serve_asset(self, path: str) -> None: def _create_session(self) -> None: try: body = self._read_json() - unknown = set(body) - {"goal_id", "agent_id", "mode", "context_kind", "project_ref"} + unknown = set(body) - {"goal_id", "agent_id", "agent_model_id", "mode", "context_kind", "project_ref"} if unknown: raise ValueError("unknown session field") goal_id = _compact_text(body.get("goal_id"), limit=160) or self.server.selected_goal_id or "" @@ -591,6 +592,8 @@ def _create_session(self) -> None: # own default through its own owner instead of inheriting whatever # executor this client happens to ship with. requested_endpoint = _compact_text(body.get("agent_id"), limit=80) + model_value = body.get("agent_model_id") + requested_model = normalize_agent_model_id(model_value) if model_value not in (None, "") else None mode = _compact_text(body.get("mode"), limit=40) or "resume_latest" context_kind = _compact_text(body.get("context_kind"), limit=40) or "goal" if context_kind not in {"goal", "manager", "project"}: @@ -630,6 +633,7 @@ def _create_session(self) -> None: objective=runtime_objective, mode=mode, requested_endpoint=requested_endpoint, + agent_model_id=requested_model, **({"project_ref": project_ref} if context_kind == "project" else {}), ) except CodexChatAgentError as exc: @@ -646,6 +650,7 @@ def _create_session(self) -> None: "session_id": public["session_id"], "goal_id": public["goal_id"], "agent_id": public.get("executor_endpoint_id") or requested_endpoint, + "agent_model_id": public.get("agent_model_id"), "context_kind": context_kind, "resumed": resumed, "session": public, @@ -653,6 +658,28 @@ def _create_session(self) -> None: status=200 if resumed else 201, ) + def _agent_model_options(self) -> None: + try: + body = self._read_json() + if set(body) != {"agent_id"}: + raise ValueError("agent_id is required") + agent_id = _compact_text(body.get("agent_id"), limit=80) + if not agent_id: + raise ValueError("agent_id is required") + # Discovery is deliberately bounded to a selected, known endpoint. + # It opens no persisted LoopX Chat session and exposes no command or + # local workspace data to the browser. + result = self.server.runtime_controller.inspect_agent_model_options( + agent_id, work_dir=Path.cwd() + ) + except CodexChatAgentError as exc: + self._send_error(str(exc), status=424, gate=exc.gate, error_code=exc.error_code) + return + except Exception as exc: # noqa: BLE001 - compact local validation error. + self._send_error(str(exc)) + return + self._send_json({"ok": True, "schema_version": "loopx_chat_agent_model_options_v1", **result}) + def _record_projection_exchange(self) -> None: """Persist a status-only exchange in the same owner-local message store.""" try: @@ -1500,6 +1527,7 @@ def do_POST(self) -> None: path = urlparse(self.path).path post_dispatch = { CHAT_SESSIONS_PATH: self._create_session, + CHAT_AGENT_MODEL_OPTIONS_PATH: self._agent_model_options, CHAT_ATTACH_SESSION_PATH: self._attach_session, CHAT_PROJECTION_MESSAGES_PATH: self._record_projection_exchange, CHAT_ACTION_PREVIEW_PATH: self._action_preview, diff --git a/loopx/chat_session_open.py b/loopx/chat_session_open.py index 63cc79720f..fb20424cbf 100644 --- a/loopx/chat_session_open.py +++ b/loopx/chat_session_open.py @@ -38,6 +38,7 @@ def open_chat_session( objective: str, mode: str, requested_endpoint: str = "", + agent_model_id: str = "", project_ref: str | None = None, ) -> tuple[dict[str, Any], bool]: """Return ``(session, resumed)`` for one entry-point request.""" @@ -49,6 +50,7 @@ def open_chat_session( work_dir=work_dir, executor_endpoint_id=requested_endpoint or None, mode=mode, + agent_model_id=agent_model_id or None, ) if context_kind == "project": if goal_id is not None or not project_ref: @@ -56,6 +58,7 @@ def open_chat_session( return controller.open_session( goal_id=None, agent_id=requested_endpoint or DEFAULT_GOAL_AGENT_ID, work_dir=work_dir, objective=objective, mode=mode, project_ref=project_ref, + agent_model_id=agent_model_id or None, ) if context_kind != "goal": raise ValueError("unknown conversation context") @@ -67,4 +70,5 @@ def open_chat_session( mode=mode, channel_id=f"goal.{goal_id}", agent_goal_id=goal_id, + agent_model_id=agent_model_id or None, ) diff --git a/loopx/chat_store.py b/loopx/chat_store.py index fbeac0e987..20a3ca2340 100644 --- a/loopx/chat_store.py +++ b/loopx/chat_store.py @@ -48,6 +48,7 @@ SESSION_QUEUE_TTL_SECONDS = 3600 _OPAQUE_ID = re.compile(r"^[A-Za-z0-9._-]{1,160}$") +_AGENT_MODEL_ID_MAX_LENGTH = 160 def utc_now() -> str: @@ -68,6 +69,19 @@ def _opaque_id(value: Any, *, field: str) -> str: return token +def normalize_agent_model_id(value: Any) -> str: + """Keep a provider model value without applying file-name rules. + + ACP option values are protocol strings rather than LoopX entity IDs. They + are persisted in JSON only, so retain a bounded printable value while + rejecting controls that would make logs or projections unsafe. + """ + token = str(value or "") + if not token.strip() or len(token) > _AGENT_MODEL_ID_MAX_LENGTH or any(ord(character) < 32 for character in token): + raise ValueError("agent_model_id must be a bounded model value") + return token + + def _upstream_id(value: Any) -> str: token = str(value or "").strip() if not token or len(token) > 1024 or any(ord(character) < 32 for character in token): @@ -257,6 +271,7 @@ def create_session( codex_home: str | None = None, project_context: dict[str, Any] | None = None, steward_context: dict[str, Any] | None = None, + agent_model_id: str | None = None, ) -> dict[str, Any]: now = utc_now() token = _opaque_id(session_id or uuid.uuid4().hex, field="session_id") @@ -304,6 +319,7 @@ def create_session( field="executor_endpoint_id", ) normalized_adapter_kind = _opaque_id(adapter_kind, field="adapter_kind") + normalized_agent_model_id = normalize_agent_model_id(agent_model_id) if agent_model_id else None normalized_upstream_thread_id = _upstream_id(upstream_thread_id) normalized_upstream_mode = _opaque_id(upstream_mode, field="upstream_mode") normalized_channel_id = _opaque_id( @@ -330,6 +346,7 @@ def create_session( else {} ), "agent_id": normalized_agent_id, + "agent_model_id": normalized_agent_model_id, "executor_endpoint_id": normalized_executor_endpoint_id, "adapter_kind": normalized_adapter_kind, "upstream_thread_id": normalized_upstream_thread_id, @@ -388,6 +405,7 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: "manager_runtime_standing_grant", "manager_runtime_tool_classes", "manager_executor_allocation", + "agent_model_id", "goal_id", } unknown = set(changes) - allowed @@ -408,6 +426,8 @@ def update_session(self, session_id: str, **changes: Any) -> dict[str, Any]: raise ValueError("only the owner manager may migrate to global identity") if "upstream_thread_id" in changes: changes["upstream_thread_id"] = _upstream_id(changes["upstream_thread_id"]) + if "agent_model_id" in changes: + changes["agent_model_id"] = normalize_agent_model_id(changes["agent_model_id"]) if changes["agent_model_id"] else None if "upstream_mode" in changes: changes["upstream_mode"] = _opaque_id(changes["upstream_mode"], field="upstream_mode") if "manager_authorization_scope_id" in changes: @@ -1914,6 +1934,7 @@ def public_session(self, payload: dict[str, Any]) -> dict[str, Any]: key: payload.get(key) for key in ( "session_id", "goal_id", "agent_id", "adapter_kind", "status", + "agent_model_id", "active_turn_id", "last_error_code", "created_at", "updated_at", "last_activity_at", ) } | { diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index b41c7974e0..9bb6424079 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -519,7 +519,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 1013, + "line": 1040, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -527,7 +527,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 540, + "line": 541, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -535,7 +535,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_project_registry#1", - "line": 1610, + "line": 1638, "column": 16, "kind": "codec_read", "api": "load_project_registry", @@ -543,7 +543,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1721, + "line": 1749, "column": 20, "kind": "codec_read", "api": "load_registry", diff --git a/tests/test_chat_agent_model_http.py b/tests/test_chat_agent_model_http.py new file mode 100644 index 0000000000..8b616f5010 --- /dev/null +++ b/tests/test_chat_agent_model_http.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import http.client +import json +from pathlib import Path +import threading +from types import SimpleNamespace + +import pytest + +import loopx.chat_server as chat_server +from loopx.chat_store import ChatSessionStore + + +@pytest.mark.parametrize("value", ["vendor/model fast", "vendor/model-fast", " vendor/model "]) +def test_http_session_preserves_exact_model_value(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + status, payload, received = _create_session(tmp_path, monkeypatch, value) + assert status == 201, payload + assert received == [value] + assert payload["session"]["agent_model_id"] == value + + +@pytest.mark.parametrize("value", ["model\nnext", "model\tfast", "x" * 161, " "]) +def test_http_session_rejects_invalid_model_before_start(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + status, payload, received = _create_session(tmp_path, monkeypatch, value) + assert status == 400, payload + assert received == [] + assert "agent_model_id" in payload["error"] + + +def _create_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, value: str) -> tuple[int, dict, list[str]]: + received: list[str] = [] + store = ChatSessionStore(tmp_path / "runtime") + monkeypatch.setattr(chat_server.ChatRequestHandler, "_registry_and_goal", lambda _self, _goal: ({}, {})) + monkeypatch.setattr(chat_server, "_goal_public_context", lambda *_args: { + "project": tmp_path, "objective": "Validate model input.", "title": "Fixture", + }) + + def open_session(**kwargs): + model = kwargs["agent_model_id"] + received.append(model) + return store.create_session( + goal_id="fixture", agent_id="fixture-acp", adapter_kind="acp", + upstream_thread_id="fixture-thread", agent_model_id=model, + ), False + + monkeypatch.setattr(chat_server, "open_chat_session", open_session) + server = chat_server.ChatHTTPServer(("127.0.0.1", 0), chat_server.ChatRequestHandler) + server.verbose = False + server.selected_goal_id = "fixture" + server.chat_store = store + server.runtime_controller = SimpleNamespace(close=lambda: None) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + connection = http.client.HTTPConnection("127.0.0.1", server.server_address[1], timeout=5) + try: + connection.request("POST", "/api/chat/sessions", body=json.dumps({ + "goal_id": "fixture", "agent_id": "fixture-acp", "agent_model_id": value, "mode": "new", + }), headers={"Content-Type": "application/json", "Origin": "http://127.0.0.1"}) + response = connection.getresponse() + return response.status, json.loads(response.read()), received + finally: + connection.close() + server.shutdown() + thread.join(timeout=5) + server.server_close() diff --git a/tests/test_chat_store_input_validation.py b/tests/test_chat_store_input_validation.py index 0f9dd2d5ec..499b79467c 100644 --- a/tests/test_chat_store_input_validation.py +++ b/tests/test_chat_store_input_validation.py @@ -9,6 +9,17 @@ from loopx.chat_store import CHAT_SESSION_SCHEMA_VERSION, ChatSessionStore +def test_agent_model_value_keeps_provider_protocol_characters() -> None: + assert chat_store.normalize_agent_model_id("vendor/model-fast") == "vendor/model-fast" + assert chat_store.normalize_agent_model_id("vendor/model fast") == "vendor/model fast" + + +@pytest.mark.parametrize("value", ["", "model\nnext", "x" * 161]) +def test_agent_model_value_rejects_empty_controls_and_unbounded_values(value: str) -> None: + with pytest.raises(ValueError, match="agent_model_id"): + chat_store.normalize_agent_model_id(value) + + def test_session_id_cannot_escape_sessions_directory(tmp_path: Path) -> None: store = ChatSessionStore(tmp_path) (store.root / "session.json").write_text(