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
163// Input schema — when context is bound, workspace fields aren't needed163// Input schema — when context is bound, workspace fields aren't needed
164// ---------------------------------------------------------------------------164// ---------------------------------------------------------------------------
165 165
166function toAnySchema(schema: unknown): AnySchema {166const FULL_INPUT: AnySchema = z.object({
167 return schema as AnySchema;
168}
169
170const FULL_INPUT = toAnySchema(z.object({
171 code: z.string().min(1),167 code: z.string().min(1),
172 timeoutMs: z.number().int().min(1).max(600_000).optional(),168 timeoutMs: z.number().int().min(1).max(600_000).optional(),
173 runtimeId: z.string().optional(),169 runtimeId: z.string().optional(),
@@ -176,14 +172,14 @@ const FULL_INPUT = toAnySchema(z.object({ · 2 unmodified lines
176 sessionId: z.string().optional(),172 sessionId: z.string().optional(),
177 waitForResult: z.boolean().optional(),173 waitForResult: z.boolean().optional(),
178 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),174 resultTimeoutMs: z.number().int().min(100).max(900_000).optional(),
179}));175});
180 176
181const BOUND_INPUT = toAnySchema(z.object({177const BOUND_INPUT: AnySchema = z.object({
182 code: z.string().min(1),178 code: z.string().min(1),
183 timeoutMs: z.number().int().min(1).max(600_000).optional(),179 timeoutMs: z.number().int().min(1).max(600_000).optional(),
184 runtimeId: z.string().optional(),180 runtimeId: z.string().optional(),
185 metadata: z.record(z.string(), z.any()).optional(),181 metadata: z.record(z.string(), z.any()).optional(),
186}));182});
187 183
188// ---------------------------------------------------------------------------184// ---------------------------------------------------------------------------
189// MCP server factory185// MCP server factory
@@ -207,13 +203,7 @@ async function createMcpServer( · 17 unmodified lines
207 },203 },
208 );204 );
209 const onApprovalPrompt = createMcpApprovalPrompt(mcp);205 const onApprovalPrompt = createMcpApprovalPrompt(mcp);
210 const registerTool = (mcp.registerTool as (206 mcp.registerTool(
211 name: string,
212 config: { description: string; inputSchema: AnySchema },
213 cb: ReturnType<typeof createRunCodeTool>,
214 ) => void).bind(mcp);
215
216 registerTool(
217 "run_code",207 "run_code",
218 {208 {
219 description: buildRunCodeDescription(),209 description: buildRunCodeDescription(),
executor/packages/database/convex/database/task_events.ts+1−3
@@ -17,9 +17,7 @@ export const createTaskEvent = internalMutation({ · 16 unmodified lines
17 throw new Error(`Task not found for event: ${args.taskId}`);17 throw new Error(`Task not found for event: ${args.taskId}`);
18 }18 }
19 19
20 const currentSequence = typeof (task as { nextEventSequence?: unknown }).nextEventSequence === "number"20 const currentSequence = task.nextEventSequence ?? 0;
21 ? (task as { nextEventSequence: number }).nextEventSequence
22 : 0;
23 const sequence = currentSequence + 1;21 const sequence = currentSequence + 1;
24 const createdAt = Date.now();22 const createdAt = Date.now();
25 23
executor/packages/database/src/database/tool_source_config.test.ts+51−0
@@ -0,0 +1,51 @@
1import { expect, test } from "bun:test";
2import { normalizeExternalToolSource } from "../runtime/tool_source_loading";
3import { normalizeToolSourceConfig } from "./tool_source_config";
4
5test("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
14test("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
37test("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 @@
1import { Result } from "better-result";1import { Result } from "better-result";
2import type { OpenApiAuth } from "../../../core/src/tool/source-types";2import type {
3 GraphqlToolSourceConfig,
4 McpToolSourceConfig,
5 OpenApiAuth,
6 OpenApiToolSourceConfig,
7} from "../../../core/src/tool/source-types";
3import type { ToolApprovalMode } from "../../../core/src/types";8import type { ToolApprovalMode } from "../../../core/src/types";
4import { asRecord } from "../lib/object";9import { asRecord } from "../lib/object";
5 10
6type ToolSourceType = "mcp" | "openapi" | "graphql";11export type ToolSourceType = "mcp" | "openapi" | "graphql";
12
13export type NormalizedMcpToolSourceConfig = Pick<
14 McpToolSourceConfig,
15 "url" | "auth" | "discoveryHeaders" | "transport" | "queryParams" | "defaultApproval" | "overrides"
16>;
17
18export type NormalizedGraphqlToolSourceConfig = Pick<
19 GraphqlToolSourceConfig,
20 "endpoint" | "schema" | "auth" | "defaultQueryApproval" | "defaultMutationApproval" | "overrides"
21>;
22
23export type NormalizedOpenApiToolSourceConfig = Pick<
24 OpenApiToolSourceConfig,
25 "spec" | "collectionUrl" | "postmanProxyUrl" | "baseUrl" | "auth" | "defaultReadApproval" | "defaultWriteApproval" | "overrides"
26>;
27
28export type NormalizedToolSourceConfig =
29 | NormalizedMcpToolSourceConfig
30 | NormalizedGraphqlToolSourceConfig
31 | NormalizedOpenApiToolSourceConfig;
7 32
8function optionalTrimmedString(value: unknown): string | undefined {33function optionalTrimmedString(value: unknown): string | undefined {
9 if (typeof value !== "string") return undefined;34 if (typeof value !== "string") return undefined;
@@ -164,10 +189,26 @@ function normalizeSpec( · 154 unmodified lines
164 return Result.ok(specObject);189 return Result.ok(specObject);
165}190}
166 191
192export function normalizeToolSourceConfig(
193 type: "mcp",
194 rawConfig: unknown,
195): Result<NormalizedMcpToolSourceConfig, Error>;
196export function normalizeToolSourceConfig(
197 type: "graphql",
198 rawConfig: unknown,
199): Result<NormalizedGraphqlToolSourceConfig, Error>;
200export function normalizeToolSourceConfig(
201 type: "openapi",
202 rawConfig: unknown,
203): Result<NormalizedOpenApiToolSourceConfig, Error>;
204export function normalizeToolSourceConfig(
205 type: ToolSourceType,
206 rawConfig: unknown,
207): Result<NormalizedToolSourceConfig, Error>;
167export function normalizeToolSourceConfig(208export function normalizeToolSourceConfig(
168 type: ToolSourceType,209 type: ToolSourceType,
169 rawConfig: unknown,210 rawConfig: unknown,
170): Result<Record<string, unknown>, Error> {211): Result<NormalizedToolSourceConfig, Error> {
171 const config = asRecord(rawConfig);212 const config = asRecord(rawConfig);
172 213
173 if (type === "mcp") {214 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
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { resolveCredentialPayload } from "../../../core/src/credential-providers";5import { resolveCredentialPayload } from "../../../core/src/credential-providers";
6import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";6import { APPROVAL_DENIED_PREFIX } from "../../../core/src/execution-constants";
7import type { CredentialScope, ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";7import type { ResolvedToolCredential, TaskRecord, ToolCallRecord, ToolCredentialSpec } from "../../../core/src/types";
8import { asPayload } from "../lib/object";8import { asPayload } from "../lib/object";
9 9
10export async function resolveCredentialHeaders(10export async function resolveCredentialHeaders(
@@ -15,7 +15,7 @@ export async function resolveCredentialHeaders( · 4 unmodified lines
15 const record = await ctx.runQuery(internal.database.resolveCredential, {15 const record = await ctx.runQuery(internal.database.resolveCredential, {
16 workspaceId: task.workspaceId,16 workspaceId: task.workspaceId,
17 sourceKey: spec.sourceKey,17 sourceKey: spec.sourceKey,
18 scope: spec.mode as CredentialScope,18 scope: spec.mode,
19 actorId: task.actorId,19 actorId: task.actorId,
20 });20 });
21 21
@@ -25,25 +25,26 @@ export async function resolveCredentialHeaders( · 3 unmodified lines
25 if (!source) {25 if (!source) {
26 return null;26 return null;
27 }27 }
28 const sourcePayload = asPayload(source);
28 29
29 const headers: Record<string, string> = {};30 const headers: Record<string, string> = {};
30 if (spec.authType === "bearer") {31 if (spec.authType === "bearer") {
31 const token = String((source as Record<string, unknown>).token ?? "").trim();32 const token = String(sourcePayload.token ?? "").trim();
32 if (token) headers.authorization = `Bearer ${token}`;33 if (token) headers.authorization = `Bearer ${token}`;
33 } else if (spec.authType === "apiKey") {34 } else if (spec.authType === "apiKey") {
34 const headerName = spec.headerName ?? String((source as Record<string, unknown>).headerName ?? "x-api-key");35 const headerName = spec.headerName ?? String(sourcePayload.headerName ?? "x-api-key");
35 const value = String((source as Record<string, unknown>).value ?? (source as Record<string, unknown>).token ?? "").trim();36 const value = String(sourcePayload.value ?? sourcePayload.token ?? "").trim();
36 if (value) headers[headerName] = value;37 if (value) headers[headerName] = value;
37 } else if (spec.authType === "basic") {38 } else if (spec.authType === "basic") {
38 const username = String((source as Record<string, unknown>).username ?? "");39 const username = String(sourcePayload.username ?? "");
39 const password = String((source as Record<string, unknown>).password ?? "");40 const password = String(sourcePayload.password ?? "");
40 if (username || password) {41 if (username || password) {
41 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");42 const encoded = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
42 headers.authorization = `Basic ${encoded}`;43 headers.authorization = `Basic ${encoded}`;
43 }44 }
44 }45 }
45 46
46 const bindingOverrides = asPayload((record?.overridesJson as unknown) ?? {});47 const bindingOverrides = asPayload(record?.overridesJson ?? {});
47 const overrideHeaders = asPayload(bindingOverrides.headers);48 const overrideHeaders = asPayload(bindingOverrides.headers);
48 for (const [key, value] of Object.entries(overrideHeaders)) {49 for (const [key, value] of Object.entries(overrideHeaders)) {
49 if (!key) continue;50 if (!key) continue;
executor/packages/database/src/runtime/tool_call_resolution.ts+16−8
@@ -1,10 +1,12 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
3import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
5import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";6import { parseGraphqlOperationPaths } from "../../../core/src/graphql/operation-paths";
6import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";7import type { AccessPolicyRecord, PolicyDecision, TaskRecord, ToolDefinition } from "../../../core/src/types";
7import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";8import { rehydrateTools, type SerializedTool } from "../../../core/src/tool/source-serialization";
9import { asPayload } from "../lib/object";
8import { getDecisionForContext, getToolDecision } from "./policy";10import { getDecisionForContext, getToolDecision } from "./policy";
9import { getReadyRegistryBuildId } from "./tool_registry_state";11import { getReadyRegistryBuildId } from "./tool_registry_state";
10import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";12import { normalizeToolPathForLookup, toPreferredToolPath } from "./tool_paths";
@@ -18,7 +20,7 @@ export function getGraphqlDecision( · 7 unmodified lines
18 policies: AccessPolicyRecord[],20 policies: AccessPolicyRecord[],
19): { decision: PolicyDecision; effectivePaths: string[] } {21): { decision: PolicyDecision; effectivePaths: string[] } {
20 const sourceName = tool._graphqlSource!;22 const sourceName = tool._graphqlSource!;
21 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};23 const payload = asPayload(input);
22 const queryString = typeof payload.query === "string" ? payload.query : "";24 const queryString = typeof payload.query === "string" ? payload.query : "";
23 25
24 if (!queryString.trim()) {26 if (!queryString.trim()) {
@@ -95,13 +97,13 @@ export async function resolveToolForCall( · 70 unmodified lines
95 ctx: ActionCtx,97 ctx: ActionCtx,
96 task: TaskRecord,98 task: TaskRecord,
97 toolPath: string,99 toolPath: string,
98): Promise<{100): Promise<Result<{
99 tool: ToolDefinition;101 tool: ToolDefinition;
100 resolvedToolPath: string;102 resolvedToolPath: string;
101}> {103}, Error>> {
102 const builtin = baseTools.get(toolPath);104 const builtin = baseTools.get(toolPath);
103 if (builtin) {105 if (builtin) {
104 return { tool: builtin, resolvedToolPath: toolPath };106 return Result.ok({ tool: builtin, resolvedToolPath: toolPath });
105 }107 }
106 108
107 const buildId = await getReadyRegistryBuildId(ctx, {109 const buildId = await getReadyRegistryBuildId(ctx, {
@@ -137,14 +139,20 @@ export async function resolveToolForCall( · 29 unmodified lines
137 139
138 if (!entry) {140 if (!entry) {
139 const suggestions = await suggestFromRegistry(ctx, task.workspaceId, buildId, toolPath);141 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)));
141 }143 }
142 144
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;
144 const [tool] = rehydrateTools([serialized], baseTools);152 const [tool] = rehydrateTools([serialized], baseTools);
145 if (!tool) {153 if (!tool) {
146 throw new Error(`Failed to rehydrate tool: ${resolvedToolPath}`);154 return Result.err(new Error(`Failed to rehydrate tool: ${resolvedToolPath}`));
147 }155 }
148 156
149 return { tool, resolvedToolPath };157 return Result.ok({ tool, resolvedToolPath });
150}158}
executor/packages/database/src/runtime/tool_invocation.ts+238−200
@@ -2,7 +2,10 @@ · 1 unmodified line
2 2
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";4import { internal } from "../../convex/_generated/api";
5import { APPROVAL_PENDING_PREFIX } from "../../../core/src/execution-constants";5import {
6 APPROVAL_DENIED_PREFIX,
7 APPROVAL_PENDING_PREFIX,
8} from "../../../core/src/execution-constants";
6import type {9import type {
7 AccessPolicyRecord,10 AccessPolicyRecord,
8 PolicyDecision,11 PolicyDecision,
@@ -66,237 +69,265 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 57 unmodified lines
66 })) as ToolCallRecord;69 })) as ToolCallRecord;
67 assertPersistedCallRunnable(persistedCall, callId);70 assertPersistedCallRunnable(persistedCall, callId);
68 71
69 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });72 let effectiveToolPath = toolPath;
70 const typedPolicies = policies as AccessPolicyRecord[];73 try {
71 74 const policies = await ctx.runQuery(internal.database.listAccessPolicies, { workspaceId: task.workspaceId });
72 // Fast system tools are handled server-side from the registry.75 const typedPolicies = policies as AccessPolicyRecord[];
73 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {76 const finalizeImmediateTool = async (value: unknown): Promise<unknown> => {
74 const toolRegistry = internal.toolRegistry;77 if (persistedCall.status === "requested") {
75 const buildId = await getReadyRegistryBuildId(ctx, {78 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
76 workspaceId: task.workspaceId,79 taskId: task.id,
77 actorId: task.actorId,80 callId,
78 clientId: task.clientId,81 toolPath,
79 refreshOnStale: true,82 approval: "auto",
80 });83 });
81 84 }
82 const payload = typeof input === "string"85 await completeToolCall(ctx, {
83 ? { query: input }86 taskId: task.id,
84 : (input && typeof input === "object" ? (input as Record<string, unknown>) : {});87 callId,
85 const isAllowed = (path: string, approval: ToolDefinition["approval"]) =>88 toolPath,
86 getDecisionForContext(89 });
87 { path, approval, description: "", run: async () => null } as ToolDefinition,90 return value;
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;
95 };91 };
96 92
97 if (toolPath === "catalog.namespaces") {93 // Fast system tools are handled server-side from the registry.
98 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 200)));94 if (toolPath === "discover" || toolPath === "catalog.namespaces" || toolPath === "catalog.tools") {
99 const namespaces = await ctx.runQuery(toolRegistry.listNamespaces, {95 const toolRegistry = internal.toolRegistry;
96 const buildId = await getReadyRegistryBuildId(ctx, {
100 workspaceId: task.workspaceId,97 workspaceId: task.workspaceId,
101 buildId,98 actorId: task.actorId,
102 limit,99 clientId: task.clientId,
103 }) as Array<{ namespace: string; toolCount: number; samplePaths: string[] }>;100 refreshOnStale: true,
104 return { namespaces, total: namespaces.length };101 });
105 }
106 102
107 if (toolPath === "catalog.tools") {103 const payload = typeof input === "string"
108 const namespace = String(payload.namespace ?? "").trim().toLowerCase();104 ? { query: input }
109 const query = String(payload.query ?? "").trim();105 : asPayload(input);
110 const limit = Math.max(1, Math.min(200, Number(payload.limit ?? 50)));106 const isAllowed = (path: string, approval: ToolDefinition["approval"]) => {
111 107 const policyProbeTool: ToolDefinition = {
112 const raw = query108 path,
113 ? await ctx.runQuery(toolRegistry.searchTools, {109 approval,
114 workspaceId: task.workspaceId,110 description: "",
115 buildId,111 run: async () => null,
116 query,112 };
117 limit,113 return getDecisionForContext(
118 })114 policyProbeTool,
119 : namespace115 { workspaceId: task.workspaceId, actorId: task.actorId, clientId: task.clientId },
120 ? await ctx.runQuery(toolRegistry.listToolsByNamespace, {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, {
121 workspaceId: task.workspaceId,142 workspaceId: task.workspaceId,
122 buildId,143 buildId,
123 namespace,144 query,
124 limit,145 limit,
125 })146 })
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[];
127 186
128 const results = (raw as RegistryToolEntry[])187 const filtered = hits
129 .filter((entry) => !namespace || String(entry.preferredPath ?? entry.path ?? "").toLowerCase().startsWith(`${namespace}.`))
130 .filter((entry) => isAllowed(entry.path, entry.approval))188 .filter((entry) => isAllowed(entry.path, entry.approval))
131 .slice(0, limit)189 .slice(0, limit);
132 .map((entry) => {190
133 const preferredPath = entry.preferredPath ?? entry.path;191 const results = filtered.map((entry) => {
134 return {192 const preferredPath = entry.preferredPath ?? entry.path;
135 path: preferredPath,193 const description = compact ? String(entry.description ?? "").split("\n")[0] : entry.description;
136 source: entry.source,194 return {
137 approval: entry.approval,195 path: preferredPath,
138 description: entry.description,196 source: entry.source,
139 input: normalizeHint(entry.displayInput, "{}"),197 approval: entry.approval,
140 output: normalizeHint(entry.displayOutput, "unknown"),198 description,
141 // required keys are encoded in the `input` type hint199 input: normalizeHint(entry.displayInput, "{}"),
142 };200 output: normalizeHint(entry.displayOutput, "unknown"),
143 });201 // required keys are encoded in the `input` type hint
144 202 };
145 return { results, total: results.length };203 });
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);
186 204
187 let decision: PolicyDecision;205 const bestPath = results[0]?.path ?? null;
188 let effectiveToolPath = resolvedToolPath;206 return await finalizeImmediateTool({
189 if (tool._graphqlSource) {207 bestPath,
190 const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);208 results,
191 decision = result.decision;209 total: results.length,
192 if (result.effectivePaths.length > 0) {210 });
193 effectiveToolPath = result.effectivePaths.join(", ");
194 }211 }
195 } else {
196 decision = getToolDecision(task, tool, typedPolicies);
197 }
198
199 const publishToolStarted = persistedCall.status === "requested";
200 212
201 if (decision === "deny") {213 const resolvedToolResult = await resolveToolForCall(ctx, task, toolPath);
202 const deniedMessage = `${effectiveToolPath} (policy denied)`;214 if (resolvedToolResult.isErr()) {
203 await denyToolCall(ctx, {215 throw resolvedToolResult.error;
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)`);
217 }216 }
218 credential = resolved;217 const { tool, resolvedToolPath } = resolvedToolResult.value;
219 }218
220 219 let decision: PolicyDecision;
221 if (publishToolStarted) {220 effectiveToolPath = resolvedToolPath;
222 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {221 if (tool._graphqlSource) {
223 taskId: task.id,222 const result = getGraphqlDecision(task, tool, input, undefined, typedPolicies);
224 callId,223 decision = result.decision;
225 toolPath: effectiveToolPath,224 if (result.effectivePaths.length > 0) {
226 approval: decision === "require_approval" ? "required" : "auto",225 effectiveToolPath = result.effectivePaths.join(", ");
227 });226 }
228 }227 } else {
229 228 decision = getToolDecision(task, tool, typedPolicies);
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}`);
237 }229 }
238 230
239 if (existingApproval.status === "pending") {231 const publishToolStarted = persistedCall.status === "requested";
240 throw new Error(`${APPROVAL_PENDING_PREFIX}${existingApproval.id}`);
241 }
242 232
243 if (existingApproval.status === "denied") {233 if (decision === "deny") {
244 await denyToolCallForApproval(ctx, {234 const deniedMessage = `${effectiveToolPath} (policy denied)`;
235 await denyToolCall(ctx, {
245 task,236 task,
246 callId,237 callId,
247 toolPath: effectiveToolPath,238 toolPath: effectiveToolPath,
248 approvalId: existingApproval.id,239 deniedMessage,
240 reason: "policy_deny",
249 });241 });
250 }242 }
251 243
252 approvalSatisfied = existingApproval.status === "approved";244 let credential: ResolvedToolCredential | undefined;
253 }245 if (tool.credential) {
254 246 const resolved = await resolveCredentialHeaders(ctx, tool.credential, task);
255 if (decision === "require_approval" && !approvalSatisfied) {247 if (!resolved) {
256 const approvalId = persistedCall.approvalId ?? createApprovalId();248 throw new Error(`Missing credential for source '${tool.credential.sourceKey}' (${tool.credential.mode} scope)`);
257 let approval = await ctx.runQuery(internal.database.getApproval, {249 }
258 approvalId,250 credential = resolved;
259 });251 }
260 252
261 if (!approval) {253 if (publishToolStarted) {
262 approval = await ctx.runMutation(internal.database.createApproval, {254 await publishTaskEvent(ctx, task.id, "task", "tool.call.started", {
263 id: approvalId,
264 taskId: task.id,255 taskId: task.id,
256 callId,
265 toolPath: effectiveToolPath,257 toolPath: effectiveToolPath,
266 input,258 approval: decision === "require_approval" ? "required" : "auto",
267 });259 });
260 }
268 261
269 await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {262 let approvalSatisfied = false;
270 approvalId: approval.id,263 if (persistedCall.approvalId) {
271 taskId: task.id,264 const existingApproval = await ctx.runQuery(internal.database.getApproval, {
272 callId,265 approvalId: persistedCall.approvalId,
273 toolPath: approval.toolPath,
274 input: asPayload(approval.input),
275 createdAt: approval.createdAt,
276 });266 });
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";
277 }285 }
278 286
279 await ctx.runMutation(internal.database.setToolCallPendingApproval, {287 if (decision === "require_approval" && !approvalSatisfied) {
280 taskId: task.id,288 const approvalId = persistedCall.approvalId ?? createApprovalId();
281 callId,289 let approval = await ctx.runQuery(internal.database.getApproval, {
282 approvalId: approval.id,290 approvalId,
283 });291 });
284 292
285 if (approval.status === "pending") {293 if (!approval) {
286 throw new Error(`${APPROVAL_PENDING_PREFIX}${approval.id}`);294 approval = await ctx.runMutation(internal.database.createApproval, {
287 }295 id: approvalId,
296 taskId: task.id,
297 toolPath: effectiveToolPath,
298 input,
299 });
288 300
289 if (approval.status === "denied") {301 await publishTaskEvent(ctx, task.id, "approval", "approval.requested", {
290 await denyToolCallForApproval(ctx, {302 approvalId: approval.id,
291 task,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,
292 callId,313 callId,
293 toolPath: effectiveToolPath,
294 approvalId: approval.id,314 approvalId: approval.id,
295 });315 });
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 }
296 }329 }
297 }
298 330
299 try {
300 const context: ToolRunContext = {331 const context: ToolRunContext = {
301 taskId: task.id,332 taskId: task.id,
302 workspaceId: task.workspaceId,333 workspaceId: task.workspaceId,
@@ -315,12 +346,19 @@ export async function invokeTool(ctx: ActionCtx, task: TaskRecord, call: ToolCal · 12 unmodified lines
315 return value;346 return value;
316 } catch (error) {347 } catch (error) {
317 const message = describeError(error);348 const message = describeError(error);
318 await failToolCall(ctx, {349 const isApprovalControlSignal =
319 taskId: task.id,350 message.startsWith(APPROVAL_PENDING_PREFIX) ||
320 callId,351 message.startsWith(APPROVAL_DENIED_PREFIX);
321 error: message,352
322 toolPath: effectiveToolPath,353 if (!isApprovalControlSignal) {
323 });354 await failToolCall(ctx, {
355 taskId: task.id,
356 callId,
357 error: message,
358 toolPath: effectiveToolPath,
359 });
360 }
361
324 throw error;362 throw error;
325 }363 }
326}364}
executor/packages/database/src/runtime/tool_registry_state.ts+42−3
@@ -3,6 +3,7 @@ · 2 unmodified lines
3import type { ActionCtx } from "../../convex/_generated/server";3import type { ActionCtx } from "../../convex/_generated/server";
4import type { Id } from "../../convex/_generated/dataModel.d.ts";4import type { Id } from "../../convex/_generated/dataModel.d.ts";
5import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
6import { asPayload } from "../lib/object";
6import { sourceSignature } from "./tool_source_loading";7import { sourceSignature } from "./tool_source_loading";
7 8
8export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";9export const TOOL_REGISTRY_SIGNATURE_PREFIX = "toolreg_v2|";
@@ -26,14 +27,52 @@ type ToolSourceState = { · 17 unmodified lines
26 enabled: boolean;27 enabled: boolean;
27};28};
28 29
30function 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
46function 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
29async function readRegistryState(66async function readRegistryState(
30 ctx: Pick<ActionCtx, "runQuery">,67 ctx: Pick<ActionCtx, "runQuery">,
31 workspaceId: Id<"workspaces">,68 workspaceId: Id<"workspaces">,
32): Promise<{ buildId?: string; isReady: boolean }> {69): Promise<{ buildId?: string; isReady: boolean }> {
33 const [state, sources] = await Promise.all([70 const [rawState, rawSources] = await Promise.all([
34 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }) as Promise<RegistryState>,71 ctx.runQuery(internal.toolRegistry.getState, { workspaceId }),
35 ctx.runQuery(internal.database.listToolSources, { workspaceId }) as Promise<ToolSourceState[]>,72 ctx.runQuery(internal.database.listToolSources, { workspaceId }),
36 ]);73 ]);
74 const state = toRegistryState(rawState);
75 const sources = toToolSourceStateList(rawSources);
37 76
38 const expectedSignature = registrySignatureForWorkspace(workspaceId, sources);77 const expectedSignature = registrySignatureForWorkspace(workspaceId, sources);
39 const buildId = state?.readyBuildId;78 const buildId = state?.readyBuildId;
executor/packages/database/src/runtime/tool_source_loading.ts+47−27
@@ -1,5 +1,6 @@
1"use node";1"use node";
2 2
3import { Result } from "better-result";
3import type { ActionCtx } from "../../convex/_generated/server";4import type { ActionCtx } from "../../convex/_generated/server";
4import { internal } from "../../convex/_generated/api";5import { internal } from "../../convex/_generated/api";
5import type { Id } from "../../convex/_generated/dataModel.d.ts";6import type { Id } from "../../convex/_generated/dataModel.d.ts";
@@ -40,58 +41,77 @@ export function normalizeExternalToolSource(raw: { · 34 unmodified lines
40 type: ToolSourceRecord["type"];41 type: ToolSourceRecord["type"];
41 name: string;42 name: string;
42 config: Record<string, unknown>;43 config: Record<string, unknown>;
43}): ExternalToolSourceConfig {44}): Result<ExternalToolSourceConfig, Error> {
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;
49 45
50 if (raw.type === "mcp") {46 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;
51 const result: McpToolSourceConfig = {54 const result: McpToolSourceConfig = {
52 type: "mcp",55 type: "mcp",
53 name: raw.name,56 name: raw.name,
54 sourceId: raw.id,57 sourceId: raw.id,
55 sourceKey: `source:${raw.id}`,58 sourceKey: `source:${raw.id}`,
56 url: String(config.url),59 url: config.url,
57 auth: config.auth as McpToolSourceConfig["auth"],60 auth: config.auth,
58 transport: config.transport as McpToolSourceConfig["transport"],61 transport: config.transport,
59 queryParams: config.queryParams as McpToolSourceConfig["queryParams"],62 queryParams: config.queryParams,
60 defaultApproval: config.defaultApproval as McpToolSourceConfig["defaultApproval"],63 defaultApproval: config.defaultApproval,
61 overrides: config.overrides as McpToolSourceConfig["overrides"],64 overrides: config.overrides,
62 };65 };
63 return result;66 return Result.ok(result);
64 }67 }
65 68
66 if (raw.type === "graphql") {69 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;
67 const result: GraphqlToolSourceConfig = {77 const result: GraphqlToolSourceConfig = {
68 type: "graphql",78 type: "graphql",
69 name: raw.name,79 name: raw.name,
70 sourceId: raw.id,80 sourceId: raw.id,
71 sourceKey: `source:${raw.id}`,81 sourceKey: `source:${raw.id}`,
72 endpoint: String(config.endpoint),82 endpoint: config.endpoint,
73 schema: config.schema as GraphqlToolSourceConfig["schema"],83 schema: config.schema,
74 auth: config.auth as GraphqlToolSourceConfig["auth"],84 auth: config.auth,
75 defaultQueryApproval: config.defaultQueryApproval as GraphqlToolSourceConfig["defaultQueryApproval"],85 defaultQueryApproval: config.defaultQueryApproval,
76 defaultMutationApproval: config.defaultMutationApproval as GraphqlToolSourceConfig["defaultMutationApproval"],86 defaultMutationApproval: config.defaultMutationApproval,
77 overrides: config.overrides as GraphqlToolSourceConfig["overrides"],87 overrides: config.overrides,
78 };88 };
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 );
80 }97 }
98 const config = configResult.value;
81 99
82 const result: OpenApiToolSourceConfig = {100 const result: OpenApiToolSourceConfig = {
83 type: "openapi",101 type: "openapi",
84 name: raw.name,102 name: raw.name,
85 sourceId: raw.id,103 sourceId: raw.id,
86 sourceKey: `source:${raw.id}`,104 sourceKey: `source:${raw.id}`,
87 spec: config.spec as OpenApiToolSourceConfig["spec"],105 spec: config.spec,
88 baseUrl: config.baseUrl as OpenApiToolSourceConfig["baseUrl"],106 collectionUrl: config.collectionUrl,
89 auth: config.auth as OpenApiToolSourceConfig["auth"],107 postmanProxyUrl: config.postmanProxyUrl,
90 defaultReadApproval: config.defaultReadApproval as OpenApiToolSourceConfig["defaultReadApproval"],108 baseUrl: config.baseUrl,
91 defaultWriteApproval: config.defaultWriteApproval as OpenApiToolSourceConfig["defaultWriteApproval"],109 auth: config.auth,
92 overrides: config.overrides as OpenApiToolSourceConfig["overrides"],110 defaultReadApproval: config.defaultReadApproval,
111 defaultWriteApproval: config.defaultWriteApproval,
112 overrides: config.overrides,
93 };113 };
94 return result;114 return Result.ok(result);
95}115}
96 116
97function buildHeadersFromCredentialSecret(117function buildHeadersFromCredentialSecret(
executor/packages/database/src/runtime/workspace_tools.ts+9−8
@@ -26,6 +26,7 @@ import { computeOpenApiSourceQuality, listVisibleToolDescriptors } from "./tool_ · 25 unmodified lines
26import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";26import { loadSourceArtifact, normalizeExternalToolSource, sourceSignature } from "./tool_source_loading";
27import { registrySignatureForWorkspace } from "./tool_registry_state";27import { registrySignatureForWorkspace } from "./tool_registry_state";
28import { normalizeToolPathForLookup } from "./tool_paths";28import { normalizeToolPathForLookup } from "./tool_paths";
29import { asPayload } from "../lib/object";
29 30
30const baseTools = new Map<string, ToolDefinition>();31const baseTools = new Map<string, ToolDefinition>();
31 32
@@ -58,7 +59,7 @@ baseTools.set("admin.send_announcement", { · 26 unmodified lines
58 },59 },
59 },60 },
60 run: async (input: unknown) => {61 run: async (input: unknown) => {
61 const payload = input && typeof input === "object" ? (input as Record<string, unknown>) : {};62 const payload = asPayload(input);
62 const channel = typeof payload.channel === "string" ? payload.channel : "";63 const channel = typeof payload.channel === "string" ? payload.channel : "";
63 const message = typeof payload.message === "string" ? payload.message : "";64 const message = typeof payload.message === "string" ? payload.message : "";
64 return { ok: true, channel, message };65 return { ok: true, channel, message };
@@ -365,7 +366,7 @@ function asJsonSchema(value: unknown): JsonSchema { · 300 unmodified lines
365 if (!value || typeof value !== "object" || Array.isArray(value)) {366 if (!value || typeof value !== "object" || Array.isArray(value)) {
366 return {};367 return {};
367 }368 }
368 return value as JsonSchema;369 return asPayload(value);
369}370}
370 371
371async function buildWorkspaceToolRegistry(372async function buildWorkspaceToolRegistry(
@@ -513,7 +514,7 @@ export async function getWorkspaceTools( · 141 unmodified lines
513 if (source.type !== "mcp") {514 if (source.type !== "mcp") {
514 return false;515 return false;
515 }516 }
516 const auth = source.config.auth as Record<string, unknown> | undefined;517 const auth = asPayload(source.config.auth);
517 return auth?.mode === "actor";518 return auth?.mode === "actor";
518 });519 });
519 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;520 const skipCacheRead = (options.skipCacheRead ?? false) || hasActorScopedMcpSource;
@@ -598,12 +599,12 @@ export async function getWorkspaceTools( · 78 unmodified lines
598 const warnings: string[] = [];599 const warnings: string[] = [];
599 const normalizeSourcesStartedAt = Date.now();600 const normalizeSourcesStartedAt = Date.now();
600 for (const source of sources) {601 for (const source of sources) {
601 try {602 const normalizedResult = normalizeExternalToolSource(source);
602 configs.push(normalizeExternalToolSource(source));603 if (normalizedResult.isErr()) {
603 } catch (error) {604 warnings.push(`Source '${source.name}': ${normalizedResult.error.message}`);
604 const message = error instanceof Error ? error.message : String(error);605 continue;
605 warnings.push(`Source '${source.name}': ${message}`);
606 }606 }
607 configs.push(normalizedResult.value);
607 }608 }
608 traceStep("normalizeSources", normalizeSourcesStartedAt);609 traceStep("normalizeSources", normalizeSourcesStartedAt);
609 610