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