History

Adopt typed tool-call control signals and cleanup runtime wrappers

Rhys Sullivan committed 8 months ago

Commit b5a42d61 parent 501720b11 files · +276 −80Browse files

Unified · Split

11 files
  1. executor/packages/core/src/adapters/in/process/execution-adapter.test.ts +5 −2
  2. executor/packages/core/src/adapters/in/process/execution-adapter.ts +8 −6
  3. executor/packages/core/src/runtimes/runtime-core.ts +9 −4
  4. executor/packages/core/src/tool-call-control.ts +42 −0
  5. executor/packages/database/src/executor/handlers.ts +70 −19
  6. executor/packages/database/src/runtime/external_tool_call.ts +13 −10
  7. executor/packages/database/src/runtime/task_runner.ts +19 −7
  8. executor/packages/database/src/runtime/tool_call_credentials.ts +5 −2
  9. executor/packages/database/src/runtime/tool_call_lifecycle.ts +5 −2
  10. executor/packages/database/src/runtime/tool_call_resolution.ts +37 −6
  11. executor/packages/database/src/runtime/tool_invocation.ts +63 −22
executor/packages/core/src/adapters/in/process/execution-adapter.test.ts+5−2
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2−import { APPROVAL_DENIED_PREFIX } from "../../../execution-constants";
2+import { ToolCallControlError } from "../../../tool-call-control";
33 import { InProcessExecutionAdapter } from "./execution-adapter";
44
55 test("returns run mismatch without invoking tool", async () => {
@@ -30,7 +30,10 @@ test("maps approval denied errors to denied result", async () => { · 24 unmodified lines
3030 const adapter = new InProcessExecutionAdapter({
3131 runId: "run_1",
3232 invokeTool: async () => {
33− throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`);
33+ throw new ToolCallControlError({
34+ kind: "approval_denied",
35+ reason: "approval required",
36+ });
3437 },
3538 });
3639
executor/packages/core/src/adapters/in/process/execution-adapter.ts+8−6
@@ -1,4 +1,4 @@
1−import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../execution-constants";
1+import { decodeToolCallControlSignal } from "../../../tool-call-control";
22 import type {
33 ExecutionAdapter,
44 ToolCallRequest,
@@ -27,25 +27,27 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 22 unmodified lines
2727 const value = await this.options.invokeTool(call);
2828 return { ok: true, value };
2929 } catch (error) {
30− const message = describeError(error);
31− if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
30+ const controlSignal = decodeToolCallControlSignal(error);
31+ if (controlSignal?.kind === "approval_denied") {
3232 return {
3333 ok: false,
3434 kind: "denied",
35− error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
35+ error: controlSignal.reason,
3636 };
3737 }
3838
39− if (message.startsWith(APPROVAL_PENDING_PREFIX)) {
39+ if (controlSignal?.kind === "approval_pending") {
4040 return {
4141 ok: false,
4242 kind: "pending",
43− approvalId: message.replace(APPROVAL_PENDING_PREFIX, "").trim(),
43+ approvalId: controlSignal.approvalId,
4444 retryAfterMs: 500,
4545 error: "Approval pending",
4646 };
4747 }
4848
49+ const message = describeError(error);
50+
4951 return {
5052 ok: false,
5153 kind: "failed",
executor/packages/core/src/runtimes/runtime-core.ts+9−4
@@ -4,9 +4,10 @@ · 3 unmodified lines
44 // string version of similar logic, built from sandbox-fragments.ts. Changes to
55 // the core helpers here (createToolsProxy, console proxy, execution
66 // loop, result mapping) should be mirrored there.
7−import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";
7+import { TASK_TIMEOUT_MARKER } from "../execution-constants";
88 import { Result } from "better-result";
99 import { Script, createContext } from "node:vm";
10+import { decodeToolCallControlSignal, ToolCallControlError } from "../tool-call-control";
1011 import type {
1112 ExecutionAdapter,
1213 SandboxExecutionRequest,
@@ -91,7 +92,10 @@ function createToolsProxy( · 78 unmodified lines
9192 retryDelayMs = clampRetryDelayMs(retryDelayMs * RETRY_BACKOFF_MULTIPLIER);
9293 continue;
9394 case "denied":
94− throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`);
95+ throw new ToolCallControlError({
96+ kind: "approval_denied",
97+ reason: result.error,
98+ });
9599 case "failed":
96100 default:
97101 throw new Error(result.error);
@@ -115,10 +119,11 @@ function classifyExecutionError( · 17 unmodified lines
115119 };
116120 }
117121
118− if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
122+ const controlSignal = decodeToolCallControlSignal(error);
123+ if (controlSignal?.kind === "approval_denied") {
119124 return {
120125 status: "denied",
121− message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
126+ message: controlSignal.reason,
122127 };
123128 }
124129
executor/packages/core/src/tool-call-control.ts+42−0
@@ -0,0 +1,42 @@
1+import {
2+ APPROVAL_DENIED_PREFIX,
3+ APPROVAL_PENDING_PREFIX,
4+} from "./execution-constants";
5+
6+export type ToolCallControlSignal =
7+ | { kind: "approval_pending"; approvalId: string }
8+ | { kind: "approval_denied"; reason: string };
9+
10+export function encodeToolCallControlSignal(signal: ToolCallControlSignal): string {
11+ if (signal.kind === "approval_pending") {
12+ return `${APPROVAL_PENDING_PREFIX}${signal.approvalId}`;
13+ }
14+
15+ return `${APPROVAL_DENIED_PREFIX}${signal.reason}`;
16+}
17+
18+export function decodeToolCallControlSignal(value: unknown): ToolCallControlSignal | null {
19+ const message = value instanceof Error ? value.message : String(value ?? "");
20+
21+ if (message.startsWith(APPROVAL_PENDING_PREFIX)) {
22+ const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim();
23+ return { kind: "approval_pending", approvalId };
24+ }
25+
26+ if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
27+ const reason = message.replace(APPROVAL_DENIED_PREFIX, "").trim();
28+ return { kind: "approval_denied", reason };
29+ }
30+
31+ return null;
32+}
33+
34+export class ToolCallControlError extends Error {
35+ readonly signal: ToolCallControlSignal;
36+
37+ constructor(signal: ToolCallControlSignal) {
38+ super(encodeToolCallControlSignal(signal));
39+ this.name = "ToolCallControlError";
40+ this.signal = signal;
41+ }
42+}
executor/packages/database/src/executor/handlers.ts+70−19
@@ -19,6 +19,61 @@ type TaskCreateContext = Pick<MutationCtx, "runMutation"> & { · 18 unmodified lines
1919 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];
2020 };
2121
22+async function createTaskDocument(
23+ ctx: TaskCreateContext,
24+ internal: Internal,
25+ args: {
26+ id: string;
27+ code: string;
28+ runtimeId: string;
29+ timeoutMs: number;
30+ metadata?: unknown;
31+ workspaceId: Id<"workspaces">;
32+ actorId: string;
33+ clientId?: string;
34+ },
35+): Promise<TaskRecord> {
36+ return await ctx.runMutation(internal.database.createTask, args) as TaskRecord;
37+}
38+
39+async function resolveApprovalDocument(
40+ ctx: MutationCtx,
41+ internal: Internal,
42+ args: {
43+ approvalId: string;
44+ decision: "approved" | "denied";
45+ reviewerId?: string;
46+ reason?: string;
47+ },
48+): Promise<ApprovalRecord | null> {
49+ return await ctx.runMutation(internal.database.resolveApproval, args) as ApprovalRecord | null;
50+}
51+
52+async function getTaskById(
53+ ctx: Pick<MutationCtx, "runQuery">,
54+ internal: Internal,
55+ taskId: string,
56+): Promise<TaskRecord | null> {
57+ return await ctx.runQuery(internal.database.getTask, { taskId }) as TaskRecord | null;
58+}
59+
60+async function createTaskInternal(
61+ ctx: Pick<ActionCtx, "runMutation">,
62+ internal: Internal,
63+ args: {
64+ code: string;
65+ timeoutMs?: number;
66+ runtimeId?: string;
67+ metadata?: unknown;
68+ workspaceId: Id<"workspaces">;
69+ actorId: string;
70+ clientId?: string;
71+ scheduleAfterCreate?: boolean;
72+ },
73+): Promise<{ task: TaskRecord }> {
74+ return await ctx.runMutation(internal.executor.createTaskInternal, args) as { task: TaskRecord };
75+}
76+
2277 async function createTaskRecord(
2378 ctx: TaskCreateContext,
2479 internal: Internal,
@@ -46,7 +101,7 @@ async function createTaskRecord( · 21 unmodified lines
46101 }
47102
48103 const taskId = `task_${crypto.randomUUID()}`;
49− const task = (await ctx.runMutation(internal.database.createTask, {
104+ const task = await createTaskDocument(ctx, internal, {
50105 id: taskId,
51106 code: args.code,
52107 runtimeId,
@@ -55,7 +110,7 @@ async function createTaskRecord( · 2 unmodified lines
55110 workspaceId: args.workspaceId,
56111 actorId: args.actorId,
57112 clientId: args.clientId,
58− })) as TaskRecord;
113+ });
59114
60115 await createTaskEvent(ctx, {
61116 taskId,
@@ -113,12 +168,12 @@ async function resolveApprovalRecord( · 51 unmodified lines
113168 return null;
114169 }
115170
116− const approval = (await ctx.runMutation(internal.database.resolveApproval, {
171+ const approval = await resolveApprovalDocument(ctx, internal, {
117172 approvalId: args.approvalId,
118173 decision: args.decision,
119174 reviewerId: args.reviewerId,
120175 reason: args.reason,
121− })) as ApprovalRecord | null;
176+ });
122177 if (!approval) {
123178 return null;
124179 }
@@ -138,9 +193,7 @@ async function resolveApprovalRecord( · 13 unmodified lines
138193 },
139194 });
140195
141− const task = (await ctx.runQuery(internal.database.getTask, {
142− taskId: approval.taskId,
143− })) as TaskRecord | null;
196+ const task = await getTaskById(ctx, internal, approval.taskId);
144197 if (!task) {
145198 throw new Error(`Task ${approval.taskId} missing while resolving approval`);
146199 }
@@ -172,7 +225,7 @@ export async function createTaskHandler( · 25 unmodified lines
172225 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
173226
174227 const waitForResult = args.waitForResult ?? false;
175− const created = await ctx.runMutation(internal.executor.createTaskInternal, {
228+ const created = await createTaskInternal(ctx, internal, {
176229 code: args.code,
177230 timeoutMs: args.timeoutMs,
178231 runtimeId: args.runtimeId,
@@ -184,7 +237,7 @@ export async function createTaskHandler( · 5 unmodified lines
184237 });
185238
186239 if (!waitForResult) {
187− return { task: created.task as TaskRecord };
240+ return { task: created.task };
188241 }
189242
190243 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {
@@ -225,7 +278,10 @@ export async function createTaskInternalHandler( · 34 unmodified lines
225278 }
226279
227280 export async function resolveApprovalHandler(
228− ctx: unknown,
281+ ctx: MutationCtx & {
282+ account: { _id: string; provider: string; providerAccountId: string };
283+ workspaceId: Id<"workspaces">;
284+ },
229285 internal: Internal,
230286 args: {
231287 approvalId: string;
@@ -234,17 +290,12 @@ export async function resolveApprovalHandler( · 2 unmodified lines
234290 reason?: string;
235291 },
236292 ): Promise<{ approval: ApprovalRecord; task: TaskRecord } | null> {
237− const typedCtx = ctx as MutationCtx & {
238− account: { _id: string; provider: string; providerAccountId: string };
239− workspaceId: Id<"workspaces">;
240− };
241−
242− const canonicalActorId = actorIdForAccount(typedCtx.account);
293+ const canonicalActorId = actorIdForAccount(ctx.account);
243294 assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId");
244295
245− return await resolveApprovalRecord(typedCtx, internal, {
296+ return await resolveApprovalRecord(ctx, internal, {
246297 ...args,
247− workspaceId: typedCtx.workspaceId,
298+ workspaceId: ctx.workspaceId,
248299 reviewerId: canonicalActorId,
249300 });
250301 }
@@ -274,7 +325,7 @@ export async function completeRuntimeRunHandler( · 23 unmodified lines
274325 durationMs?: number;
275326 },
276327 ) {
277− const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.runId })) as TaskRecord | null;
328+ const task = await getTaskById(ctx, internal, args.runId);
278329 if (!task) {
279330 return { ok: false as const, error: `Run not found: ${args.runId}` };
280331 }
executor/packages/database/src/runtime/external_tool_call.ts+13−10
@@ -2,11 +2,15 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
5−import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants";
65 import type { TaskRecord, ToolCallResult } from "../../../core/src/types";
6+import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control";
77 import { describeError } from "../../../core/src/utils";
88 import { invokeTool } from "./tool_invocation";
99
10+async function getTaskById(ctx: ActionCtx, taskId: string): Promise<TaskRecord | null> {
11+ return await ctx.runQuery(internal.database.getTask, { taskId }) as TaskRecord | null;
12+}
13+
1014 export async function handleExternalToolCallRequest(
1115 ctx: ActionCtx,
1216 args: {
@@ -16,9 +20,7 @@ export async function handleExternalToolCallRequest( · 3 unmodified lines
1620 input?: unknown;
1721 },
1822 ): Promise<ToolCallResult> {
19− const task = (await ctx.runQuery(internal.database.getTask, {
20− taskId: args.runId,
21− })) as TaskRecord | null;
23+ const task = await getTaskById(ctx, args.runId);
2224 if (!task) {
2325 return {
2426 ok: false,
@@ -36,25 +38,26 @@ export async function handleExternalToolCallRequest( · 11 unmodified lines
3638 });
3739 return { ok: true, value };
3840 } catch (error) {
39− const message = describeError(error);
40− if (message.startsWith(APPROVAL_PENDING_PREFIX)) {
41− const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim();
41+ const controlSignal = decodeToolCallControlSignal(error);
42+ if (controlSignal?.kind === "approval_pending") {
4243 return {
4344 ok: false,
4445 kind: "pending",
45− approvalId,
46+ approvalId: controlSignal.approvalId,
4647 retryAfterMs: 0,
4748 error: "Approval pending",
4849 };
4950 }
50− if (message.startsWith(APPROVAL_DENIED_PREFIX)) {
51+ if (controlSignal?.kind === "approval_denied") {
5152 return {
5253 ok: false,
5354 kind: "denied",
54− error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),
55+ error: controlSignal.reason,
5556 };
5657 }
5758
59+ const message = describeError(error);
60+
5861 return {
5962 ok: false,
6063 kind: "failed",
executor/packages/database/src/runtime/task_runner.ts+19−7
@@ -3,8 +3,8 @@ · 2 unmodified lines
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
55 import { InProcessExecutionAdapter } from "../../../core/src/adapters/in/process/execution-adapter";
6−import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
76 import { dispatchCodeWithCloudflareWorkerLoader } from "../../../core/src/runtimes/cloudflare/worker/loader-runtime";
7+import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control";
88 import {
99 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
1010 isCloudflareWorkerLoaderConfigured,
@@ -19,6 +19,19 @@ import { taskTerminalEventType } from "../task/status"; · 8 unmodified lines
1919 import { markTaskFinished } from "../task/finish";
2020 import { invokeTool } from "./tool_invocation";
2121
22+async function getTaskById(ctx: ActionCtx, taskId: string): Promise<TaskRecord | null> {
23+ return await ctx.runQuery(internal.database.getTask, { taskId }) as TaskRecord | null;
24+}
25+
26+async function markTaskRunning(
27+ ctx: ActionCtx,
28+ taskId: string,
29+): Promise<TaskRecord | null> {
30+ return await ctx.runMutation(internal.database.markTaskRunning, {
31+ taskId,
32+ }) as TaskRecord | null;
33+}
34+
2235 async function markTaskFailedAndPublish(
2336 ctx: ActionCtx,
2437 args: { taskId: string; error: string },
@@ -66,7 +79,7 @@ export async function runQueuedTask( · 41 unmodified lines
6679 ctx: ActionCtx,
6780 args: { taskId: string },
6881 ): Promise<TaskExecutionOutcome | null> {
69− const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.taskId })) as TaskRecord | null;
82+ const task = await getTaskById(ctx, args.taskId);
7083 if (!task || task.status !== "queued") {
7184 return null;
7285 }
@@ -93,9 +106,7 @@ export async function runQueuedTask( · 20 unmodified lines
93106 }
94107
95108 try {
96− const running = (await ctx.runMutation(internal.database.markTaskRunning, {
97− taskId: args.taskId,
98− })) as TaskRecord | null;
109+ const running = await markTaskRunning(ctx, args.taskId);
99110 if (!running) {
100111 return null;
101112 }
@@ -182,11 +193,12 @@ export async function runQueuedTask( · 80 unmodified lines
182193 };
183194 } catch (error) {
184195 const message = describeError(error);
185− const denied = message.startsWith(APPROVAL_DENIED_PREFIX);
196+ const controlSignal = decodeToolCallControlSignal(error);
197+ const denied = controlSignal?.kind === "approval_denied";
186198 const finished = await markTaskFinished(ctx, {
187199 taskId: args.taskId,
188200 status: denied ? "denied" : "failed",
189− error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,
201+ error: denied ? controlSignal.reason : message,
190202 });
191203
192204 if (finished) {
executor/packages/database/src/runtime/tool_call_credentials.ts+5−2
@@ -3,8 +3,8 @@ · 2 unmodified lines
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
55 import { resolveCredentialPayload } from "../../../core/src/credential-providers";
6−import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
76 import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
7+import { ToolCallControlError } from "../../../core/src/tool-call-control";
88 import { asPayload } from "../lib/object";
99
1010 export async function resolveCredentialHeaders(
@@ -72,6 +72,9 @@ export function assertPersistedCallRunnable(persistedCall: ToolCallRecord, callI · 61 unmodified lines
7272 }
7373
7474 if (persistedCall.status === "denied") {
75− throw new Error(`${APPROVAL_DENIED_PREFIX}${persistedCall.error ?? persistedCall.toolPath}`);
75+ throw new ToolCallControlError({
76+ kind: "approval_denied",
77+ reason: persistedCall.error ?? persistedCall.toolPath,
78+ });
7679 }
7780 }
executor/packages/database/src/runtime/tool_call_lifecycle.ts+5−2
@@ -2,8 +2,8 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
5−import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
65 import type { TaskRecord } from "../../../core/src/types";
6+import { ToolCallControlError } from "../../../core/src/tool-call-control";
77 import { publishTaskEvent } from "./events";
88
99 async function finishToolCall(
@@ -42,7 +42,10 @@ export async function denyToolCall( · 32 unmodified lines
4242 ...(args.approvalId ? { approvalId: args.approvalId } : {}),
4343 ...(args.reason ? { reason: args.reason } : {}),
4444 });
45− throw new Error(`${APPROVAL_DENIED_PREFIX}${args.deniedMessage}`);
45+ throw new ToolCallControlError({
46+ kind: "approval_denied",
47+ reason: args.deniedMessage,
48+ });
4649 }
4750
4851 export async function completeToolCall(
executor/packages/database/src/runtime/tool_call_resolution.ts+37−6
@@ -12,6 +12,37 @@ import { getReadyRegistryBuildId } from "./tool_registry_state"; · 11 unmodified lines
1212 import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
1313 import { baseTools } from "./workspace_tools";
1414
15+type RegistrySerializedToolEntry = {
16+ path: string;
17+ serializedToolJson: string;
18+};
19+
20+async function searchRegistryEntries(
21+ ctx: ActionCtx,
22+ args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
23+): Promise<Array<{ preferredPath: string }>> {
24+ return await ctx.runQuery(internal.toolRegistry.searchTools, args) as Array<{ preferredPath: string }>;
25+}
26+
27+async function getRegistryToolByPath(
28+ ctx: ActionCtx,
29+ args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; path: string },
30+): Promise<RegistrySerializedToolEntry | null> {
31+ return await ctx.runQuery(internal.toolRegistry.getToolByPath, args) as RegistrySerializedToolEntry | null;
32+}
33+
34+async function getRegistryToolsByNormalizedPath(
35+ ctx: ActionCtx,
36+ args: {
37+ workspaceId: TaskRecord["workspaceId"];
38+ buildId: string;
39+ normalizedPath: string;
40+ limit: number;
41+ },
42+): Promise<RegistrySerializedToolEntry[]> {
43+ return await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, args) as RegistrySerializedToolEntry[];
44+}
45+
1546 export function getGraphqlDecision(
1647 task: TaskRecord,
1748 tool: ToolDefinition,
@@ -75,12 +106,12 @@ async function suggestFromRegistry( · 57 unmodified lines
75106 toolPath: string,
76107 ): Promise<string[]> {
77108 const term = toolPath.split(".").filter(Boolean).join(" ");
78− const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {
109+ const hits = await searchRegistryEntries(ctx, {
79110 workspaceId,
80111 buildId,
81112 query: term,
82113 limit: 3,
83− }) as Array<{ preferredPath: string }>;
114+ });
84115 return hits.map((hit) => hit.preferredPath);
85116 }
86117
@@ -114,20 +145,20 @@ export async function resolveToolForCall( · 27 unmodified lines
114145 });
115146
116147 let resolvedToolPath = toolPath;
117− let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {
148+ let entry = await getRegistryToolByPath(ctx, {
118149 workspaceId: task.workspaceId,
119150 buildId,
120151 path: toolPath,
121− }) as null | { path: string; serializedToolJson: string };
152+ });
122153
123154 if (!entry) {
124155 const normalized = normalizeToolPathForLookup(toolPath);
125− const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {
156+ const hits = await getRegistryToolsByNormalizedPath(ctx, {
126157 workspaceId: task.workspaceId,
127158 buildId,
128159 normalizedPath: normalized,
129160 limit: 5,
130− }) as Array<{ path: string; serializedToolJson: string }>;
161+ });
131162
132163 if (hits.length > 0) {
133164 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.
executor/packages/database/src/runtime/tool_invocation.ts+63−22
@@ -2,10 +2,6 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
5−import {
6− APPROVAL_DENIED_PREFIX,
7− APPROVAL_PENDING_PREFIX,
8−} from "../../../core/src/execution-constants";
95 import type {
106 AccessPolicyRecord,
117 PolicyDecision,
@@ -17,6 +13,10 @@ import type { · 5 unmodified lines
1713 ToolRunContext,
1814 } from "../../../core/src/types";
1915 import { describeError } from "../../../core/src/utils";
16+import {
17+ decodeToolCallControlSignal,
18+ ToolCallControlError,
19+} from "../../../core/src/tool-call-control";
2020 import { asPayload } from "../lib/object";
2121 import { getToolDecision, getDecisionForContext } from "./policy";
2222 import { baseTools } from "./workspace_tools";
@@ -40,6 +40,45 @@ type RegistryToolEntry = { · 17 unmodified lines
4040 displayOutput?: string;
4141 };
4242
43+async function upsertRequestedToolCall(
44+ ctx: ActionCtx,
45+ args: { taskId: string; callId: string; workspaceId: TaskRecord["workspaceId"]; toolPath: string },
46+): Promise<ToolCallRecord> {
47+ return await ctx.runMutation(internal.database.upsertToolCallRequested, args) as ToolCallRecord;
48+}
49+
50+async function listWorkspaceAccessPolicies(
51+ ctx: ActionCtx,
52+ workspaceId: TaskRecord["workspaceId"],
53+): Promise<AccessPolicyRecord[]> {
54+ return await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId }) as AccessPolicyRecord[];
55+}
56+
57+async function listRegistryNamespaces(
58+ ctx: ActionCtx,
59+ args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; limit: number },
60+): Promise<Array<{ namespace: string; toolCount: number; samplePaths: string[] }>> {
61+ return await ctx.runQuery(internal.toolRegistry.listNamespaces, args) as Array<{
62+ namespace: string;
63+ toolCount: number;
64+ samplePaths: string[];
65+ }>;
66+}
67+
68+async function searchRegistryTools(
69+ ctx: ActionCtx,
70+ args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; query: string; limit: number },
71+): Promise<RegistryToolEntry[]> {
72+ return await ctx.runQuery(internal.toolRegistry.searchTools, args) as RegistryToolEntry[];
73+}
74+
75+async function listRegistryToolsByNamespace(
76+ ctx: ActionCtx,
77+ args: { workspaceId: TaskRecord["workspaceId"]; buildId: string; namespace: string; limit: number },
78+): Promise<RegistryToolEntry[]> {
79+ return await ctx.runQuery(internal.toolRegistry.listToolsByNamespace, args) as RegistryToolEntry[];
80+}
81+
4382 async function denyToolCallForApproval(
4483 ctx: ActionCtx,
4584 args: {
@@ -61,18 +100,17 @@ async function denyToolCallForApproval( · 15 unmodified lines
61100
62101 export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
63102 const { toolPath, input, callId } = call;
64− const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, {
103+ const persistedCall = await upsertRequestedToolCall(ctx, {
65104 taskId: task.id,
66105 callId,
67106 workspaceId: task.workspaceId,
68107 toolPath,
69− })) as ToolCallRecord;
108+ });
70109 assertPersistedCallRunnable(persistedCall, callId);
71110
72111 let effectiveToolPath = toolPath;
73112 try {
74− const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
75− const typedPolicies = policies as AccessPolicyRecord[];
113+ const typedPolicies = await listWorkspaceAccessPolicies(ctx, task.workspaceId);
76114 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
77115 if (persistedCall.status === "requested") {
78116 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
@@ -92,7 +130,6 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 13 unmodified lines
92130
93131 // Fast system tools are handled server-side from the registry.
94132 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
95− const toolRegistry = internal.toolRegistry;
96133 const buildId = await getReadyRegistryBuildId(ctx, {
97134 workspaceId: task.workspaceId,
98135 actorId: task.actorId,
@@ -124,11 +161,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 25 unmodified lines
124161
125162 if (toolPath === "catalog.namespaces") {
126163 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));
127− const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {
164+ const namespaces = await listRegistryNamespaces(ctx, {
128165 workspaceId: task.workspaceId,
129166 buildId,
130167 limit,
131− }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;
168+ });
132169 return await finalizeImmediateTool({ namespaces, total: namespaces.length });
133170 }
134171
@@ -138,14 +175,14 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 3 unmodified lines
138175 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
139176
140177 const raw = query
141− ? await ctx.runQuery(toolRegistry.searchTools, {
178+ ? await searchRegistryTools(ctx, {
142179 workspaceId: task.workspaceId,
143180 buildId,
144181 query,
145182 limit,
146183 })
147184 : namespace
148− ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {
185+ ? await listRegistryToolsByNamespace(ctx, {
149186 workspaceId: task.workspaceId,
150187 buildId,
151188 namespace,
@@ -153,7 +190,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 1 unmodified line
153190 })
154191 : [];
155192
156− const results = (raw as RegistryToolEntry[])
193+ const results = raw
157194 .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
158195 .filter((entry) => isAllowed(entry.path, entry.approval))
159196 .slice(0, limit)
@@ -177,12 +214,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 17 unmodified lines
177214 const query = String(payload.query ?? "").trim();
178215 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
179216 const compact = payload.compact === false ? false : true;
180− const hits = await ctx.runQuery(toolRegistry.searchTools, {
217+ const hits = await searchRegistryTools(ctx, {
181218 workspaceId: task.workspaceId,
182219 buildId,
183220 query,
184221 limit: Math.max(limit * 2, limit),
185− }) as RegistryToolEntry[];
222+ });
186223
187224 const filtered = hits
188225 .filter((entry) => isAllowed(entry.path, entry.approval))
@@ -269,7 +306,10 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 80 unmodified lines
269306 }
270307
271308 if (existingApproval.status === "pending") {
272− throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
309+ throw new ToolCallControlError({
310+ kind: "approval_pending",
311+ approvalId: existingApproval.id,
312+ });
273313 }
274314
275315 if (existingApproval.status === "denied") {
@@ -315,7 +355,10 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 39 unmodified lines
315355 });
316356
317357 if (approval.status === "pending") {
318− throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);
358+ throw new ToolCallControlError({
359+ kind: "approval_pending",
360+ approvalId: approval.id,
361+ });
319362 }
320363
321364 if (approval.status === "denied") {
@@ -346,11 +389,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 24 unmodified lines
346389 return value;
347390 } catch (error) {
348391 const message = describeError(error);
349− const isApprovalControlSignal =
350− message.startsWith(APPROVAL_PENDING_PREFIX) ||
351− message.startsWith(APPROVAL_DENIED_PREFIX);
392+ const controlSignal = decodeToolCallControlSignal(error);
352393
353− if (!isApprovalControlSignal) {
394+ if (!controlSignal) {
354395 await failToolCall(ctx, {
355396 taskId: task.id,
356397 callId,