Unified · Split
| @@ -1,5 +1,5 @@ | |||
| 1 | import { expect, test } from "bun:test"; | 1 | import { expect, test } from "bun:test"; |
| 2 | import { APPROVAL_DENIED_PREFIX } from "../../../execution-constants"; | 2 | import { ToolCallControlError } from "../../../tool-call-control"; |
| 3 | import { InProcessExecutionAdapter } from "./execution-adapter"; | 3 | import { InProcessExecutionAdapter } from "./execution-adapter"; |
| 4 | 4 | ||
| 5 | test("returns run mismatch without invoking tool", async () => { | 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 | 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 | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../execution-constants"; | 1 | import { decodeToolCallControlSignal } from "../../../tool-call-control"; |
| 2 | import type { | 2 | import 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", |
| @@ -4,9 +4,10 @@ · 3 unmodified lines | |||
| 4 | // string version of similar logic, built from sandbox-fragments.ts. Changes to | 4 | // string version of similar logic, built from sandbox-fragments.ts. Changes to |
| 5 | // the core helpers here (createToolsProxy, console proxy, execution | 5 | // the core helpers here (createToolsProxy, console proxy, execution |
| 6 | // loop, result mapping) should be mirrored there. | 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 | import { Result } from "better-result"; | 8 | import { Result } from "better-result"; |
| 9 | import { Script, createContext } from "node:vm"; | 9 | import { Script, createContext } from "node:vm"; |
| 10 | import { decodeToolCallControlSignal, ToolCallControlError } from "../tool-call-control"; | ||
| 10 | import type { | 11 | import 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 | ||
| @@ -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 | scheduler?: Pick<MutationCtx, "scheduler">["scheduler"]; | 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 | async function createTaskRecord( | 77 | async 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 | ||
| 227 | export async function resolveApprovalHandler( | 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 | 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 | } |
| @@ -2,11 +2,15 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { APPROVAL_DENIED_PREFIX, APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants"; | ||
| 6 | import type { TaskRecord, ToolCallResult } from "../../../core/src/types"; | 5 | import type { TaskRecord, ToolCallResult } from "../../../core/src/types"; |
| 6 | import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control"; | ||
| 7 | import { describeError } from "../../../core/src/utils"; | 7 | import { describeError } from "../../../core/src/utils"; |
| 8 | import { invokeTool } from "./tool_invocation"; | 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 | export async function handleExternalToolCallRequest( | 14 | export 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", |
| @@ -3,8 +3,8 @@ · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { InProcessExecutionAdapter } from "../../../core/src/adapters/in/process/execution-adapter"; | 5 | import { InProcessExecutionAdapter } from "../../../core/src/adapters/in/process/execution-adapter"; |
| 6 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; | ||
| 7 | import { dispatchCodeWithCloudflareWorkerLoader } from "../../../core/src/runtimes/cloudflare/worker/loader-runtime"; | 6 | import { dispatchCodeWithCloudflareWorkerLoader } from "../../../core/src/runtimes/cloudflare/worker/loader-runtime"; |
| 7 | import { decodeToolCallControlSignal } from "../../../core/src/tool-call-control"; | ||
| 8 | import { | 8 | import { |
| 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 | |||
| 19 | import { markTaskFinished } from "../task/finish"; | 19 | import { markTaskFinished } from "../task/finish"; |
| 20 | import { invokeTool } from "./tool_invocation"; | 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 | async function markTaskFailedAndPublish( | 35 | async 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) { |
| @@ -3,8 +3,8 @@ · 2 unmodified lines | |||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; | 5 | import { resolveCredentialPayload } from "../../../core/src/credential-providers"; |
| 6 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; | ||
| 7 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; | 6 | import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types"; |
| 7 | import { ToolCallControlError } from "../../../core/src/tool-call-control"; | ||
| 8 | import { asPayload } from "../lib/object"; | 8 | import { asPayload } from "../lib/object"; |
| 9 | 9 | ||
| 10 | export async function resolveCredentialHeaders( | 10 | export 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 | } |
| @@ -2,8 +2,8 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants"; | ||
| 6 | import type { TaskRecord } from "../../../core/src/types"; | 5 | import type { TaskRecord } from "../../../core/src/types"; |
| 6 | import { ToolCallControlError } from "../../../core/src/tool-call-control"; | ||
| 7 | import { publishTaskEvent } from "./events"; | 7 | import { publishTaskEvent } from "./events"; |
| 8 | 8 | ||
| 9 | async function finishToolCall( | 9 | async 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 | ||
| 48 | export async function completeToolCall( | 51 | export async function completeToolCall( |
| @@ -12,6 +12,37 @@ import { getReadyRegistryBuildId } from "./tool_registry_state"; · 11 unmodified lines | |||
| 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; | 12 | import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths"; |
| 13 | import { baseTools } from "./workspace_tools"; | 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 | export function getGraphqlDecision( | 46 | export 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. |
| @@ -2,10 +2,6 @@ · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import type { ActionCtx } from "../../convex/_generated/server"; | 3 | import type { ActionCtx } from "../../convex/_generated/server"; |
| 4 | import { internal } from "../../convex/_generated/api"; | 4 | import { internal } from "../../convex/_generated/api"; |
| 5 | import { | ||
| 6 | APPROVAL_DENIED_PREFIX, | ||
| 7 | APPROVAL_PENDING_PREFIX, | ||
| 8 | } from "../../../core/src/execution-constants"; | ||
| 9 | import type { | 5 | import 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"; |
| 19 | import { describeError } from "../../../core/src/utils"; | 15 | import { describeError } from "../../../core/src/utils"; |
| 16 | import { | ||
| 17 | decodeToolCallControlSignal, | ||
| 18 | ToolCallControlError, | ||
| 19 | } from "../../../core/src/tool-call-control"; | ||
| 20 | import { asPayload } from "../lib/object"; | 20 | import { asPayload } from "../lib/object"; |
| 21 | import { getToolDecision, getDecisionForContext } from "./policy"; | 21 | import { getToolDecision, getDecisionForContext } from "./policy"; |
| 22 | import { baseTools } from "./workspace_tools"; | 22 | import { baseTools } from "./workspace_tools"; |
| @@ -40,6 +40,45 @@ type RegistryToolEntry = { · 17 unmodified lines | |||
| 40 | displayOutput?: string; | 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 | async function denyToolCallForApproval( | 82 | async function denyToolCallForApproval( |
| 44 | ctx: ActionCtx, | 83 | ctx: ActionCtx, |
| 45 | args: { | 84 | args: { |
| @@ -61,18 +100,17 @@ async function denyToolCallForApproval( · 15 unmodified lines | |||
| 61 | 100 | ||
| 62 | export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCallRequest): Promise<unknown> { | 101 | export 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 = query | 177 | 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 | : namespace | 184 | : 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 = hits | 224 | 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, |