From 8fcdd61bc53e63cfd04b304d6897df8a0c0a0f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 12:53:25 -0400 Subject: [PATCH] Q76: runs call the agent's own model, or fail plainly where the provider cannot Co-Authored-By: Claude Opus 5.5 --- src/main/services/provider-response-runner.ts | 17 +++-- src/main/services/task-execution-service.ts | 4 +- src/main/services/task-prompts.ts | 3 +- src/main/services/task-run-preparation.ts | 5 ++ tests/integration/agent-model.test.ts | 70 +++++++++++++++++++ 5 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 tests/integration/agent-model.test.ts diff --git a/src/main/services/provider-response-runner.ts b/src/main/services/provider-response-runner.ts index eb03510b..2510f9f0 100644 --- a/src/main/services/provider-response-runner.ts +++ b/src/main/services/provider-response-runner.ts @@ -17,6 +17,7 @@ import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router"; import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver"; import { currentRunId } from "./run-control"; import { budgetBlock, isPriced, promptChars, providerPrices } from "./spend"; +import { effectiveModel } from "./task-run-preparation"; import { HEALTH_STATE_HEALTHY, WORKSPACE_SCOPES, @@ -116,15 +117,19 @@ export class ProviderResponseRunner { if (!supportsToolTurn(provider.provider_key)) { throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, Google Antigravity, or Claude Pro provider."); } - const model = provider.model; + // A call made for a run works for its agent: the agent's model, its budget, its attribution. + const taskRunId = currentRunId(); + const agent = taskRunId + ? this.deps.db.prepare("select a.id, a.department, a.model_id from task_runs r join agents a on a.id = r.agent_id where r.id = ?").get(taskRunId) as { id: string; department: string; model_id: string | null } + : null; + // Azure calls the deployment stored with its credential, so an agent there cannot pick another model. + if (provider.provider_key === "azure-openai" && agent?.model_id && agent.model_id !== provider.model) { + throw new Error(`Azure OpenAI calls the deployment configured on the provider; this agent asks for "${agent.model_id}". Clear the agent's model or set it to "${provider.model}".`); + } + const model = effectiveModel(agent?.model_id ?? null, provider.model); if (!model) throw new Error("Provider model is required."); const owner: ProviderRow & { model: string } = { ...provider, model }; const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key); - // A call made for a run is spent by its agent: refuse before calling once its budget is gone. - const taskRunId = currentRunId(); - const agent = taskRunId - ? this.deps.db.prepare("select a.id, a.department from task_runs r join agents a on a.id = r.agent_id where r.id = ?").get(taskRunId) as { id: string; department: string } - : null; const blocked = agent ? budgetBlock(this.deps.db, agent.id, isPriced(providerPrices(this.deps.db, provider.id)), new Date()) : null; if (blocked) throw new Error(blocked); // Everything the model reads: instructions, the conversation, and the tool definitions. diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index c7c137d8..1adb76d7 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -37,7 +37,7 @@ import { ConnectorGrantsService } from "./composio/connector-grants"; import { ProviderCooldowns } from "./provider-cooldowns"; import { HeartbeatScheduler } from "./heartbeat-scheduler"; import { - assertExecutionApproval, createIsolationContext, executionApprovalProblem, loadGoal, loadProject, loadWorkspace, + assertExecutionApproval, createIsolationContext, effectiveModel, executionApprovalProblem, loadGoal, loadProject, loadWorkspace, requireAgentProvider, requireAssignedAgent, requireTask, sameWorkspaceAccess, workspaceAccess, } from "./task-run-preparation"; @@ -175,7 +175,7 @@ export class TaskExecutionService { let providerRequestId: string | null = null; let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null; try { - skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: provider.model }, task.writing_mode === WRITING_MODE.CONVERSATIONAL); + skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId, skillBudgetTokens(provider.context_window_tokens), `${task.title}\n${task.description}`, { providerKey: provider.provider_key, model: effectiveModel(agent.model_id, provider.model) }, task.writing_mode === WRITING_MODE.CONVERSATIONAL); // Watermark for reply delivery and mid-run message polling. const runStartedAt = new Date().toISOString(); const chatContext = loadChatWakeContext(this.db, orgId, agent.id, isolation.secretScopeJson); diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 274ff2b2..4dbb9757 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -1,5 +1,6 @@ import { WRITING_MODE, type WritingMode } from "../../shared/domain"; import { CEO_FIRST_HIRES, firstHiresFor, leadsPeople } from "./org-building"; +import { effectiveModel } from "./task-run-preparation"; import { canonicalMentionHandle } from "../../shared/mention-handles"; const RECORD_LEARNINGS_INSTRUCTION = @@ -220,7 +221,7 @@ export function buildTaskPrompt(input: { `Requires filesystem: ${input.task.requires_filesystem === 1 ? "yes" : "no"}`, `Assignee: ${input.agent.name} (${input.agent.role})`, ...(input.managerName ? [`Reports to: ${input.managerName}`] : []), - `Provider: ${input.provider.label} ${input.provider.model ?? input.agent.model_id ?? ""}`.trim(), + `Provider: ${input.provider.label} ${effectiveModel(input.agent.model_id, input.provider.model) ?? ""}`.trim(), `Execution context: ${input.isolation.executionContextId}`, `Service identity: ${input.isolation.serviceIdentityId}`, ]; diff --git a/src/main/services/task-run-preparation.ts b/src/main/services/task-run-preparation.ts index 38ab9d15..dd1e8bd7 100644 --- a/src/main/services/task-run-preparation.ts +++ b/src/main/services/task-run-preparation.ts @@ -67,6 +67,11 @@ export function sameWorkspaceAccess(mountJson: string, current: Array<{ workspac return JSON.stringify(recorded) === JSON.stringify(current); } +/** The model a run calls: the agent's own choice, or else its provider's default. */ +export function effectiveModel(agentModelId: string | null, providerModel: string | null): string | null { + return agentModelId?.trim() || providerModel; +} + export function requireAgentProvider(db: DatabaseSync, orgId: string, agent: TaskExecutionAgentRow): TaskExecutionProviderRow { if (!agent.model_provider_id) throw new Error("Task assignee requires a model provider."); const provider = db.prepare(` diff --git a/tests/integration/agent-model.test.ts b/tests/integration/agent-model.test.ts new file mode 100644 index 00000000..f9e66462 --- /dev/null +++ b/tests/integration/agent-model.test.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ProviderService } from "../../src/main/services/provider-service"; +import { providerAdapters } from "../../src/main/services/provider-adapters"; +import { TaskExecutionService } from "../../src/main/services/task-execution-service"; +import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain"; +import type { AgentTurnResult, ProviderToolTurnInput } from "../../src/main/services/provider-tool-turn"; + +const testVault = { + encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), + decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"), +}; + +describe("agent model", () => { + test("an agent's own model overrides the provider default in the call, the prompt and the request record", async () => { + const models: string[] = []; + let prompt = ""; + const turn = async (input: ProviderToolTurnInput): Promise => { + models.push(input.model); + const first = input.messages[0]; + if (first?.role === "user") prompt = first.text; + return { text: "done", toolCalls: [], rawResponseJson: "{}" }; + }; + const connection = new DatabaseConnection(join(tmpdir(), `ac-model-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const execution = new TaskExecutionService(connection.db, providers); + let orgId = ""; + const tasks: Record = {}; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + providers.saveCredential({ orgId, providerId, secret: "sk-test", model: "gpt-5.4" }); + for (const [name, modelId] of [["Ada", "gpt-5.5-mini"], ["Bo", null]] as const) { + const agentId = organization.createAgent({ orgId, name, role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId, budgetCents: 1000, managerAgentId: null }); + tasks[name] = organization.createTask({ orgId, title: `${name} work`, description: "d", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false }); + } + }); + const adaRun = await execution.runTask({ orgId, taskId: tasks.Ada }); + expect(models.at(-1)).toBe("gpt-5.5-mini"); + expect(prompt).toContain("gpt-5.5-mini"); + expect(connection.db.prepare("select model from provider_requests where task_run_id = ?").get(adaRun)).toEqual({ model: "gpt-5.5-mini" }); + await execution.runTask({ orgId, taskId: tasks.Bo }); + expect(models.at(-1)).toBe("gpt-5.4"); + connection.close(); + }); + + test("an agent on Azure cannot silently switch away from the provider's deployment", async () => { + const turn = async (): Promise => ({ text: "done", toolCalls: [], rawResponseJson: "{}" }); + const connection = new DatabaseConnection(join(tmpdir(), `ac-model-az-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const providers = new ProviderService(connection.db, providerAdapters, testVault, undefined, turn); + const execution = new TaskExecutionService(connection.db, providers); + let orgId = ""; + let taskId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'azure-openai'").get(orgId) as { id: string }).id; + connection.db.prepare("update providers set model = 'gpt-5.4', enabled = 1, auth_state = 'configured', credential_id = 'cred_az' where id = ?").run(providerId); + const agentId = organization.createAgent({ orgId, name: "Ada", role: "Engineer", department: "Engineering", modelProviderId: providerId, modelId: "gpt-5.4-mini", budgetCents: 1000, managerAgentId: null }); + taskId = organization.createTask({ orgId, title: "Work", description: "d", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: agentId, risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false }); + }); + await expect(execution.runTask({ orgId, taskId })).rejects.toThrow('Azure OpenAI calls the deployment configured on the provider; this agent asks for "gpt-5.4-mini".'); + connection.close(); + }); +});