Q95: a run names the capabilities it could not load

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 15:55:33 -04:00
1 parent d92bdbad90
commit f6447319a3
10 files changed
+118 -11

No files matched your search

@@ -0,0 +1,6 @@
export const runCapabilityWarningsSchema = `
alter table task_runs add column capability_warnings text;
insert into schema_migrations(version, name, applied_at)
values (80, 'run_capability_warnings', datetime('now'));
`;
+2
View File
@@ -78,6 +78,7 @@ import { runPinsSchema } from "./migrations/076-run-pins";
import { modelChecksSchema } from "./migrations/077-model-checks";
import { requestErrorCodesSchema } from "./migrations/078-request-error-codes";
import { eraseRetiredCredentialsSchema } from "./migrations/079-erase-retired-credentials";
import { runCapabilityWarningsSchema } from "./migrations/080-run-capability-warnings";
interface Migration {
version: number;
@@ -165,6 +166,7 @@ const MIGRATIONS: Migration[] = [
{ version: 77, name: "model_checks", sql: modelChecksSchema },
{ version: 78, name: "request_error_codes", sql: requestErrorCodesSchema },
{ version: 79, name: "erase_retired_credentials", sql: eraseRetiredCredentialsSchema },
{ version: 80, name: "run_capability_warnings", sql: runCapabilityWarningsSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -3,6 +3,7 @@ import type { ToolDefinition, ToolCall } from "../provider-tool-turn";
import { ComposioClient, type ComposioToolSchema } from "./composio-client";
import type { IntegrationKeyStore } from "../integration-key-store";
import { ConnectorGrantsService } from "./connector-grants";
import { currentRunId } from "../run-control";
const COMPOSIO_TOOL_PREFIX = "composio_";
export const FIND_ACTIONS_TOOL = "composio_find_actions";
@@ -66,7 +67,7 @@ export async function executeComposioTool(
const toolkits = new ConnectorGrantsService(db).toolkitsForAgent(orgId, agentId);
if (toolkits.length === 0) return "ERROR: You hold no connector grants. Ask the CEO to grant you one (grant_connector).";
try {
if (call.name === FIND_ACTIONS_TOOL) return await findActions(apiKey, toolkits, call.arguments);
if (call.name === FIND_ACTIONS_TOOL) return await findActions(db, apiKey, toolkits, call.arguments);
if (call.name === RUN_ACTION_TOOL) return await runAction(db, apiKey, orgId, agentId, toolkits, call.arguments);
return `ERROR: ${call.name} is not a connector tool; use ${FIND_ACTIONS_TOOL} and ${RUN_ACTION_TOOL}.`;
} catch (error) {
@@ -74,12 +75,12 @@ export async function executeComposioTool(
}
}
async function findActions(apiKey: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
async function findActions(db: DatabaseSync, apiKey: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
const query = typeof args.query === "string" ? args.query.trim().toLowerCase() : "";
if (!query) return "ERROR: query is required: say in a few words what you want to do.";
const toolkit = typeof args.toolkit === "string" ? args.toolkit.toLowerCase() : null;
if (toolkit && !toolkits.includes(toolkit)) return `ERROR: You hold no grant for "${toolkit}". Your services: ${toolkits.join(", ")}.`;
const actions = (await actionCatalog(apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit);
const actions = (await actionCatalog(db, apiKey, toolkits)).filter((action) => !toolkit || action.toolkitSlug?.toLowerCase() === toolkit);
const words = query.split(/[^a-z0-9]+/).filter(Boolean);
const scored = actions
.map((action) => {
@@ -99,7 +100,7 @@ async function findActions(apiKey: string, toolkits: string[], args: Record<stri
async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentId: string, toolkits: string[], args: Record<string, unknown>): Promise<string> {
const name = typeof args.action === "string" ? args.action.trim().toUpperCase() : "";
if (!name) return `ERROR: action is required; find one with ${FIND_ACTIONS_TOOL}.`;
const action = (await actionCatalog(apiKey, toolkits)).find((candidate) => candidate.name.toUpperCase() === name);
const action = (await actionCatalog(db, apiKey, toolkits)).find((candidate) => candidate.name.toUpperCase() === name);
if (!action?.toolkitSlug) return `ERROR: ${name} is not an action of your connected services (${toolkits.join(", ")}); find one with ${FIND_ACTIONS_TOOL}.`;
const toolkit = action.toolkitSlug.toLowerCase();
// Grants are read again after the catalog load: one revoked meanwhile must stop the action.
@@ -117,14 +118,33 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI
return JSON.stringify(result.data);
}
/** The full action list of the granted toolkits, every page of it; a failed listing is an error the agent sees. */
async function actionCatalog(apiKey: string, toolkits: string[]): Promise<ComposioToolSchema[]> {
/** The full action list of the granted toolkits, every page of it; a failed listing reaches the agent and is named on the run. */
async function actionCatalog(db: DatabaseSync, apiKey: string, toolkits: string[]): Promise<ComposioToolSchema[]> {
const cacheKey = `${apiKey}:${[...toolkits].sort().join(",")}`;
const cached = catalogCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) return cached.actions;
const actions = await new ComposioClient(apiKey).getTools(toolkits);
catalogCache.set(cacheKey, { actions, fetchedAt: Date.now() });
return actions;
try {
const actions = await new ComposioClient(apiKey).getTools(toolkits);
catalogCache.set(cacheKey, { actions, fetchedAt: Date.now() });
return actions;
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
noteMissingCapability(db, `The actions of ${toolkits.join(", ")} could not be listed: ${reason}`);
throw error;
}
}
/** Names a capability the current run went without, once, on the run the user looks at; also logged. */
function noteMissingCapability(db: DatabaseSync, text: string): void {
// One line per gap: error bodies (JSON, HTML) arrive with their own line breaks.
const warning = text.replace(/\s+/g, " ").trim();
console.warn(`[connectors] ${warning}`);
const runId = currentRunId();
if (!runId) return;
const row = db.prepare("select capability_warnings from task_runs where id = ?").get(runId) as { capability_warnings: string | null } | undefined;
const lines = row?.capability_warnings ? row.capability_warnings.split("\n") : [];
if (lines.includes(warning)) return;
db.prepare("update task_runs set capability_warnings = ? where id = ?").run([...lines, warning].join("\n"), runId);
}
export function getComposioUserId(db: DatabaseSync, orgId: string): string {
+1
View File
@@ -32,6 +32,7 @@ export const zh: Record<string, string> = {
"superseded": "已被取代",
"stalled": "已停滞",
"Resume": "恢复",
"Missing this run": "本次运行缺少的能力",
"Disconnect": "断开连接",
"Revokes the sign-in at {provider} and erases it from this computer.": "在 {provider} 撤销登录授权,并从本机删除。",
"Erases the key from this computer. {provider} keeps accepting it until you revoke it in your account.": "仅从本机删除密钥。在你于 {provider} 账号中撤销之前,该密钥仍然有效。",
+7
View File
@@ -160,6 +160,13 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions }:
</div>
)}
{run.capabilityWarnings && (
<div className="card" style={{ padding: 16, marginBottom: 16, borderColor: "var(--amber)" }}>
<div className="section-label" style={{ color: "var(--amber)", marginBottom: 8 }}>{t("Missing this run")}</div>
<div style={{ fontSize: 14, color: "var(--text-1)", whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{run.capabilityWarnings}</div>
</div>
)}
{request && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="section-label" style={{ marginBottom: 8 }}>{t("Provider request")}</div>
@@ -169,6 +169,9 @@ function OverviewTab({ task, assignee, project, goal, workspace, provider, runs
{run.state === "failed" && run.errorMessage && (
<span style={{ color: "var(--red)", fontSize: 12, paddingLeft: 4 }}>{run.errorMessage}</span>
)}
{run.capabilityWarnings && (
<span style={{ color: "var(--amber)", fontSize: 12, paddingLeft: 4, whiteSpace: "pre-wrap" }}>{t("Missing this run")}: {run.capabilityWarnings}</span>
)}
</div>
))}
</div>
+2
View File
@@ -163,6 +163,8 @@ export interface TaskRunRecord {
errorMessage: string | null;
/** JSON RunContextManifest: which messages the run read. */
contextJson: string | null;
/** Capabilities the run could not load, one per line, so the user sees what it worked without. */
capabilityWarnings: string | null;
}
export interface ApprovalRecord {
+66
View File
@@ -0,0 +1,66 @@
import { EventEmitter } from "node:events";
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test, vi } from "vitest";
vi.mock("electron", () => ({
net: {
request: () => {
const request = new EventEmitter() as EventEmitter & { setHeader: () => void; write: () => void; end: () => void; abort: () => void };
request.setHeader = () => undefined;
request.write = () => undefined;
request.abort = () => undefined;
request.end = () => {
const response = new EventEmitter() as EventEmitter & { statusCode: number };
response.statusCode = 503;
request.emit("response", response);
response.emit("data", Buffer.from(JSON.stringify({ error: "Service Unavailable", detail: "busy" }, null, 2)));
response.emit("end");
};
return request;
},
},
}));
const { DatabaseConnection } = await import("../../src/main/database/connection");
const { OnboardingService } = await import("../../src/main/services/onboarding-service");
const { ProviderService } = await import("../../src/main/services/provider-service");
const { providerAdapters } = await import("../../src/main/services/provider-adapters");
const { IntegrationKeyStore } = await import("../../src/main/services/integration-key-store");
const { claimNextQueuedRun } = await import("../../src/main/app/task-queue-worker");
const { executeComposioTool } = await import("../../src/main/services/composio/composio-tool-bridge");
const { withRunControl } = await import("../../src/main/services/run-control");
const vault = { encryptSecret: (secret: string) => Buffer.from(secret, "utf8"), decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8") };
describe("a run tells the user which capabilities it lacked", () => {
test("a connector whose actions cannot be listed is named on the run, as well as told to the agent", async () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-capability-${randomUUID()}.db`));
const providers = new ProviderService(connection.db, providerAdapters, vault);
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
let orgId = "";
let ceoId = "";
connection.transaction(() => {
const summary = onboarding.bootstrap({
companyName: "FlowPilot", companyDescription: "d", employeeName: "Yang", workspacePath: "", executionMode: "ai_ceo", autoApproveHires: true,
reviewDeliverables: false, goalTitle: "Ship.", goalDescription: "d", providerKey: "openai", providerSecret: "sk-test", providerModel: "gpt-5.4",
});
orgId = summary.orgId;
ceoId = summary.ceoAgentId;
const now = new Date().toISOString();
connection.db.prepare("insert into connector_accounts(id, org_id, toolkit_slug, toolkit_name, auth_config_id, status, created_at, updated_at) values ('ca_gh', ?, 'github', 'GitHub', 'ac_gh', 'ACTIVE', ?, ?)").run(orgId, now, now);
});
const keys = new IntegrationKeyStore(connection.db, vault);
keys.write("composio", "ak_capability");
const run = claimNextQueuedRun(connection)!;
const output = await withRunControl(run.id, () => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c1", name: "composio_find_actions", arguments: { query: "create pull request" } }), 60_000);
expect(output).toMatch(/^ERROR: .*503/);
const warnings = (connection.db.prepare("select capability_warnings from task_runs where id = ?").get(run.id) as { capability_warnings: string }).capability_warnings;
expect(warnings).toMatch(/^The actions of github could not be listed: .*503/);
expect(warnings.split("\n")).toHaveLength(1);
await withRunControl(run.id, () => executeComposioTool(connection.db, keys, orgId, ceoId, { id: "c2", name: "composio_find_actions", arguments: { query: "again" } }), 60_000);
expect((connection.db.prepare("select capability_warnings from task_runs where id = ?").get(run.id) as { capability_warnings: string }).capability_warnings).toBe(warnings);
connection.close();
});
});
+1 -1
View File
@@ -24,7 +24,7 @@ describe("domain services", () => {
const connection = createConnection();
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]);
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80]);
connection.close();
});
+1 -1
View File
@@ -6,7 +6,7 @@ function run(agentId: string | null, contextJson: string | null): TaskRunRecord
return {
id: `run_${Math.random()}`, orgId: "org", taskId: "tsk", agentId, providerId: null, providerRequestId: null,
executionContextId: null, serviceIdentityId: null, workspaceMountJson: "[]", secretScopeJson: "{}",
trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson,
trigger: "chat_mention", state: "completed", createdAt: "", startedAt: null, completedAt: null, errorMessage: null, contextJson, capabilityWarnings: null,
};
}