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 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { APPROVAL_DENIED_PREFIX } from "../../../execution-constants";2import { ToolCallControlError } from "../../../tool-call-control";
3import { InProcessExecutionAdapter } from "./execution-adapter";3import { InProcessExecutionAdapter } from "./execution-adapter";
4 4
5test("returns run mismatch without invoking tool", async () => {5test("returns run mismatch without invoking tool", async () => {
@@ -30,7 +30,10 @@ test("maps approval denied errors to denied result", async () => { · 24 unmodified lines
30 const adapter = new InProcessExecutionAdapter({30 const adapter = new InProcessExecutionAdapter({
31 runId: "run_1",31 runId: "run_1",
32 invokeTool: async () => {32 invokeTool: async () => {
33 throw new Error(`${APPROVAL_DENIED_PREFIX}approval required`);33 throw new ToolCallControlError({
34 kind: "approval_denied",
35 reason: "approval required",
36 });
34 },37 },
35 });38 });
36 39
executor/packages/core/src/adapters/in/process/execution-adapter.ts+8−6
@@ -1,4 +1,4 @@
1import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../execution-constants";1import { decodeToolCallControlSignal } from "../../../tool-call-control";
2import type {2import type {
3 ExecutionAdapter,3 ExecutionAdapter,
4 ToolCallRequest,4 ToolCallRequest,
@@ -27,25 +27,27 @@ export class InProcessExecutionAdapter implements ExecutionAdapter { · 22 unmodified lines
27 const value = await this.options.invokeTool(call);27 const value = await this.options.invokeTool(call);
28 return { ok: true, value };28 return { ok: true, value };
29 } catch (error) {29 } catch (error) {
30 const message = describeError(error);30 const controlSignal = decodeToolCallControlSignal(error);
31 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {31 if (controlSignal?.kind === "approval_denied") {
32 return {32 return {
33 ok: false,33 ok: false,
34 kind: "denied",34 kind: "denied",
35 error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),35 error: controlSignal.reason,
36 };36 };
37 }37 }
38 38
39 if (message.startsWith(APPROVAL_PENDING_PREFIX)) {39 if (controlSignal?.kind === "approval_pending") {
40 return {40 return {
41 ok: false,41 ok: false,
42 kind: "pending",42 kind: "pending",
43 approvalId: message.replace(APPROVAL_PENDING_PREFIX, "").trim(),43 approvalId: controlSignal.approvalId,
44 retryAfterMs: 500,44 retryAfterMs: 500,
45 error: "Approval pending",45 error: "Approval pending",
46 };46 };
47 }47 }
48 48
49 const message = describeError(error);
50
49 return {51 return {
50 ok: false,52 ok: false,
51 kind: "failed",53 kind: "failed",
executor/packages/core/src/runtimes/runtime-core.ts+9−4
@@ -4,9 +4,10 @@ · 3 unmodified lines
4// string version of similar logic, built from sandbox-fragments.ts. Changes to4// string version of similar logic, built from sandbox-fragments.ts. Changes to
5// the core helpers here (createToolsProxy, console proxy, execution5// the core helpers here (createToolsProxy, console proxy, execution
6// loop, result mapping) should be mirrored there.6// loop, result mapping) should be mirrored there.
7import { APPROVAL_DENIED_PREFIX, TASK_TIMEOUT_MARKER } from "../execution-constants";7import { TASK_TIMEOUT_MARKER } from "../execution-constants";
8import { Result } from "better-result";8import { Result } from "better-result";
9import { Script, createContext } from "node:vm";9import { Script, createContext } from "node:vm";
10import { decodeToolCallControlSignal, ToolCallControlError } from "../tool-call-control";
10import type {11import type {
11 ExecutionAdapter,12 ExecutionAdapter,
12 SandboxExecutionRequest,13 SandboxExecutionRequest,
@@ -91,7 +92,10 @@ function createToolsProxy( · 78 unmodified lines
91 retryDelayMs = clampRetryDelayMs(retryDelayMs * RETRY_BACKOFF_MULTIPLIER);92 retryDelayMs = clampRetryDelayMs(retryDelayMs * RETRY_BACKOFF_MULTIPLIER);
92 continue;93 continue;
93 case "denied":94 case "denied":
94 throw new Error(`${APPROVAL_DENIED_PREFIX}${result.error}`);95 throw new ToolCallControlError({
96 kind: "approval_denied",
97 reason: result.error,
98 });
95 case "failed":99 case "failed":
96 default:100 default:
97 throw new Error(result.error);101 throw new Error(result.error);
@@ -115,10 +119,11 @@ function classifyExecutionError( · 17 unmodified lines
115 };119 };
116 }120 }
117 121
118 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {122 const controlSignal = decodeToolCallControlSignal(error);
123 if (controlSignal?.kind === "approval_denied") {
119 return {124 return {
120 status: "denied",125 status: "denied",
121 message: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),126 message: controlSignal.reason,
122 };127 };
123 }128 }
124 129
executor/packages/core/src/tool-call-control.ts+42−0
@@ -0,0 +1,42 @@
1import {
2 APPROVAL_DENIED_PREFIX,
3 APPROVAL_PENDING_PREFIX,
4} from "./execution-constants";
5
6export type ToolCallControlSignal =
7 | { kind: "approval_pending"; approvalId: string }
8 | { kind: "approval_denied"; reason: string };
9
10export 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
18export 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
34export 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
19 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];19 scheduler?: Pick<MutationCtx, "scheduler">["scheduler"];
20};20};
21 21
22async 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
39async 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
52async 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
60async 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
22async function createTaskRecord(77async function createTaskRecord(
23 ctx: TaskCreateContext,78 ctx: TaskCreateContext,
24 internal: Internal,79 internal: Internal,
@@ -46,7 +101,7 @@ async function createTaskRecord( · 21 unmodified lines
46 }101 }
47 102
48 const taskId = `task_${crypto.randomUUID()}`;103 const taskId = `task_${crypto.randomUUID()}`;
49 const task = (await ctx.runMutation(internal.database.createTask, {104 const task = await createTaskDocument(ctx, internal, {
50 id: taskId,105 id: taskId,
51 code: args.code,106 code: args.code,
52 runtimeId,107 runtimeId,
@@ -55,7 +110,7 @@ async function createTaskRecord( · 2 unmodified lines
55 workspaceId: args.workspaceId,110 workspaceId: args.workspaceId,
56 actorId: args.actorId,111 actorId: args.actorId,
57 clientId: args.clientId,112 clientId: args.clientId,
58 })) as TaskRecord;113 });
59 114
60 await createTaskEvent(ctx, {115 await createTaskEvent(ctx, {
61 taskId,116 taskId,
@@ -113,12 +168,12 @@ async function resolveApprovalRecord( · 51 unmodified lines
113 return null;168 return null;
114 }169 }
115 170
116 const approval = (await ctx.runMutation(internal.database.resolveApproval, {171 const approval = await resolveApprovalDocument(ctx, internal, {
117 approvalId: args.approvalId,172 approvalId: args.approvalId,
118 decision: args.decision,173 decision: args.decision,
119 reviewerId: args.reviewerId,174 reviewerId: args.reviewerId,
120 reason: args.reason,175 reason: args.reason,
121 })) as ApprovalRecord | null;176 });
122 if (!approval) {177 if (!approval) {
123 return null;178 return null;
124 }179 }
@@ -138,9 +193,7 @@ async function resolveApprovalRecord( · 13 unmodified lines
138 },193 },
139 });194 });
140 195
141 const task = (await ctx.runQuery(internal.database.getTask, {196 const task = await getTaskById(ctx, internal, approval.taskId);
142 taskId: approval.taskId,
143 })) as TaskRecord | null;
144 if (!task) {197 if (!task) {
145 throw new Error(`Task ${approval.taskId} missing while resolving approval`);198 throw new Error(`Task ${approval.taskId} missing while resolving approval`);
146 }199 }
@@ -172,7 +225,7 @@ export async function createTaskHandler( · 25 unmodified lines
172 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);225 assertMatchesCanonicalActorId(args.actorId, canonicalActorId);
173 226
174 const waitForResult = args.waitForResult ?? false;227 const waitForResult = args.waitForResult ?? false;
175 const created = await ctx.runMutation(internal.executor.createTaskInternal, {228 const created = await createTaskInternal(ctx, internal, {
176 code: args.code,229 code: args.code,
177 timeoutMs: args.timeoutMs,230 timeoutMs: args.timeoutMs,
178 runtimeId: args.runtimeId,231 runtimeId: args.runtimeId,
@@ -184,7 +237,7 @@ export async function createTaskHandler( · 5 unmodified lines
184 });237 });
185 238
186 if (!waitForResult) {239 if (!waitForResult) {
187 return { task: created.task as TaskRecord };240 return { task: created.task };
188 }241 }
189 242
190 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {243 const runOutcome = await ctx.runAction(internal.executorNode.runTask, {
@@ -225,7 +278,10 @@ export async function createTaskInternalHandler( · 34 unmodified lines
225}278}
226 279
227export async function resolveApprovalHandler(280export async function resolveApprovalHandler(
228 ctx: unknown,281 ctx: MutationCtx & {
282 account: { _id: string; provider: string; providerAccountId: string };
283 workspaceId: Id<"workspaces">;
284 },
229 internal: Internal,285 internal: Internal,
230 args: {286 args: {
231 approvalId: string;287 approvalId: string;
@@ -234,17 +290,12 @@ export async function resolveApprovalHandler( · 2 unmodified lines
234 reason?: string;290 reason?: string;
235 },291 },
236): Promise<{ approval: ApprovalRecord; task: TaskRecord } | null> {292): Promise<{ approval: ApprovalRecord; task: TaskRecord } | null> {
237 const typedCtx = ctx as MutationCtx & {293 const canonicalActorId = actorIdForAccount(ctx.account);
238 account: { _id: string; provider: string; providerAccountId: string };
239 workspaceId: Id<"workspaces">;
240 };
241
242 const canonicalActorId = actorIdForAccount(typedCtx.account);
243 assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId");294 assertMatchesCanonicalActorId(args.reviewerId, canonicalActorId, "reviewerId");
244 295
245 return await resolveApprovalRecord(typedCtx, internal, {296 return await resolveApprovalRecord(ctx, internal, {
246 ...args,297 ...args,
247 workspaceId: typedCtx.workspaceId,298 workspaceId: ctx.workspaceId,
248 reviewerId: canonicalActorId,299 reviewerId: canonicalActorId,
249 });300 });
250}301}
@@ -274,7 +325,7 @@ export async function completeRuntimeRunHandler( · 23 unmodified lines
274 durationMs?: number;325 durationMs?: number;
275 },326 },
276) {327) {
277 const task = (await ctx.runQuery(internal.database.getTask, { taskId: args.runId })) as TaskRecord | null;328 const task = await getTaskById(ctx, internal, args.runId);
278 if (!task) {329 if (!task) {
279 return { ok: false as const, error: `Run not found: ${args.runId}` };330 return { ok: false as const, error: `Run not found: ${args.runId}` };
280 }331 }
executor/packages/database/src/runtime/external_tool_call.ts+13−10
@@ -2,11 +2,15 @@ · 1 unmodified line
2 2
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants";
6import type { TaskRecord, ToolCallResult } from "../../../core/src/types";5import type { TaskRecord, ToolCallResult } from "../../../core/src/types";
6import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control";
7import { describeError } from "../../../core/src/utils";7import { describeError } from "../../../core/src/utils";
8import { invokeTool } from "./tool_invocation";8import { invokeTool } from "./tool_invocation";
9 9
10async function getTaskById(ctx: ActionCtx, taskId: string): Promise<TaskRecord | null> {
11 return await ctx.runQuery(internal.database.getTask, { taskId }) as TaskRecord | null;
12}
13
10export async function handleExternalToolCallRequest(14export async function handleExternalToolCallRequest(
11 ctx: ActionCtx,15 ctx: ActionCtx,
12 args: {16 args: {
@@ -16,9 +20,7 @@ export async function handleExternalToolCallRequest( · 3 unmodified lines
16 input?: unknown;20 input?: unknown;
17 },21 },
18): Promise<ToolCallResult> {22): Promise<ToolCallResult> {
19 const task = (await ctx.runQuery(internal.database.getTask, {23 const task = await getTaskById(ctx, args.runId);
20 taskId: args.runId,
21 })) as TaskRecord | null;
22 if (!task) {24 if (!task) {
23 return {25 return {
24 ok: false,26 ok: false,
@@ -36,25 +38,26 @@ export async function handleExternalToolCallRequest( · 11 unmodified lines
36 });38 });
37 return { ok: true, value };39 return { ok: true, value };
38 } catch (error) {40 } catch (error) {
39 const message = describeError(error);41 const controlSignal = decodeToolCallControlSignal(error);
40 if (message.startsWith(APPROVAL_PENDING_PREFIX)) {42 if (controlSignal?.kind === "approval_pending") {
41 const approvalId = message.replace(APPROVAL_PENDING_PREFIX, "").trim();
42 return {43 return {
43 ok: false,44 ok: false,
44 kind: "pending",45 kind: "pending",
45 approvalId,46 approvalId: controlSignal.approvalId,
46 retryAfterMs: 0,47 retryAfterMs: 0,
47 error: "Approval pending",48 error: "Approval pending",
48 };49 };
49 }50 }
50 if (message.startsWith(APPROVAL_DENIED_PREFIX)) {51 if (controlSignal?.kind === "approval_denied") {
51 return {52 return {
52 ok: false,53 ok: false,
53 kind: "denied",54 kind: "denied",
54 error: message.replace(APPROVAL_DENIED_PREFIX, "").trim(),55 error: controlSignal.reason,
55 };56 };
56 }57 }
57 58
59 const message = describeError(error);
60
58 return {61 return {
59 ok: false,62 ok: false,
60 kind: "failed",63 kind: "failed",
executor/packages/database/src/runtime/task_runner.ts+19−7
@@ -3,8 +3,8 @@ · 2 unmodified lines
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { InProcessExecutionAdapter } from "../../../core/src/adapters/in/process/execution-adapter";5import { InProcessExecutionAdapter } from "../../../core/src/adapters/in/process/execution-adapter";
6import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
7import { dispatchCodeWithCloudflareWorkerLoader } from "../../../core/src/runtimes/cloudflare/worker/loader-runtime";6import { dispatchCodeWithCloudflareWorkerLoader } from "../../../core/src/runtimes/cloudflare/worker/loader-runtime";
7import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control";
8import {8import {
9 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,9 CLOUDFLARE_WORKER_LOADER_RUNTIME_ID,
10 isCloudflareWorkerLoaderConfigured,10 isCloudflareWorkerLoaderConfigured,
@@ -19,6 +19,19 @@ import { taskTerminalEventType } from "../task/status"; · 8 unmodified lines
19import { markTaskFinished } from "../task/finish";19import { markTaskFinished } from "../task/finish";
20import { invokeTool } from "./tool_invocation";20import { invokeTool } from "./tool_invocation";
21 21
22async function getTaskById(ctx: ActionCtx, taskId: string): Promise<TaskRecord | null> {
23 return await ctx.runQuery(internal.database.getTask, { taskId }) as TaskRecord | null;
24}
25
26async 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
22async function markTaskFailedAndPublish(35async function markTaskFailedAndPublish(
23 ctx: ActionCtx,36 ctx: ActionCtx,
24 args: { taskId: string; error: string },37 args: { taskId: string; error: string },
@@ -66,7 +79,7 @@ export async function runQueuedTask( · 41 unmodified lines
66 ctx: ActionCtx,79 ctx: ActionCtx,
67 args: { taskId: string },80 args: { taskId: string },
68): Promise<TaskExecutionOutcome | null> {81): 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);
70 if (!task || task.status !== "queued") {83 if (!task || task.status !== "queued") {
71 return null;84 return null;
72 }85 }
@@ -93,9 +106,7 @@ export async function runQueuedTask( · 20 unmodified lines
93 }106 }
94 107
95 try {108 try {
96 const running = (await ctx.runMutation(internal.database.markTaskRunning, {109 const running = await markTaskRunning(ctx, args.taskId);
97 taskId: args.taskId,
98 })) as TaskRecord | null;
99 if (!running) {110 if (!running) {
100 return null;111 return null;
101 }112 }
@@ -182,11 +193,12 @@ export async function runQueuedTask( · 80 unmodified lines
182 };193 };
183 } catch (error) {194 } catch (error) {
184 const message = describeError(error);195 const message = describeError(error);
185 const denied = message.startsWith(APPROVAL_DENIED_PREFIX);196 const controlSignal = decodeToolCallControlSignal(error);
197 const denied = controlSignal?.kind === "approval_denied";
186 const finished = await markTaskFinished(ctx, {198 const finished = await markTaskFinished(ctx, {
187 taskId: args.taskId,199 taskId: args.taskId,
188 status: denied ? "denied" : "failed",200 status: denied ? "denied" : "failed",
189 error: denied ? message.replace(APPROVAL_DENIED_PREFIX, "") : message,201 error: denied ? controlSignal.reason : message,
190 });202 });
191 203
192 if (finished) {204 if (finished) {
executor/packages/database/src/runtime/tool_call_credentials.ts+5−2
@@ -3,8 +3,8 @@ · 2 unmodified lines
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { resolveCredentialPayload } from "../../../core/src/credential-providers";5import { resolveCredentialPayload } from "../../../core/src/credential-providers";
6import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
7import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";6import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
7import { ToolCallControlError } from "../../../core/src/tool-call-control";
8import { asPayload } from "../lib/object";8import { asPayload } from "../lib/object";
9 9
10export async function resolveCredentialHeaders(10export async function resolveCredentialHeaders(
@@ -72,6 +72,9 @@ export function assertPersistedCallRunnable(persistedCall: ToolCallRecord, callI · 61 unmodified lines
72 }72 }
73 73
74 if (persistedCall.status === "denied") {74 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 });
76 }79 }
77}80}
executor/packages/database/src/runtime/tool_call_lifecycle.ts+5−2
@@ -2,8 +2,8 @@ · 1 unmodified line
2 2
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
6import type { TaskRecord } from "../../../core/src/types";5import type { TaskRecord } from "../../../core/src/types";
6import { ToolCallControlError } from "../../../core/src/tool-call-control";
7import { publishTaskEvent } from "./events";7import { publishTaskEvent } from "./events";
8 8
9async function finishToolCall(9async function finishToolCall(
@@ -42,7 +42,10 @@ export async function denyToolCall( · 32 unmodified lines
42 ...(args.approvalId ? { approvalId: args.approvalId } : {}),42 ...(args.approvalId ? { approvalId: args.approvalId } : {}),
43 ...(args.reason ? { reason: args.reason } : {}),43 ...(args.reason ? { reason: args.reason } : {}),
44 });44 });
45 throw new Error(`${APPROVAL_DENIED_PREFIX}${args.deniedMessage}`);45 throw new ToolCallControlError({
46 kind: "approval_denied",
47 reason: args.deniedMessage,
48 });
46}49}
47 50
48export async function completeToolCall(51export 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
12import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";12import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
13import { baseTools } from "./workspace_tools";13import { baseTools } from "./workspace_tools";
14 14
15type RegistrySerializedToolEntry = {
16 path: string;
17 serializedToolJson: string;
18};
19
20async 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
27async 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
34async 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
15export function getGraphqlDecision(46export function getGraphqlDecision(
16 task: TaskRecord,47 task: TaskRecord,
17 tool: ToolDefinition,48 tool: ToolDefinition,
@@ -75,12 +106,12 @@ async function suggestFromRegistry( · 57 unmodified lines
75 toolPath: string,106 toolPath: string,
76): Promise<string[]> {107): Promise<string[]> {
77 const term = toolPath.split(".").filter(Boolean).join(" ");108 const term = toolPath.split(".").filter(Boolean).join(" ");
78 const hits = await ctx.runQuery(internal.toolRegistry.searchTools, {109 const hits = await searchRegistryEntries(ctx, {
79 workspaceId,110 workspaceId,
80 buildId,111 buildId,
81 query: term,112 query: term,
82 limit: 3,113 limit: 3,
83 }) as Array<{ preferredPath: string }>;114 });
84 return hits.map((hit) => hit.preferredPath);115 return hits.map((hit) => hit.preferredPath);
85}116}
86 117
@@ -114,20 +145,20 @@ export async function resolveToolForCall( · 27 unmodified lines
114 });145 });
115 146
116 let resolvedToolPath = toolPath;147 let resolvedToolPath = toolPath;
117 let entry = await ctx.runQuery(internal.toolRegistry.getToolByPath, {148 let entry = await getRegistryToolByPath(ctx, {
118 workspaceId: task.workspaceId,149 workspaceId: task.workspaceId,
119 buildId,150 buildId,
120 path: toolPath,151 path: toolPath,
121 }) as null | { path: string; serializedToolJson: string };152 });
122 153
123 if (!entry) {154 if (!entry) {
124 const normalized = normalizeToolPathForLookup(toolPath);155 const normalized = normalizeToolPathForLookup(toolPath);
125 const hits = await ctx.runQuery(internal.toolRegistry.getToolsByNormalizedPath, {156 const hits = await getRegistryToolsByNormalizedPath(ctx, {
126 workspaceId: task.workspaceId,157 workspaceId: task.workspaceId,
127 buildId,158 buildId,
128 normalizedPath: normalized,159 normalizedPath: normalized,
129 limit: 5,160 limit: 5,
130 }) as Array<{ path: string; serializedToolJson: string }>;161 });
131 162
132 if (hits.length > 0) {163 if (hits.length > 0) {
133 // Prefer exact match on preferred path formatting, otherwise shortest canonical path.164 // 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
2 2
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import {
6 APPROVAL_DENIED_PREFIX,
7 APPROVAL_PENDING_PREFIX,
8} from "../../../core/src/execution-constants";
9import type {5import type {
10 AccessPolicyRecord,6 AccessPolicyRecord,
11 PolicyDecision,7 PolicyDecision,
@@ -17,6 +13,10 @@ import type { · 5 unmodified lines
17 ToolRunContext,13 ToolRunContext,
18} from "../../../core/src/types";14} from "../../../core/src/types";
19import { describeError } from "../../../core/src/utils";15import { describeError } from "../../../core/src/utils";
16import {
17 decodeToolCallControlSignal,
18 ToolCallControlError,
19} from "../../../core/src/tool-call-control";
20import { asPayload } from "../lib/object";20import { asPayload } from "../lib/object";
21import { getToolDecision, getDecisionForContext } from "./policy";21import { getToolDecision, getDecisionForContext } from "./policy";
22import { baseTools } from "./workspace_tools";22import { baseTools } from "./workspace_tools";
@@ -40,6 +40,45 @@ type RegistryToolEntry = { · 17 unmodified lines
40 displayOutput?: string;40 displayOutput?: string;
41};41};
42 42
43async 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
50async 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
57async 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
68async 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
75async 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
43async function denyToolCallForApproval(82async function denyToolCallForApproval(
44 ctx: ActionCtx,83 ctx: ActionCtx,
45 args: {84 args: {
@@ -61,18 +100,17 @@ async function denyToolCallForApproval( · 15 unmodified lines
61 100
62export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {101export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> {
63 const { toolPath, input, callId } = call;102 const { toolPath, input, callId } = call;
64 const persistedCall = (await ctx.runMutation(internal.database.upsertToolCallRequested, {103 const persistedCall = await upsertRequestedToolCall(ctx, {
65 taskId: task.id,104 taskId: task.id,
66 callId,105 callId,
67 workspaceId: task.workspaceId,106 workspaceId: task.workspaceId,
68 toolPath,107 toolPath,
69 })) as ToolCallRecord;108 });
70 assertPersistedCallRunnable(persistedCall, callId);109 assertPersistedCallRunnable(persistedCall, callId);
71 110
72 let effectiveToolPath = toolPath;111 let effectiveToolPath = toolPath;
73 try {112 try {
74 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });113 const typedPolicies = await listWorkspaceAccessPolicies(ctx, task.workspaceId);
75 const typedPolicies = policies as AccessPolicyRecord[];
76 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {114 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
77 if (persistedCall.status === "requested") {115 if (persistedCall.status === "requested") {
78 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {116 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
92 130
93 // Fast system tools are handled server-side from the registry.131 // Fast system tools are handled server-side from the registry.
94 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {132 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
95 const toolRegistry = internal.toolRegistry;
96 const buildId = await getReadyRegistryBuildId(ctx, {133 const buildId = await getReadyRegistryBuildId(ctx, {
97 workspaceId: task.workspaceId,134 workspaceId: task.workspaceId,
98 actorId: task.actorId,135 actorId: task.actorId,
@@ -124,11 +161,11 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 25 unmodified lines
124 161
125 if (toolPath === "catalog.namespaces") {162 if (toolPath === "catalog.namespaces") {
126 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));163 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, {
128 workspaceId: task.workspaceId,165 workspaceId: task.workspaceId,
129 buildId,166 buildId,
130 limit,167 limit,
131 }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;168 });
132 return await finalizeImmediateTool({ namespaces, total: namespaces.length });169 return await finalizeImmediateTool({ namespaces, total: namespaces.length });
133 }170 }
134 171
@@ -138,14 +175,14 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 3 unmodified lines
138 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));175 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
139 176
140 const raw = query177 const raw = query
141 ? await ctx.runQuery(toolRegistry.searchTools, {178 ? await searchRegistryTools(ctx, {
142 workspaceId: task.workspaceId,179 workspaceId: task.workspaceId,
143 buildId,180 buildId,
144 query,181 query,
145 limit,182 limit,
146 })183 })
147 : namespace184 : namespace
148 ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {185 ? await listRegistryToolsByNamespace(ctx, {
149 workspaceId: task.workspaceId,186 workspaceId: task.workspaceId,
150 buildId,187 buildId,
151 namespace,188 namespace,
@@ -153,7 +190,7 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 1 unmodified line
153 })190 })
154 : [];191 : [];
155 192
156 const results = (raw as RegistryToolEntry[])193 const results = raw
157 .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))194 .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
158 .filter((entry) => isAllowed(entry.path, entry.approval))195 .filter((entry) => isAllowed(entry.path, entry.approval))
159 .slice(0, limit)196 .slice(0, limit)
@@ -177,12 +214,12 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 17 unmodified lines
177 const query = String(payload.query ?? "").trim();214 const query = String(payload.query ?? "").trim();
178 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));215 const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
179 const compact = payload.compact === false ? false : true;216 const compact = payload.compact === false ? false : true;
180 const hits = await ctx.runQuery(toolRegistry.searchTools, {217 const hits = await searchRegistryTools(ctx, {
181 workspaceId: task.workspaceId,218 workspaceId: task.workspaceId,
182 buildId,219 buildId,
183 query,220 query,
184 limit: Math.max(limit * 2, limit),221 limit: Math.max(limit * 2, limit),
185 }) as RegistryToolEntry[];222 });
186 223
187 const filtered = hits224 const filtered = hits
188 .filter((entry) => isAllowed(entry.path, entry.approval))225 .filter((entry) => isAllowed(entry.path, entry.approval))
@@ -269,7 +306,10 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 80 unmodified lines
269 }306 }
270 307
271 if (existingApproval.status === "pending") {308 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 });
273 }313 }
274 314
275 if (existingApproval.status === "denied") {315 if (existingApproval.status === "denied") {
@@ -315,7 +355,10 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 39 unmodified lines
315 });355 });
316 356
317 if (approval.status === "pending") {357 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 });
319 }362 }
320 363
321 if (approval.status === "denied") {364 if (approval.status === "denied") {
@@ -346,11 +389,9 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 24 unmodified lines
346 return value;389 return value;
347 } catch (error) {390 } catch (error) {
348 const message = describeError(error);391 const message = describeError(error);
349 const isApprovalControlSignal =392 const controlSignal = decodeToolCallControlSignal(error);
350 message.startsWith(APPROVAL_PENDING_PREFIX) ||
351 message.startsWith(APPROVAL_DENIED_PREFIX);
352 393
353 if (!isApprovalControlSignal) {394 if (!controlSignal) {
354 await failToolCall(ctx, {395 await failToolCall(ctx, {
355 taskId: task.id,396 taskId: task.id,
356 callId,397 callId,