History

Harden tool invocation flow and remove runtime typing shims

Rhys Sullivan committed 8 months ago

Commit 6c01f0b1 parent 7c0a99710 files · +462 −275Browse files

Unified · Split

10 files
  1. executor/packages/core/src/mcp-server.ts +5 −15
  2. executor/packages/database/convex/database/task_events.ts +1 −3
  3. executor/packages/database/src/database/tool_source_config.test.ts +51 −0
  4. executor/packages/database/src/database/tool_source_config.ts +44 −3
  5. executor/packages/database/src/runtime/tool_call_credentials.ts +9 −8
  6. executor/packages/database/src/runtime/tool_call_resolution.ts +16 −8
  7. executor/packages/database/src/runtime/tool_invocation.ts +238 −200
  8. executor/packages/database/src/runtime/tool_registry_state.ts +42 −3
  9. executor/packages/database/src/runtime/tool_source_loading.ts +47 −27
  10. executor/packages/database/src/runtime/workspace_tools.ts +9 −8
executor/packages/core/src/mcp-server.ts+5−15
@@ -163,11 +163,7 @@ function createRunCodeTool( · 162 unmodified lines
163163 // Input schema — when context is bound, workspace fields aren't needed
164164 // ---------------------------------------------------------------------------
165165
166−function toAnySchema(schema: unknown): AnySchema {
167− return schema as AnySchema;
168−}
169−
170−const FULL_INPUT = toAnySchema(z.object({
166+const FULL_INPUT: AnySchema = z.object({
171167 code: z.string().min(1),
172168 timeoutMs: z.number().int().min(1).max(600_000).optional(),
173169 runtimeId: z.string().optional(),
@@ -176,14 +172,14 @@ const FULL_INPUT = toAnySchema(z.object({ · 2 unmodified lines
176172 sessionId: z.string().optional(),
177173 waitForResult: z.boolean().optional(),
178174 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),
179−}));
175+});
180176
181−const BOUND_INPUT = toAnySchema(z.object({
177+const BOUND_INPUT: AnySchema = z.object({
182178 code: z.string().min(1),
183179 timeoutMs: z.number().int().min(1).max(600_000).optional(),
184180 runtimeId: z.string().optional(),
185181 metadata: z.record(z.string(), z.any()).optional(),
186−}));
182+});
187183
188184 // ---------------------------------------------------------------------------
189185 // MCP server factory
@@ -207,13 +203,7 @@ async function createMcpServer( · 17 unmodified lines
207203 },
208204 );
209205 const onApprovalPrompt = createMcpApprovalPrompt(mcp);
210− const registerTool = (mcp.registerTool as (
211− name: string,
212− config: { description: string; inputSchema: AnySchema },
213− cb: ReturnType<typeof createRunCodeTool>,
214− ) => void).bind(mcp);
215−
216− registerTool(
206+ mcp.registerTool(
217207 "run_code",
218208 {
219209 description: buildRunCodeDescription(),
executor/packages/database/convex/database/task_events.ts+1−3
@@ -17,9 +17,7 @@ export const createTaskEvent = internalMutation({ · 16 unmodified lines
1717 throw new Error(`Task not found for event: ${args.taskId}`);
1818 }
1919
20− const currentSequence = typeof (task as { nextEventSequence?: unknown }).nextEventSequence === "number"
21− ? (task as { nextEventSequence: number }).nextEventSequence
22− : 0;
20+ const currentSequence = task.nextEventSequence ?? 0;
2321 const sequence = currentSequence + 1;
2422 const createdAt = Date.now();
2523
executor/packages/database/src/database/tool_source_config.test.ts+51−0
@@ -0,0 +1,51 @@
1+import { expect, test } from "bun:test";
2+import { normalizeExternalToolSource } from "../runtime/tool_source_loading";
3+import { normalizeToolSourceConfig } from "./tool_source_config";
4+
5+test("normalizeToolSourceConfig returns an error result for invalid mcp config", () => {
6+ const result = normalizeToolSourceConfig("mcp", {});
7+
8+ expect(result.isErr()).toBe(true);
9+ if (result.isErr()) {
10+ expect(result.error.message).toBe("Tool source url is required");
11+ }
12+});
13+
14+test("normalizeToolSourceConfig trims and normalizes openapi auth/header values", () => {
15+ const result = normalizeToolSourceConfig("openapi", {
16+ spec: " https://example.com/openapi.json ",
17+ baseUrl: " https://api.example.com ",
18+ auth: {
19+ type: "apiKey",
20+ header: " x-api-key ",
21+ value: "token-value",
22+ },
23+ });
24+
25+ expect(result.isOk()).toBe(true);
26+ if (result.isOk()) {
27+ expect(result.value.spec).toBe("https://example.com/openapi.json");
28+ expect(result.value.baseUrl).toBe("https://api.example.com");
29+ expect(result.value.auth?.type).toBe("apiKey");
30+ if (result.value.auth?.type === "apiKey") {
31+ expect(result.value.auth.header).toBe("x-api-key");
32+ expect(result.value.auth.value).toBe("token-value");
33+ }
34+ }
35+});
36+
37+test("normalizeExternalToolSource returns error result with source context", () => {
38+ const result = normalizeExternalToolSource({
39+ id: "src_123",
40+ type: "graphql",
41+ name: "bad-graphql",
42+ config: {},
43+ });
44+
45+ expect(result.isErr()).toBe(true);
46+ if (result.isErr()) {
47+ expect(result.error.message).toBe(
48+ "Failed to normalize 'bad-graphql' source config: Tool source endpoint is required",
49+ );
50+ }
51+});
executor/packages/database/src/database/tool_source_config.ts+44−3
@@ -1,9 +1,34 @@
11 import { Result } from "better-result";
2−import type { OpenApiAuth } from "../../../core/src/tool/source-types";
2+import type {
3+ GraphqlToolSourceConfig,
4+ McpToolSourceConfig,
5+ OpenApiAuth,
6+ OpenApiToolSourceConfig,
7+} from "../../../core/src/tool/source-types";
38 import type { ToolApprovalMode } from "../../../core/src/types";
49 import { asRecord } from "../lib/object";
510
6−type ToolSourceType = "mcp" | "openapi" | "graphql";
11+export type ToolSourceType = "mcp" | "openapi" | "graphql";
12+
13+export type NormalizedMcpToolSourceConfig = Pick<
14+ McpToolSourceConfig,
15+ "url" | "auth" | "discoveryHeaders" | "transport" | "queryParams" | "defaultApproval" | "overrides"
16+>;
17+
18+export type NormalizedGraphqlToolSourceConfig = Pick<
19+ GraphqlToolSourceConfig,
20+ "endpoint" | "schema" | "auth" | "defaultQueryApproval" | "defaultMutationApproval" | "overrides"
21+>;
22+
23+export type NormalizedOpenApiToolSourceConfig = Pick<
24+ OpenApiToolSourceConfig,
25+ "spec" | "collectionUrl" | "postmanProxyUrl" | "baseUrl" | "auth" | "defaultReadApproval" | "defaultWriteApproval" | "overrides"
26+>;
27+
28+export type NormalizedToolSourceConfig =
29+ | NormalizedMcpToolSourceConfig
30+ | NormalizedGraphqlToolSourceConfig
31+ | NormalizedOpenApiToolSourceConfig;
732
833 function optionalTrimmedString(value: unknown): string | undefined {
934 if (typeof value !== "string") return undefined;
@@ -164,10 +189,26 @@ function normalizeSpec( · 154 unmodified lines
164189 return Result.ok(specObject);
165190 }
166191
192+export function normalizeToolSourceConfig(
193+ type: "mcp",
194+ rawConfig: unknown,
195+): Result<NormalizedMcpToolSourceConfig, Error>;
196+export function normalizeToolSourceConfig(
197+ type: "graphql",
198+ rawConfig: unknown,
199+): Result<NormalizedGraphqlToolSourceConfig, Error>;
200+export function normalizeToolSourceConfig(
201+ type: "openapi",
202+ rawConfig: unknown,
203+): Result<NormalizedOpenApiToolSourceConfig, Error>;
204+export function normalizeToolSourceConfig(
205+ type: ToolSourceType,
206+ rawConfig: unknown,
207+): Result<NormalizedToolSourceConfig, Error>;
167208 export function normalizeToolSourceConfig(
168209 type: ToolSourceType,
169210 rawConfig: unknown,
170−): Result<Record<string, unknown>, Error> {
211+): Result<NormalizedToolSourceConfig, Error> {
171212 const config = asRecord(rawConfig);
172213
173214 if (type === "mcp") {
executor/packages/database/src/runtime/tool_call_credentials.ts+9−8
@@ -4,7 +4,7 @@ import type { ActionCtx } from "../../convex/_generated/server"; · 3 unmodified lines
44 import { internal } from "../../convex/_generated/api";
55 import { resolveCredentialPayload } from "../../../core/src/credential-providers";
66 import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
7−import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
7+import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
88 import { asPayload } from "../lib/object";
99
1010 export async function resolveCredentialHeaders(
@@ -15,7 +15,7 @@ export async function resolveCredentialHeaders( · 4 unmodified lines
1515 const record = await ctx.runQuery(internal.database.resolveCredential, {
1616 workspaceId: task.workspaceId,
1717 sourceKey: spec.sourceKey,
18− scope: spec.mode as CredentialScope,
18+ scope: spec.mode,
1919 actorId: task.actorId,
2020 });
2121
@@ -25,25 +25,26 @@ export async function resolveCredentialHeaders( · 3 unmodified lines
2525 if (!source) {
2626 return null;
2727 }
28+ const sourcePayload = asPayload(source);
2829
2930 const headers: Record<string, string> = {};
3031 if (spec.authType === "bearer") {
31− const token = String((source as Record<string, unknown>).token ?? "").trim();
32+ const token = String(sourcePayload.token ?? "").trim();
3233 if (token) headers.authorization = `Bearer ${token}`;
3334 } else if (spec.authType === "apiKey") {
34− const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");
35− const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();
35+ const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key");
36+ const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim();
3637 if (value) headers[headerName] = value;
3738 } else if (spec.authType === "basic") {
38− const username = String((source as Record<string, unknown>).username ?? "");
39− const password = String((source as Record<string, unknown>).password ?? "");
39+ const username = String(sourcePayload.username ?? "");
40+ const password = String(sourcePayload.password ?? "");
4041 if (username || password) {
4142 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
4243 headers.authorization = `Basic ${encoded}`;
4344 }
4445 }
4546
46− const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});
47+ const bindingOverrides = asPayload(record?.overridesJson ?? {});
4748 const overrideHeaders = asPayload(bindingOverrides.headers);
4849 for (const [key, value] of Object.entries(overrideHeaders)) {
4950 if (!key) continue;
executor/packages/database/src/runtime/tool_call_resolution.ts+16−8
@@ -1,10 +1,12 @@
11 "use node";
22
3+import { Result } from "better-result";
34 import type { ActionCtx } from "../../convex/_generated/server";
45 import { internal } from "../../convex/_generated/api";
56 import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
67 import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
78 import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
9+import { asPayload } from "../lib/object";
810 import { getDecisionForContext, getToolDecision } from "./policy";
911 import { getReadyRegistryBuildId } from "./tool_registry_state";
1012 import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
@@ -18,7 +20,7 @@ export function getGraphqlDecision( · 7 unmodified lines
1820 policies: AccessPolicyRecord[],
1921 ): { decision: PolicyDecision; effectivePaths: string[] } {
2022 const sourceName = tool._graphqlSource!;
21− const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
23+ const payload = asPayload(input);
2224 const queryString = typeof payload.query === "string" ? payload.query : "";
2325
2426 if (!queryString.trim()) {
@@ -95,13 +97,13 @@ export async function resolveToolForCall( · 70 unmodified lines
9597 ctx: ActionCtx,
9698 task: TaskRecord,
9799 toolPath: string,
98−): Promise<{
100+): Promise<Result<{
99101 tool: ToolDefinition;
100102 resolvedToolPath: string;
101−}> {
103+}, Error>> {
102104 const builtin = baseTools.get(toolPath);
103105 if (builtin) {
104− return { tool: builtin, resolvedToolPath: toolPath };
106+ return Result.ok({ tool: builtin, resolvedToolPath: toolPath });
105107 }
106108
107109 const buildId = await getReadyRegistryBuildId(ctx, {
@@ -137,14 +139,20 @@ export async function resolveToolForCall( · 29 unmodified lines
137139
138140 if (!entry) {
139141 const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);
140− throw new Error(unknownToolErrorMessage(toolPath, suggestions));
142+ return Result.err(new Error(unknownToolErrorMessage(toolPath, suggestions)));
141143 }
142144
143− const serialized = JSON.parse(entry.serializedToolJson) as SerializedTool;
145+ const serializedResult = Result.try(() => JSON.parse(entry.serializedToolJson) as SerializedTool);
146+ if (serializedResult.isErr()) {
147+ return Result.err(
148+ new Error(`Failed to parse tool registry entry '${resolvedToolPath}': ${serializedResult.error.message}`),
149+ );
150+ }
151+ const serialized = serializedResult.value;
144152 const [tool] = rehydrateTools([serialized], baseTools);
145153 if (!tool) {
146− throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);
154+ return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`));
147155 }
148156
149− return { tool, resolvedToolPath };
157+ return Result.ok({ tool, resolvedToolPath });
150158 }
executor/packages/database/src/runtime/tool_invocation.ts+238−200
@@ -2,7 +2,10 @@ · 1 unmodified line
22
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import { internal } from "../../convex/_generated/api";
5−import { APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants";
5+import {
6+ APPROVAL_DENIED_PREFIX,
7+ APPROVAL_PENDING_PREFIX,
8+} from "../../../core/src/execution-constants";
69 import type {
710 AccessPolicyRecord,
811 PolicyDecision,
@@ -66,237 +69,265 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 57 unmodified lines
6669 })) as ToolCallRecord;
6770 assertPersistedCallRunnable(persistedCall, callId);
6871
69− const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
70− const typedPolicies = policies as AccessPolicyRecord[];
71−
72− // Fast system tools are handled server-side from the registry.
73− if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
74− const toolRegistry = internal.toolRegistry;
75− const buildId = await getReadyRegistryBuildId(ctx, {
76− workspaceId: task.workspaceId,
77− actorId: task.actorId,
78− clientId: task.clientId,
79− refreshOnStale: true,
80− });
81−
82− const payload = typeof input === "string"
83− ? { query: input }
84− : (input && typeof input === "object" ? (input as Record<string, unknown>) : {});
85− const isAllowed = (path: string, approval: ToolDefinition["approval"]) =>
86− getDecisionForContext(
87− { path, approval, description: "", run: async () => null } as ToolDefinition,
88− { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId },
89− typedPolicies,
90− ) !== "deny";
91−
92− const normalizeHint = (value: unknown, fallback: string) => {
93− const str = typeof value === "string" ? value.trim() : "";
94− return str.length > 0 ? str : fallback;
72+ let effectiveToolPath = toolPath;
73+ try {
74+ const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
75+ const typedPolicies = policies as AccessPolicyRecord[];
76+ const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
77+ if (persistedCall.status === "requested") {
78+ await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
79+ taskId: task.id,
80+ callId,
81+ toolPath,
82+ approval: "auto",
83+ });
84+ }
85+ await completeToolCall(ctx, {
86+ taskId: task.id,
87+ callId,
88+ toolPath,
89+ });
90+ return value;
9591 };
9692
97− if (toolPath === "catalog.namespaces") {
98− const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));
99− const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {
93+ // Fast system tools are handled server-side from the registry.
94+ if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
95+ const toolRegistry = internal.toolRegistry;
96+ const buildId = await getReadyRegistryBuildId(ctx, {
10097 workspaceId: task.workspaceId,
101− buildId,
102− limit,
103− }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;
104− return { namespaces, total: namespaces.length };
105− }
98+ actorId: task.actorId,
99+ clientId: task.clientId,
100+ refreshOnStale: true,
101+ });
106102
107− if (toolPath === "catalog.tools") {
108− const namespace = String(payload.namespace ?? "").trim().toLowerCase();
109− const query = String(payload.query ?? "").trim();
110− const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
111−
112− const raw = query
113− ? await ctx.runQuery(toolRegistry.searchTools, {
114− workspaceId: task.workspaceId,
115− buildId,
116− query,
117− limit,
118− })
119− : namespace
120− ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {
103+ const payload = typeof input === "string"
104+ ? { query: input }
105+ : asPayload(input);
106+ const isAllowed = (path: string, approval: ToolDefinition["approval"]) => {
107+ const policyProbeTool: ToolDefinition = {
108+ path,
109+ approval,
110+ description: "",
111+ run: async () => null,
112+ };
113+ return getDecisionForContext(
114+ policyProbeTool,
115+ { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId },
116+ typedPolicies,
117+ ) !== "deny";
118+ };
119+
120+ const normalizeHint = (value: unknown, fallback: string) => {
121+ const str = typeof value === "string" ? value.trim() : "";
122+ return str.length > 0 ? str : fallback;
123+ };
124+
125+ if (toolPath === "catalog.namespaces") {
126+ const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));
127+ const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {
128+ workspaceId: task.workspaceId,
129+ buildId,
130+ limit,
131+ }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;
132+ return await finalizeImmediateTool({ namespaces, total: namespaces.length });
133+ }
134+
135+ if (toolPath === "catalog.tools") {
136+ const namespace = String(payload.namespace ?? "").trim().toLowerCase();
137+ const query = String(payload.query ?? "").trim();
138+ const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));
139+
140+ const raw = query
141+ ? await ctx.runQuery(toolRegistry.searchTools, {
121142 workspaceId: task.workspaceId,
122143 buildId,
123− namespace,
144+ query,
124145 limit,
125146 })
126− : [];
147+ : namespace
148+ ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {
149+ workspaceId: task.workspaceId,
150+ buildId,
151+ namespace,
152+ limit,
153+ })
154+ : [];
155+
156+ const results = (raw as RegistryToolEntry[])
157+ .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
158+ .filter((entry) => isAllowed(entry.path, entry.approval))
159+ .slice(0, limit)
160+ .map((entry) => {
161+ const preferredPath = entry.preferredPath ?? entry.path;
162+ return {
163+ path: preferredPath,
164+ source: entry.source,
165+ approval: entry.approval,
166+ description: entry.description,
167+ input: normalizeHint(entry.displayInput, "{}"),
168+ output: normalizeHint(entry.displayOutput, "unknown"),
169+ // required keys are encoded in the `input` type hint
170+ };
171+ });
172+
173+ return await finalizeImmediateTool({ results, total: results.length });
174+ }
175+
176+ // discover
177+ const query = String(payload.query ?? "").trim();
178+ const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
179+ const compact = payload.compact === false ? false : true;
180+ const hits = await ctx.runQuery(toolRegistry.searchTools, {
181+ workspaceId: task.workspaceId,
182+ buildId,
183+ query,
184+ limit: Math.max(limit * 2, limit),
185+ }) as RegistryToolEntry[];
127186
128− const results = (raw as RegistryToolEntry[])
129− .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
187+ const filtered = hits
130188 .filter((entry) => isAllowed(entry.path, entry.approval))
131− .slice(0, limit)
132− .map((entry) => {
133− const preferredPath = entry.preferredPath ?? entry.path;
134− return {
135− path: preferredPath,
136− source: entry.source,
137− approval: entry.approval,
138− description: entry.description,
139− input: normalizeHint(entry.displayInput, "{}"),
140− output: normalizeHint(entry.displayOutput, "unknown"),
141− // required keys are encoded in the `input` type hint
142− };
143− });
144−
145− return { results, total: results.length };
146− }
147−
148− // discover
149− const query = String(payload.query ?? "").trim();
150− const limit = Math.max(1, Math.min(50, Number(payload.limit ?? 8)));
151− const compact = payload.compact === false ? false : true;
152− const hits = await ctx.runQuery(toolRegistry.searchTools, {
153− workspaceId: task.workspaceId,
154− buildId,
155− query,
156− limit: Math.max(limit * 2, limit),
157− }) as RegistryToolEntry[];
158−
159− const filtered = hits
160− .filter((entry) => isAllowed(entry.path, entry.approval))
161− .slice(0, limit);
162−
163− const results = filtered.map((entry) => {
164− const preferredPath = entry.preferredPath ?? entry.path;
165− const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description;
166− return {
167− path: preferredPath,
168− source: entry.source,
169− approval: entry.approval,
170− description,
171− input: normalizeHint(entry.displayInput, "{}"),
172− output: normalizeHint(entry.displayOutput, "unknown"),
173− // required keys are encoded in the `input` type hint
174− };
175− });
176−
177− const bestPath = results[0]?.path ?? null;
178− return {
179− bestPath,
180− results,
181− total: results.length,
182− };
183− }
184−
185− let { tool, resolvedToolPath } = await resolveToolForCall(ctx, task, toolPath);
189+ .slice(0, limit);
190+
191+ const results = filtered.map((entry) => {
192+ const preferredPath = entry.preferredPath ?? entry.path;
193+ const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description;
194+ return {
195+ path: preferredPath,
196+ source: entry.source,
197+ approval: entry.approval,
198+ description,
199+ input: normalizeHint(entry.displayInput, "{}"),
200+ output: normalizeHint(entry.displayOutput, "unknown"),
201+ // required keys are encoded in the `input` type hint
202+ };
203+ });
186204
187− let decision: PolicyDecision;
188− let effectiveToolPath = resolvedToolPath;
189− if (tool._graphqlSource) {
190− const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
191− decision = result.decision;
192− if (result.effectivePaths.length > 0) {
193− effectiveToolPath = result.effectivePaths.join(", ");
205+ const bestPath = results[0]?.path ?? null;
206+ return await finalizeImmediateTool({
207+ bestPath,
208+ results,
209+ total: results.length,
210+ });
194211 }
195− } else {
196− decision = getToolDecision(task, tool, typedPolicies);
197− }
198−
199− const publishToolStarted = persistedCall.status === "requested";
200212
201− if (decision === "deny") {
202− const deniedMessage = `${effectiveToolPath} (policy denied)`;
203− await denyToolCall(ctx, {
204− task,
205− callId,
206− toolPath: effectiveToolPath,
207− deniedMessage,
208− reason: "policy_deny",
209− });
210− }
211−
212− let credential: ResolvedToolCredential | undefined;
213− if (tool.credential) {
214− const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
215− if (!resolved) {
216− throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
213+ const resolvedToolResult = await resolveToolForCall(ctx, task, toolPath);
214+ if (resolvedToolResult.isErr()) {
215+ throw resolvedToolResult.error;
217216 }
218− credential = resolved;
219− }
220−
221− if (publishToolStarted) {
222− await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
223− taskId: task.id,
224− callId,
225− toolPath: effectiveToolPath,
226− approval: decision === "require_approval" ? "required" : "auto",
227− });
228− }
229−
230− let approvalSatisfied = false;
231− if (persistedCall.approvalId) {
232− const existingApproval = await ctx.runQuery(internal.database.getApproval, {
233− approvalId: persistedCall.approvalId,
234− });
235− if (!existingApproval) {
236− throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);
217+ const { tool, resolvedToolPath } = resolvedToolResult.value;
218+
219+ let decision: PolicyDecision;
220+ effectiveToolPath = resolvedToolPath;
221+ if (tool._graphqlSource) {
222+ const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
223+ decision = result.decision;
224+ if (result.effectivePaths.length > 0) {
225+ effectiveToolPath = result.effectivePaths.join(", ");
226+ }
227+ } else {
228+ decision = getToolDecision(task, tool, typedPolicies);
237229 }
238230
239− if (existingApproval.status === "pending") {
240− throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
241− }
231+ const publishToolStarted = persistedCall.status === "requested";
242232
243− if (existingApproval.status === "denied") {
244− await denyToolCallForApproval(ctx, {
233+ if (decision === "deny") {
234+ const deniedMessage = `${effectiveToolPath} (policy denied)`;
235+ await denyToolCall(ctx, {
245236 task,
246237 callId,
247238 toolPath: effectiveToolPath,
248− approvalId: existingApproval.id,
239+ deniedMessage,
240+ reason: "policy_deny",
249241 });
250242 }
251243
252− approvalSatisfied = existingApproval.status === "approved";
253− }
254−
255− if (decision === "require_approval" && !approvalSatisfied) {
256− const approvalId = persistedCall.approvalId ?? createApprovalId();
257− let approval = await ctx.runQuery(internal.database.getApproval, {
258− approvalId,
259− });
244+ let credential: ResolvedToolCredential | undefined;
245+ if (tool.credential) {
246+ const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
247+ if (!resolved) {
248+ throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
249+ }
250+ credential = resolved;
251+ }
260252
261− if (!approval) {
262− approval = await ctx.runMutation(internal.database.createApproval, {
263− id: approvalId,
253+ if (publishToolStarted) {
254+ await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
264255 taskId: task.id,
256+ callId,
265257 toolPath: effectiveToolPath,
266− input,
258+ approval: decision === "require_approval" ? "required" : "auto",
267259 });
260+ }
268261
269− await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {
270− approvalId: approval.id,
271− taskId: task.id,
272− callId,
273− toolPath: approval.toolPath,
274− input: asPayload(approval.input),
275− createdAt: approval.createdAt,
262+ let approvalSatisfied = false;
263+ if (persistedCall.approvalId) {
264+ const existingApproval = await ctx.runQuery(internal.database.getApproval, {
265+ approvalId: persistedCall.approvalId,
276266 });
267+ if (!existingApproval) {
268+ throw new Error(`Approval ${persistedCall.approvalId} not found for call ${callId}`);
269+ }
270+
271+ if (existingApproval.status === "pending") {
272+ throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
273+ }
274+
275+ if (existingApproval.status === "denied") {
276+ await denyToolCallForApproval(ctx, {
277+ task,
278+ callId,
279+ toolPath: effectiveToolPath,
280+ approvalId: existingApproval.id,
281+ });
282+ }
283+
284+ approvalSatisfied = existingApproval.status === "approved";
277285 }
278286
279− await ctx.runMutation(internal.database.setToolCallPendingApproval, {
280− taskId: task.id,
281− callId,
282− approvalId: approval.id,
283− });
287+ if (decision === "require_approval" && !approvalSatisfied) {
288+ const approvalId = persistedCall.approvalId ?? createApprovalId();
289+ let approval = await ctx.runQuery(internal.database.getApproval, {
290+ approvalId,
291+ });
284292
285− if (approval.status === "pending") {
286− throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);
287− }
293+ if (!approval) {
294+ approval = await ctx.runMutation(internal.database.createApproval, {
295+ id: approvalId,
296+ taskId: task.id,
297+ toolPath: effectiveToolPath,
298+ input,
299+ });
288300
289− if (approval.status === "denied") {
290− await denyToolCallForApproval(ctx, {
291− task,
301+ await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {
302+ approvalId: approval.id,
303+ taskId: task.id,
304+ callId,
305+ toolPath: approval.toolPath,
306+ input: asPayload(approval.input),
307+ createdAt: approval.createdAt,
308+ });
309+ }
310+
311+ await ctx.runMutation(internal.database.setToolCallPendingApproval, {
312+ taskId: task.id,
292313 callId,
293− toolPath: effectiveToolPath,
294314 approvalId: approval.id,
295315 });
316+
317+ if (approval.status === "pending") {
318+ throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);
319+ }
320+
321+ if (approval.status === "denied") {
322+ await denyToolCallForApproval(ctx, {
323+ task,
324+ callId,
325+ toolPath: effectiveToolPath,
326+ approvalId: approval.id,
327+ });
328+ }
296329 }
297− }
298330
299− try {
300331 const context: ToolRunContext = {
301332 taskId: task.id,
302333 workspaceId: task.workspaceId,
@@ -315,12 +346,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 12 unmodified lines
315346 return value;
316347 } catch (error) {
317348 const message = describeError(error);
318− await failToolCall(ctx, {
319− taskId: task.id,
320− callId,
321− error: message,
322− toolPath: effectiveToolPath,
323− });
349+ const isApprovalControlSignal =
350+ message.startsWith(APPROVAL_PENDING_PREFIX) ||
351+ message.startsWith(APPROVAL_DENIED_PREFIX);
352+
353+ if (!isApprovalControlSignal) {
354+ await failToolCall(ctx, {
355+ taskId: task.id,
356+ callId,
357+ error: message,
358+ toolPath: effectiveToolPath,
359+ });
360+ }
361+
324362 throw error;
325363 }
326364 }
executor/packages/database/src/runtime/tool_registry_state.ts+42−3
@@ -3,6 +3,7 @@ · 2 unmodified lines
33 import type { ActionCtx } from "../../convex/_generated/server";
44 import type { Id } from "../../convex/_generated/dataModel.d.ts";
55 import { internal } from "../../convex/_generated/api";
6+import { asPayload } from "../lib/object";
67 import { sourceSignature } from "./tool_source_loading";
78
89 export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
@@ -26,14 +27,52 @@ type ToolSourceState = { · 17 unmodified lines
2627 enabled: boolean;
2728 };
2829
30+function toRegistryState(value: unknown): RegistryState {
31+ if (!value || typeof value !== "object" || Array.isArray(value)) {
32+ return null;
33+ }
34+
35+ const state = asPayload(value);
36+ if (typeof state.signature !== "string") {
37+ return null;
38+ }
39+
40+ return {
41+ signature: state.signature,
42+ readyBuildId: typeof state.readyBuildId === "string" ? state.readyBuildId : undefined,
43+ };
44+}
45+
46+function toToolSourceStateList(value: unknown): ToolSourceState[] {
47+ if (!Array.isArray(value)) {
48+ return [];
49+ }
50+
51+ const normalized: ToolSourceState[] = [];
52+ for (const entry of value) {
53+ const record = asPayload(entry);
54+ if (typeof record.id !== "string") continue;
55+ if (typeof record.updatedAt !== "number") continue;
56+ normalized.push({
57+ id: record.id,
58+ updatedAt: record.updatedAt,
59+ enabled: record.enabled !== false,
60+ });
61+ }
62+
63+ return normalized;
64+}
65+
2966 async function readRegistryState(
3067 ctx: Pick<ActionCtx, "runQuery">,
3168 workspaceId: Id<"workspaces">,
3269 ): Promise<{ buildId?: string; isReady: boolean }> {
33− const [state, sources] = await Promise.all([
34− ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>,
35− ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>,
70+ const [rawState, rawSources] = await Promise.all([
71+ ctx.runQuery(internal.toolRegistry.getState, { workspaceId }),
72+ ctx.runQuery(internal.database.listToolSources, { workspaceId }),
3673 ]);
74+ const state = toRegistryState(rawState);
75+ const sources = toToolSourceStateList(rawSources);
3776
3877 const expectedSignature = registrySignatureForWorkspace(workspaceId, sources);
3978 const buildId = state?.readyBuildId;
executor/packages/database/src/runtime/tool_source_loading.ts+47−27
@@ -1,5 +1,6 @@
11 "use node";
22
3+import { Result } from "better-result";
34 import type { ActionCtx } from "../../convex/_generated/server";
45 import { internal } from "../../convex/_generated/api";
56 import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -40,58 +41,77 @@ export function normalizeExternalToolSource(raw: { · 34 unmodified lines
4041 type: ToolSourceRecord["type"];
4142 name: string;
4243 config: Record<string, unknown>;
43−}): ExternalToolSourceConfig {
44− const configResult = normalizeToolSourceConfig(raw.type, raw.config);
45− if (configResult.isErr()) {
46− throw new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`);
47− }
48− const config = configResult.value;
44+}): Result<ExternalToolSourceConfig, Error> {
4945
5046 if (raw.type === "mcp") {
47+ const configResult = normalizeToolSourceConfig("mcp", raw.config);
48+ if (configResult.isErr()) {
49+ return Result.err(
50+ new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),
51+ );
52+ }
53+ const config = configResult.value;
5154 const result: McpToolSourceConfig = {
5255 type: "mcp",
5356 name: raw.name,
5457 sourceId: raw.id,
5558 sourceKey: `source:${raw.id}`,
56− url: String(config.url),
57− auth: config.auth as McpToolSourceConfig["auth"],
58− transport: config.transport as McpToolSourceConfig["transport"],
59− queryParams: config.queryParams as McpToolSourceConfig["queryParams"],
60− defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],
61− overrides: config.overrides as McpToolSourceConfig["overrides"],
59+ url: config.url,
60+ auth: config.auth,
61+ transport: config.transport,
62+ queryParams: config.queryParams,
63+ defaultApproval: config.defaultApproval,
64+ overrides: config.overrides,
6265 };
63− return result;
66+ return Result.ok(result);
6467 }
6568
6669 if (raw.type === "graphql") {
70+ const configResult = normalizeToolSourceConfig("graphql", raw.config);
71+ if (configResult.isErr()) {
72+ return Result.err(
73+ new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),
74+ );
75+ }
76+ const config = configResult.value;
6777 const result: GraphqlToolSourceConfig = {
6878 type: "graphql",
6979 name: raw.name,
7080 sourceId: raw.id,
7181 sourceKey: `source:${raw.id}`,
72− endpoint: String(config.endpoint),
73− schema: config.schema as GraphqlToolSourceConfig["schema"],
74− auth: config.auth as GraphqlToolSourceConfig["auth"],
75− defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],
76− defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"],
77− overrides: config.overrides as GraphqlToolSourceConfig["overrides"],
82+ endpoint: config.endpoint,
83+ schema: config.schema,
84+ auth: config.auth,
85+ defaultQueryApproval: config.defaultQueryApproval,
86+ defaultMutationApproval: config.defaultMutationApproval,
87+ overrides: config.overrides,
7888 };
79− return result;
89+ return Result.ok(result);
90+ }
91+
92+ const configResult = normalizeToolSourceConfig("openapi", raw.config);
93+ if (configResult.isErr()) {
94+ return Result.err(
95+ new Error(`Failed to normalize '${raw.name}' source config: ${configResult.error.message}`),
96+ );
8097 }
98+ const config = configResult.value;
8199
82100 const result: OpenApiToolSourceConfig = {
83101 type: "openapi",
84102 name: raw.name,
85103 sourceId: raw.id,
86104 sourceKey: `source:${raw.id}`,
87− spec: config.spec as OpenApiToolSourceConfig["spec"],
88− baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],
89− auth: config.auth as OpenApiToolSourceConfig["auth"],
90− defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],
91− defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"],
92− overrides: config.overrides as OpenApiToolSourceConfig["overrides"],
105+ spec: config.spec,
106+ collectionUrl: config.collectionUrl,
107+ postmanProxyUrl: config.postmanProxyUrl,
108+ baseUrl: config.baseUrl,
109+ auth: config.auth,
110+ defaultReadApproval: config.defaultReadApproval,
111+ defaultWriteApproval: config.defaultWriteApproval,
112+ overrides: config.overrides,
93113 };
94− return result;
114+ return Result.ok(result);
95115 }
96116
97117 function buildHeadersFromCredentialSecret(
executor/packages/database/src/runtime/workspace_tools.ts+9−8
@@ -26,6 +26,7 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 25 unmodified lines
2626 import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
2727 import { registrySignatureForWorkspace } from "./tool_registry_state";
2828 import { normalizeToolPathForLookup } from "./tool_paths";
29+import { asPayload } from "../lib/object";
2930
3031 const baseTools = new Map<string, ToolDefinition>();
3132
@@ -58,7 +59,7 @@ baseTools.set("admin.send_announcement", { · 26 unmodified lines
5859 },
5960 },
6061 run: async (input: unknown) => {
61− const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};
62+ const payload = asPayload(input);
6263 const channel = typeof payload.channel === "string" ? payload.channel : "";
6364 const message = typeof payload.message === "string" ? payload.message : "";
6465 return { ok: true, channel, message };
@@ -365,7 +366,7 @@ function asJsonSchema(value: unknown): JsonSchema { · 300 unmodified lines
365366 if (!value || typeof value !== "object" || Array.isArray(value)) {
366367 return {};
367368 }
368− return value as JsonSchema;
369+ return asPayload(value);
369370 }
370371
371372 async function buildWorkspaceToolRegistry(
@@ -513,7 +514,7 @@ export async function getWorkspaceTools( · 141 unmodified lines
513514 if (source.type !== "mcp") {
514515 return false;
515516 }
516− const auth = source.config.auth as Record<string, unknown> | undefined;
517+ const auth = asPayload(source.config.auth);
517518 return auth?.mode === "actor";
518519 });
519520 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
@@ -598,12 +599,12 @@ export async function getWorkspaceTools( · 78 unmodified lines
598599 const warnings: string[] = [];
599600 const normalizeSourcesStartedAt = Date.now();
600601 for (const source of sources) {
601− try {
602− configs.push(normalizeExternalToolSource(source));
603− } catch (error) {
604− const message = error instanceof Error ? error.message : String(error);
605− warnings.push(`Source '${source.name}': ${message}`);
602+ const normalizedResult = normalizeExternalToolSource(source);
603+ if (normalizedResult.isErr()) {
604+ warnings.push(`Source '${source.name}': ${normalizedResult.error.message}`);
605+ continue;
606606 }
607+ configs.push(normalizedResult.value);
607608 }
608609 traceStep("normalizeSources", normalizeSourcesStartedAt);
609610