Q83: a model passes an agent check before it does agent work
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e232b241f
commit
665bf3cae1
35 files changed
+350
-47
No files matched your search
@@ -228,6 +228,13 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
return fail("provider_models_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.checkProviderModel, async (_event, input) => {
|
||||
try {
|
||||
return ok(await providers.checkModel(input));
|
||||
} catch (error) {
|
||||
return fail("provider_model_check_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.launchProviderSignIn, (_event, input) => {
|
||||
try {
|
||||
providers.launchSignInWindow(input);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const modelChecksSchema = `
|
||||
create table model_checks (
|
||||
provider_id text not null references providers(id) on delete cascade,
|
||||
model text not null,
|
||||
credential_id text not null,
|
||||
org_id text not null,
|
||||
passed integer not null,
|
||||
detail text,
|
||||
checked_at text not null,
|
||||
primary key (provider_id, model, credential_id)
|
||||
);
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (77, 'model_checks', datetime('now'));
|
||||
`;
|
||||
@@ -75,6 +75,7 @@ import { usageWindowTokensSchema } from "./migrations/073-usage-window-tokens";
|
||||
import { runFailureKindSchema } from "./migrations/074-run-failure-kind";
|
||||
import { providerCooldownsSchema } from "./migrations/075-provider-cooldowns";
|
||||
import { runPinsSchema } from "./migrations/076-run-pins";
|
||||
import { modelChecksSchema } from "./migrations/077-model-checks";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -159,6 +160,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 74, name: "run_failure_kind", sql: runFailureKindSchema },
|
||||
{ version: 75, name: "provider_cooldowns", sql: providerCooldownsSchema },
|
||||
{ version: 76, name: "run_pins", sql: runPinsSchema },
|
||||
{ version: 77, name: "model_checks", sql: modelChecksSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { randomInt } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { AgentTurnMessage, AgentTurnResult, ToolDefinition } from "./provider-tool-turn";
|
||||
|
||||
export const MODEL_CHECK_TOOL = "confirm_ready";
|
||||
|
||||
export interface ModelCheckTurn {
|
||||
code: string;
|
||||
instructions: string;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
}
|
||||
|
||||
/** One turn that needs what agent work needs: system instructions, a tool, and arguments taken from the conversation. */
|
||||
export function modelCheckTurn(): ModelCheckTurn {
|
||||
const code = String(randomInt(100_000, 1_000_000));
|
||||
return {
|
||||
code,
|
||||
instructions: `You are being checked for agent work. Answer only by calling ${MODEL_CHECK_TOOL} with the code from the user's message; never answer in text.`,
|
||||
messages: [{ role: "user", text: `The code is ${code}.` }],
|
||||
tools: [{
|
||||
name: MODEL_CHECK_TOOL,
|
||||
description: "Confirms the agent is ready by echoing the code.",
|
||||
parameters: { type: "object", properties: { code: { type: "string", description: "The code from the user's message." } }, required: ["code"] },
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/** Why the answer shows the model cannot do agent work on this interface, or null when it passed. */
|
||||
export function modelCheckProblem(result: Pick<AgentTurnResult, "toolCalls">, code: string): string | null {
|
||||
const call = result.toolCalls.find((candidate) => candidate.name === MODEL_CHECK_TOOL);
|
||||
if (!call) return result.toolCalls.length > 0 ? `it called ${result.toolCalls[0].name}, a tool it was not given` : "it answered in text instead of calling the tool it was given";
|
||||
if (call.argumentsError) return `its tool arguments were ${call.argumentsError}`;
|
||||
if (String(call.arguments.code) !== code) return "it called the tool without the code its instructions asked for";
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ModelCheck = { passed: true } | { passed: false; detail: string };
|
||||
|
||||
export function readModelCheck(db: DatabaseSync, providerId: string, model: string, credentialId: string): ModelCheck | null {
|
||||
const row = db.prepare("select passed, detail from model_checks where provider_id = ? and model = ? and credential_id = ?")
|
||||
.get(providerId, model, credentialId) as { passed: number; detail: string | null } | undefined;
|
||||
if (!row) return null;
|
||||
if (row.passed === 1) return { passed: true };
|
||||
if (!row.detail) throw new Error(`The failed agent check of ${model} has no reason recorded.`);
|
||||
return { passed: false, detail: row.detail };
|
||||
}
|
||||
|
||||
export function recordModelCheck(db: DatabaseSync, input: { orgId: string; providerId: string; model: string; credentialId: string; problem: string | null; now: Date }): void {
|
||||
db.prepare(`
|
||||
insert into model_checks(provider_id, model, credential_id, org_id, passed, detail, checked_at) values (?, ?, ?, ?, ?, ?, ?)
|
||||
on conflict(provider_id, model, credential_id) do update set passed = excluded.passed, detail = excluded.detail, checked_at = excluded.checked_at
|
||||
`).run(input.providerId, input.model, input.credentialId, input.orgId, input.problem ? 0 : 1, input.problem, input.now.toISOString());
|
||||
}
|
||||
|
||||
export function modelCheckFailure(providerLabel: string, model: string, detail: string): string {
|
||||
return `${model} on ${providerLabel} failed the agent check: ${detail}. Pick another model, or check it again in Settings.`;
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver
|
||||
import { currentRunId } from "./run-control";
|
||||
import { budgetBlock, isPriced, promptChars, providerPrices } from "./spend";
|
||||
import { assertModelSelectable, effectiveModel } from "./task-run-preparation";
|
||||
import { modelCheckFailure, modelCheckProblem, modelCheckTurn, readModelCheck, recordModelCheck } from "./model-check";
|
||||
import type { ModelCheckResult } from "../../shared/desktop-api";
|
||||
import {
|
||||
HEALTH_STATE_HEALTHY,
|
||||
WORKSPACE_SCOPES,
|
||||
@@ -113,7 +115,6 @@ export class ProviderResponseRunner {
|
||||
}): Promise<AgentTurnResult & { requestId: string }> {
|
||||
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
|
||||
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
|
||||
this.enforcePolicy(input.orgId, provider, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId);
|
||||
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.");
|
||||
}
|
||||
@@ -134,32 +135,67 @@ export class ProviderResponseRunner {
|
||||
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);
|
||||
const blocked = agent ? budgetBlock(this.deps.db, agent.id, isPriced(providerPrices(this.deps.db, provider.id)), new Date()) : null;
|
||||
const caller = { orgId: input.orgId, owner, workspaceId, agent, taskRunId, turn };
|
||||
const known = readModelCheck(this.deps.db, provider.id, model, requireCredentialId(provider));
|
||||
const problem = known ? (known.passed ? null : known.detail) : await this.probeModel(caller);
|
||||
if (problem) throw new Error(modelCheckFailure(provider.provider_key, model, problem));
|
||||
return this.callTurn(caller, { instructions: input.instructions, messages: input.messages, tools: input.tools });
|
||||
}
|
||||
|
||||
/** Probes a model for agent work and remembers the verdict; a request that fails records nothing. */
|
||||
private async probeModel(caller: TurnCaller): Promise<string | null> {
|
||||
const probe = modelCheckTurn();
|
||||
const problem = modelCheckProblem(await this.callTurn(caller, probe), probe.code);
|
||||
const { owner } = caller;
|
||||
recordModelCheck(this.deps.db, { orgId: caller.orgId, providerId: owner.id, model: owner.model, credentialId: requireCredentialId(owner), problem, now: new Date() });
|
||||
console.info(`[model-check] ${owner.provider_key} ${owner.model}: ${problem ?? "passed"}`);
|
||||
return problem;
|
||||
}
|
||||
|
||||
/** Runs the agent check of a provider's model now, replacing any earlier result. */
|
||||
async checkModel(input: { orgId: string; providerId: string; model: string }): Promise<ModelCheckResult> {
|
||||
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
|
||||
if (!supportsToolTurn(provider.provider_key)) throw new Error("Agent execution is not available for this provider.");
|
||||
const model = input.model.trim();
|
||||
if (!model) throw new Error("A model is required.");
|
||||
assertModelSelectable(provider.provider_key, model, provider.model);
|
||||
const owner = { ...provider, model };
|
||||
const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key);
|
||||
const problem = await this.probeModel({ orgId: input.orgId, owner, workspaceId: null, agent: null, taskRunId: null, turn });
|
||||
return problem === null ? { passed: true } : { passed: false, detail: problem };
|
||||
}
|
||||
|
||||
private async callTurn(caller: TurnCaller, request: { instructions: string | null; messages: AgentTurnMessage[]; tools: ToolDefinition[] }): Promise<AgentTurnResult & { requestId: string }> {
|
||||
const { orgId, owner, workspaceId, agent, taskRunId, turn } = caller;
|
||||
const model = owner.model;
|
||||
// Every request, a probe or the work, passes the provider's policy and quota and the agent's budget.
|
||||
this.enforcePolicy(orgId, owner, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId);
|
||||
const blocked = agent ? budgetBlock(this.deps.db, agent.id, isPriced(providerPrices(this.deps.db, owner.id)), new Date()) : null;
|
||||
if (blocked) throw new Error(blocked);
|
||||
// Everything the model reads: instructions, the conversation, and the tool definitions.
|
||||
const turnInputChars = (input.instructions?.length ?? 0) + JSON.stringify(input.messages).length + JSON.stringify(input.tools).length;
|
||||
const turnInputChars = (request.instructions?.length ?? 0) + JSON.stringify(request.messages).length + JSON.stringify(request.tools).length;
|
||||
const requestId = this.deps.requestStore.create({
|
||||
agentId: agent?.id ?? null,
|
||||
taskRunId,
|
||||
department: agent?.department ?? null,
|
||||
orgId: input.orgId,
|
||||
orgId,
|
||||
provider: owner,
|
||||
model,
|
||||
workspaceId,
|
||||
promptSha256: hashPrompt(JSON.stringify(input.messages)),
|
||||
promptSha256: hashPrompt(JSON.stringify(request.messages)),
|
||||
inputCharCount: turnInputChars,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId: input.orgId, provider });
|
||||
const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools });
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, input.orgId, owner, model, result.rateLimitState ?? null);
|
||||
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, integrationKeys: this.deps.integrationKeys, orgId, provider: owner });
|
||||
const result = await turn({ credential, model, instructions: request.instructions, messages: request.messages, tools: request.tools });
|
||||
recordObservedRateLimit(this.deps.rateLimitRegistry, orgId, owner, model, result.rateLimitState ?? null);
|
||||
// Tool calls are output the model wrote, as much as its text.
|
||||
const outputChars = result.text.length + (result.toolCalls.length > 0 ? JSON.stringify(result.toolCalls).length : 0);
|
||||
this.deps.requestStore.markSucceeded({
|
||||
orgId: input.orgId,
|
||||
orgId,
|
||||
requestId,
|
||||
responseText: result.text,
|
||||
rawResponseJson: result.rawResponseJson,
|
||||
@@ -168,7 +204,7 @@ export class ProviderResponseRunner {
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
this.deps.responseObserver.recordSuccess({
|
||||
orgId: input.orgId,
|
||||
orgId,
|
||||
provider: owner,
|
||||
requestId,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
@@ -176,13 +212,13 @@ export class ProviderResponseRunner {
|
||||
action: "response_created",
|
||||
});
|
||||
this.deps.requestStore.settleUsage({
|
||||
orgId: input.orgId, requestId, provider: owner, rawResponseJson: result.rawResponseJson,
|
||||
orgId, requestId, provider: owner, rawResponseJson: result.rawResponseJson,
|
||||
inputCharCount: turnInputChars, outputCharCount: outputChars,
|
||||
});
|
||||
return { ...result, requestId };
|
||||
} catch (error) {
|
||||
this.deps.responseObserver.recordFailure({
|
||||
orgId: input.orgId,
|
||||
orgId,
|
||||
provider: owner,
|
||||
requestId,
|
||||
executionMode: PROVIDER_EXECUTION_MODE.FOREGROUND,
|
||||
@@ -391,3 +427,17 @@ interface PreparedProviderExecution {
|
||||
adapter: ProviderAdapterRegistry[string];
|
||||
credential: { secret: string };
|
||||
}
|
||||
|
||||
interface TurnCaller {
|
||||
orgId: string;
|
||||
owner: ProviderRow & { model: string };
|
||||
workspaceId: string | null;
|
||||
agent: { id: string; department: string } | null;
|
||||
taskRunId: string | null;
|
||||
turn: ToolTurnFn;
|
||||
}
|
||||
|
||||
function requireCredentialId(provider: ProviderRow): string {
|
||||
if (!provider.credential_id) throw new Error("Provider credential is required.");
|
||||
return provider.credential_id;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ProviderResponseRunner, type ProviderResponseCreateInput } from "./prov
|
||||
import { launchProviderSignInWindow, type LaunchProviderSignInWindowInput } from "./provider-sign-in-window";
|
||||
import type { ToolTurnFn } from "./provider-tool-turn";
|
||||
import { stopRun } from "./run-control";
|
||||
import type { ModelCheckResult } from "../../shared/desktop-api";
|
||||
import {
|
||||
AUTH_STATE_CONFIGURED,
|
||||
AUTH_STATE_REVOKED,
|
||||
@@ -194,6 +195,10 @@ export class ProviderService {
|
||||
return adapter.listModels({ authProfile, model: provider.model });
|
||||
}
|
||||
|
||||
checkModel(input: { orgId: string; providerId: string; model: string }): Promise<ModelCheckResult> {
|
||||
return this.runner.checkModel(input);
|
||||
}
|
||||
|
||||
async checkHealth(orgId: string, providerId: string): Promise<void> {
|
||||
const provider = loadConfiguredProvider(this.db, orgId, providerId);
|
||||
const adapter = requireAdapter(this.adapters, provider.provider_key, provider.auth_type);
|
||||
|
||||
Reference in new issue
Block a user