Q99: connector actions keep an execution record so a lost reply is not resent
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
552dfcb6ce
commit
6f8731cbac
7 files changed
+135
-5
No files matched your search
@@ -29,6 +29,13 @@ export interface ComposioConnectedAccount {
|
||||
/** Composio answers 200 for business failures too; only `successful` tells them apart. */
|
||||
export type ComposioToolExecutionResult = { successful: true; data: unknown } | { successful: false; error: string };
|
||||
|
||||
/** Composio answered with an HTTP error status; below 500 the request was refused, so nothing ran. */
|
||||
export class ComposioHttpError extends Error {
|
||||
constructor(message: string, readonly status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_BYTES = 2_000_000;
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const EXECUTE_TIMEOUT_MS = 120_000;
|
||||
@@ -204,7 +211,7 @@ export class ComposioClient {
|
||||
return;
|
||||
}
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
finish(new Error(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`));
|
||||
finish(new ComposioHttpError(`Composio API ${method} ${url} returned ${response.statusCode}: ${responseData.slice(0, 200)}`, response.statusCode));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ToolDefinition, ToolCall } from "../provider-tool-turn";
|
||||
import { ComposioClient, type ComposioToolSchema } from "./composio-client";
|
||||
import { ComposioClient, ComposioHttpError, type ComposioToolExecutionResult, type ComposioToolSchema } from "./composio-client";
|
||||
import { beginConnectorExecution, settleConnectorExecution } from "./connector-executions";
|
||||
import type { IntegrationKeyStore } from "../integration-key-store";
|
||||
import { ConnectorGrantsService } from "./connector-grants";
|
||||
import { currentRunId } from "../run-control";
|
||||
@@ -48,6 +49,7 @@ export function loadComposioTools(db: DatabaseSync, keys: IntegrationKeyStore, o
|
||||
properties: {
|
||||
action: { type: "string", description: "The action's name exactly as found, e.g. GITHUB_CREATE_A_PULL_REQUEST." },
|
||||
arguments: { type: "object", description: "The action's input, following the schema the search returned." },
|
||||
confirm_not_done: { type: "boolean", description: "Only after an identical call's reply was lost and you checked in the service that it did not take effect." },
|
||||
},
|
||||
required: ["action", "arguments"],
|
||||
},
|
||||
@@ -113,11 +115,31 @@ async function runAction(db: DatabaseSync, apiKey: string, orgId: string, agentI
|
||||
if (!account) return `ERROR: The "${toolkit}" connector is no longer connected. Ask the board to reconnect it on the Connectors page.`;
|
||||
const input = typeof args.arguments === "object" && args.arguments !== null && !Array.isArray(args.arguments) ? args.arguments as Record<string, unknown> : null;
|
||||
if (!input) return "ERROR: arguments must be an object matching the action's input.";
|
||||
const result = await new ComposioClient(apiKey).executeTool({ toolName: action.name, arguments: input, userId: getComposioUserId(db, orgId), connectedAccountId: account.id });
|
||||
if (args.confirm_not_done !== undefined && typeof args.confirm_not_done !== "boolean") return "ERROR: confirm_not_done must be true or false.";
|
||||
const started = beginConnectorExecution(db, { orgId, agentId, runId: currentRunId(), action: action.name, args: input, confirmedNotDone: args.confirm_not_done === true });
|
||||
if ("blockedBy" in started) {
|
||||
const lost = started.blockedBy.detail ? `: ${started.blockedBy.detail}` : "";
|
||||
return `ERROR: An identical ${action.name} call at ${started.blockedBy.createdAt} may already have run; its reply was lost${lost}. Check in ${toolkit} whether it took effect. If it did not, call again with confirm_not_done: true.`;
|
||||
}
|
||||
let result: ComposioToolExecutionResult;
|
||||
try {
|
||||
result = await new ComposioClient(apiKey).executeTool({ toolName: action.name, arguments: input, userId: getComposioUserId(db, orgId), connectedAccountId: account.id });
|
||||
} catch (error) {
|
||||
const reason = (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").replace(/\.$/, "");
|
||||
if (error instanceof ComposioHttpError && error.status < 500) {
|
||||
settleConnectorExecution(db, started.id, "failed", reason);
|
||||
throw error;
|
||||
}
|
||||
settleConnectorExecution(db, started.id, "unknown", reason);
|
||||
console.warn(`[connectors] ${action.name} outcome unknown: ${reason}`);
|
||||
return `ERROR: ${action.name} may or may not have run; its reply was lost (${reason}). Check in ${toolkit} whether it took effect before calling it again.`;
|
||||
}
|
||||
if (!result.successful) {
|
||||
settleConnectorExecution(db, started.id, "failed", result.error);
|
||||
console.warn(`[connectors] ${action.name} failed: ${result.error.replace(/\s+/g, " ")}`);
|
||||
return `ERROR: ${action.name} failed: ${result.error}`;
|
||||
}
|
||||
settleConnectorExecution(db, started.id, "succeeded", null);
|
||||
return JSON.stringify(result.data);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { createId } from "../../../shared/ids";
|
||||
|
||||
/** How an action run ended: unknown means it was sent and its reply was lost, so it may have taken effect. */
|
||||
export type ConnectorExecutionOutcome = "succeeded" | "failed" | "unknown";
|
||||
|
||||
/** Records an action run before it is sent; an identical call whose outcome is pending or unknown blocks it until the agent confirms it did not happen. */
|
||||
export function beginConnectorExecution(
|
||||
db: DatabaseSync,
|
||||
input: { orgId: string; agentId: string; runId: string | null; action: string; args: Record<string, unknown>; confirmedNotDone: boolean },
|
||||
): { id: string } | { blockedBy: { createdAt: string; detail: string | null } } {
|
||||
const hash = createHash("sha256").update(canonicalJson(input.args)).digest("hex");
|
||||
const open = db.prepare(`
|
||||
select created_at, detail from connector_executions
|
||||
where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown') order by rowid desc limit 1
|
||||
`).get(input.orgId, input.action, hash) as { created_at: string; detail: string | null } | undefined;
|
||||
const now = new Date().toISOString();
|
||||
if (open && !input.confirmedNotDone) return { blockedBy: { createdAt: open.created_at, detail: open.detail } };
|
||||
if (open) {
|
||||
db.prepare("update connector_executions set state = 'resolved', updated_at = ? where org_id = ? and action = ? and arguments_hash = ? and state in ('pending', 'unknown')")
|
||||
.run(now, input.orgId, input.action, hash);
|
||||
}
|
||||
const id = createId("cex");
|
||||
db.prepare(`
|
||||
insert into connector_executions(id, org_id, agent_id, run_id, action, arguments_hash, state, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, 'pending', ?, ?)
|
||||
`).run(id, input.orgId, input.agentId, input.runId, input.action, hash, now, now);
|
||||
return { id };
|
||||
}
|
||||
|
||||
export function settleConnectorExecution(db: DatabaseSync, id: string, outcome: ConnectorExecutionOutcome, detail: string | null): void {
|
||||
db.prepare("update connector_executions set state = ?, detail = ?, updated_at = ? where id = ? and state = 'pending'")
|
||||
.run(outcome, detail, new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
/** JSON with object keys sorted at every level, so the same arguments always hash the same. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
if (value && typeof value === "object") {
|
||||
const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`);
|
||||
return `{${entries.join(",")}}`;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
Reference in new issue
Block a user