Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/core/execution/src/tool-invoker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,26 @@ describe("tool discovery", () => {
}),
);

it.effect("returns missing tool dispatches as ToolResult.fail", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(makeTestConfig({ plugins: [] as const }));
const invoker = makeExecutorToolInvoker(executor, {
invokeOptions: { onElicitation: acceptAll },
});

const result = yield* invoker.invoke({ path: "missing.sourceTool", args: {} });

expect(result).toEqual({
ok: false,
error: {
code: "tool_not_found",
message: "Tool not found: missing.sourceTool",
details: { toolId: "missing.sourceTool", suggestions: [] },
},
});
}),
);

it.effect("preserves nested upstream error bodies through ToolResult.fail", () =>
Effect.gen(function* () {
const executor = yield* createExecutor(
Expand Down
30 changes: 28 additions & 2 deletions packages/core/execution/src/tool-invoker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
InvokeOptions,
Source,
} from "@executor-js/sdk/core";
import { isToolResult } from "@executor-js/sdk/core";
import { isToolResult, ToolResult } from "@executor-js/sdk/core";
import type { SandboxToolInvoker } from "@executor-js/codemode-core";
import { ExecutionToolError } from "./errors";

Expand All @@ -34,6 +34,28 @@ const newCorrelationId = (): string => {
.padStart(8, "0");
};

const expectedToolFailure = (
value: unknown,
): { readonly code: string; readonly message: string; readonly details?: unknown } | null => {
if (Predicate.isTagged(value, "ToolNotFoundError") && "toolId" in value) {
const suggestions =
"suggestions" in value && Array.isArray(value.suggestions) ? value.suggestions : undefined;
return {
code: "tool_not_found",
message: `Tool not found: ${String(value.toolId)}`,
details: { toolId: value.toolId, ...(suggestions ? { suggestions } : {}) },
};
}
if (Predicate.isTagged(value, "ToolBlockedError") && "toolId" in value) {
return {
code: "tool_blocked",
message: `Tool blocked by policy: ${String(value.toolId)}`,
details: value,
};
}
return null;
};

/**
* Extract the source namespace from a tool path. Tool paths look like
* "<sourceId>.<op>" or "<sourceId>.<group>.<op>" — we take the first
Expand Down Expand Up @@ -70,8 +92,12 @@ export const makeExecutorToolInvoker = (
});

const result = yield* executor.tools.invoke(path as ToolId, args, options.invokeOptions).pipe(
Effect.catchCause((cause): Effect.Effect<never, ExecutionToolError> => {
Effect.catchCause((cause) => {
const err = cause.reasons.find(Cause.isFailReason)?.error;
const expected = expectedToolFailure(err);
if (expected) {
return Effect.succeed(ToolResult.fail(expected));
}
if (isElicitationDeclinedError(err)) {
return Effect.fail(
new ExecutionToolError({
Expand Down
5 changes: 4 additions & 1 deletion packages/core/sdk/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { ConnectionId, ToolId, SecretId } from "./ids";

export class ToolNotFoundError extends Schema.TaggedErrorClass<ToolNotFoundError>()(
"ToolNotFoundError",
{ toolId: ToolId },
{
toolId: ToolId,
suggestions: Schema.optional(Schema.Array(ToolId)),
},
) {}

export class ToolInvocationError extends Data.TaggedError("ToolInvocationError")<{
Expand Down
80 changes: 79 additions & 1 deletion packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, expect, it } from "@effect/vitest";
import { Data, Effect } from "effect";
import { Data, Effect, Predicate } from "effect";
import { FetchHttpClient } from "effect/unstable/http";

import { scopedExecutorTable, textColumn } from "./core-schema";
import { ElicitationResponse } from "./elicitation";
import { ToolNotFoundError } from "./errors";
import { createExecutor } from "./executor";
import { ScopeId } from "./ids";
import { definePlugin } from "./plugin";
Expand Down Expand Up @@ -171,6 +173,22 @@ const schemaProbePlugin = definePlugin(() => ({
}),
}))();

const caseSensitiveDynamicPlugin = definePlugin(() => ({
id: "caseDynamic" as const,
storage: () => ({}),
extension: (ctx) => ({
registerSource: () =>
ctx.core.sources.register({
id: "case_source",
scope: String(ctx.scopes[0]!.id),
kind: "case",
name: "Case Source",
tools: [{ name: "listdashboards", description: "list dashboards" }],
}),
}),
invokeTool: ({ toolRow }) => Effect.succeed({ invokedToolId: toolRow.id }),
}))();

describe("createExecutor", () => {
it.effect("rolls back plugin and core writes from ctx.transaction failures", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -311,4 +329,64 @@ describe("createExecutor", () => {
);
}),
);

it.effect("resolves dynamic tool ids case-insensitively before invoking plugins", () =>
Effect.gen(function* () {
const executor = yield* makeTestExecutor({
plugins: [caseSensitiveDynamicPlugin] as const,
});
yield* executor.caseDynamic.registerSource();

const result = yield* executor.tools.invoke("case_source.listDashboards", {});

expect(result).toEqual({ invokedToolId: "case_source.listdashboards" });
}),
);

it.effect("applies policies after case-insensitive dynamic tool id resolution", () =>
Effect.gen(function* () {
const executor = yield* makeTestExecutor({
plugins: [caseSensitiveDynamicPlugin] as const,
});
yield* executor.caseDynamic.registerSource();
yield* executor.policies.create({
targetScope: "test-scope",
pattern: "case_source.listdashboards",
action: "require_approval",
});
const calls = { count: 0 };

const result = yield* executor.tools.invoke(
"case_source.listDashboards",
{},
{
onElicitation: () =>
Effect.sync(() => {
calls.count += 1;
return ElicitationResponse.make({ action: "accept" });
}),
},
);

expect(result).toEqual({ invokedToolId: "case_source.listdashboards" });
expect(calls.count).toBe(1);
}),
);

it.effect("suggests visible tools for missing dynamic tool ids", () =>
Effect.gen(function* () {
const executor = yield* makeTestExecutor({
plugins: [caseSensitiveDynamicPlugin] as const,
});
yield* executor.caseDynamic.registerSource();

const error = yield* executor.tools
.invoke("case_source.listDashboardsWRONG", {})
.pipe(Effect.flip);

expect(error).toBeInstanceOf(ToolNotFoundError);
if (!Predicate.isTagged("ToolNotFoundError")(error)) return;
expect(error.suggestions).toEqual(["case_source.listdashboards"]);
}),
);
});
142 changes: 108 additions & 34 deletions packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,50 @@ const byScopedId =
(b: ConditionBuilder<Record<string, AnyColumn>>): Condition =>
b.and(b("scope_id", "=", scope), b("id", "=", id)) as Condition;

const toolSourceId = (toolId: string): string | null => {
const dot = toolId.indexOf(".");
return dot === -1 ? null : toolId.slice(0, dot);
};

const levenshteinDistance = (left: string, right: string): number => {
const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
const current = Array.from({ length: right.length + 1 }, () => 0);
for (let i = 0; i < left.length; i++) {
current[0] = i + 1;
for (let j = 0; j < right.length; j++) {
current[j + 1] =
left[i] === right[j]
? previous[j]!
: Math.min(previous[j]!, previous[j + 1]!, current[j]!) + 1;
}
for (let j = 0; j < previous.length; j++) previous[j] = current[j]!;
}
return previous[right.length]!;
};

const missingToolSuggestionScore = (query: string, candidate: string): number => {
const normalizedQuery = query.toLowerCase();
const normalizedCandidate = candidate.toLowerCase();
if (normalizedCandidate === normalizedQuery) return 0;
if (normalizedCandidate.startsWith(normalizedQuery)) return 1;
if (normalizedQuery.startsWith(normalizedCandidate)) return 2;
if (normalizedCandidate.includes(normalizedQuery)) return 3;
const queryLeaf = normalizedQuery.split(".").at(-1) ?? normalizedQuery;
const candidateLeaf = normalizedCandidate.split(".").at(-1) ?? normalizedCandidate;
if (candidateLeaf.startsWith(queryLeaf) || queryLeaf.startsWith(candidateLeaf)) return 4;
return 10 + levenshteinDistance(normalizedQuery, normalizedCandidate);
};

const missingToolSuggestions = (
toolId: string,
rows: readonly { readonly id: string }[],
): readonly ToolId[] =>
rows
.map((row) => ({ id: row.id, score: missingToolSuggestionScore(toolId, row.id) }))
.sort((left, right) => left.score - right.score || left.id.localeCompare(right.id))
.slice(0, 5)
.map((item) => ToolId.make(item.id));

type CoreTableName = keyof CoreSchema & string;
type CoreRow<TName extends CoreTableName> = FumaRow<CoreSchema[TName]>;
type CoreWhere<_TName extends CoreTableName> = (
Expand Down Expand Up @@ -3191,36 +3235,35 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: preserve public invoke error message wrapping for unknown plugin failures
return cause instanceof Error ? cause.message : String(cause);
};
const wrapInvocationError = <A, E>(
effect: Effect.Effect<A, E>,
): Effect.Effect<A, ToolInvocationError> =>
effect.pipe(
Effect.mapError(
(cause) =>
new ToolInvocationError({
toolId: ToolId.make(toolId),
message: formatInvocationCauseMessage(cause),
cause,
}),
),
);

// Resolve the user-authored policy first. A `block` rule
// short-circuits both the static and dynamic paths before any
// plugin code runs.
const policy = yield* resolveToolPolicyForId(toolId).pipe(
Effect.withSpan("executor.tool.resolve_policy"),
);
if (policy?.action === "block") {
return yield* new ToolBlockedError({
toolId: ToolId.make(toolId),
pattern: policy.pattern,
});
}
const wrapInvocationError =
(resolvedToolId: string) =>
<A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, ToolInvocationError> =>
effect.pipe(
Effect.mapError(
(cause) =>
new ToolInvocationError({
toolId: ToolId.make(resolvedToolId),
message: formatInvocationCauseMessage(cause),
cause,
}),
),
);

// Static path — O(1) map lookup, no DB hit.
const staticEntry = staticTools.get(toolId);
if (staticEntry) {
// Resolve the user-authored policy before static plugin code
// runs. Dynamic tools resolve policy after canonicalizing the
// stored tool id so casing aliases cannot bypass rules.
const policy = yield* resolveToolPolicyForId(toolId).pipe(
Effect.withSpan("executor.tool.resolve_policy"),
);
if (policy?.action === "block") {
return yield* new ToolBlockedError({
toolId: ToolId.make(toolId),
pattern: policy.pattern,
});
}
yield* Effect.annotateCurrentSpan({
"executor.tool.dispatch_path": "static",
"executor.source_id": staticEntry.source.id,
Expand All @@ -3230,7 +3273,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
yield* enforceApproval(staticEntry.tool.annotations, toolId, args, policy, handler).pipe(
Effect.withSpan("executor.tool.enforce_approval"),
);
return yield* wrapInvocationError(
return yield* wrapInvocationError(toolId)(
staticEntry.tool.handler({
ctx: staticEntry.ctx,
args,
Expand All @@ -3242,22 +3285,53 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
// Dynamic path — DB lookup + delegate to owning plugin. Walk the
// whole scope stack and pick the innermost-scope row so a user's
// shadow of an outer tool actually wins on invoke.
const toolRows = yield* core
let toolRows = yield* core
.findMany("tool", {
where: scopedWhere(scopeIds, byId(toolId)),
})
.pipe(Effect.withSpan("executor.tool.resolve"));
const row = findInnermost(toolRows);
let row = findInnermost(toolRows);
let resolvedToolId = toolId;
let suggestionRows: readonly CoreRow<"tool">[] = toolRows;
if (!row) {
suggestionRows = yield* core
.findMany("tool", {
where: scopedWhere(scopeIds),
})
.pipe(Effect.withSpan("executor.tool.resolve_suggestions"));
const sourceId = toolSourceId(toolId);
if (sourceId) {
const normalizedToolId = toolId.toLowerCase();
row = findInnermost(
suggestionRows.filter(
(toolRow) =>
toolRow.source_id === sourceId && toolRow.id.toLowerCase() === normalizedToolId,
),
);
if (row) resolvedToolId = row.id;
}
}
if (!row) {
return yield* new ToolNotFoundError({
toolId: ToolId.make(toolId),
suggestions: missingToolSuggestions(toolId, suggestionRows),
});
}
yield* Effect.annotateCurrentSpan({
"executor.tool.dispatch_path": "dynamic",
"executor.source_id": row.source_id,
"executor.plugin_id": row.plugin_id,
"executor.tool.resolved_id": resolvedToolId,
});
const policy = yield* resolveToolPolicyForId(resolvedToolId).pipe(
Effect.withSpan("executor.tool.resolve_policy"),
);
if (policy?.action === "block") {
return yield* new ToolBlockedError({
toolId: ToolId.make(resolvedToolId),
pattern: policy.pattern,
});
}
const runtime = runtimes.get(row.plugin_id);
if (!runtime) {
return yield* new PluginNotLoadedError({
Expand Down Expand Up @@ -3287,20 +3361,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
sourceId: row.source_id,
toolRows: [row],
})
.pipe(wrapInvocationError)
.pipe(wrapInvocationError(resolvedToolId))
.pipe(Effect.withSpan("executor.tool.resolve_annotations"));
annotations = map[toolId];
annotations = map[resolvedToolId];
}
yield* enforceApproval(annotations, toolId, args, policy, handler).pipe(
yield* enforceApproval(annotations, resolvedToolId, args, policy, handler).pipe(
Effect.withSpan("executor.tool.enforce_approval"),
);

return yield* wrapInvocationError(
return yield* wrapInvocationError(resolvedToolId)(
runtime.plugin.invokeTool({
ctx: runtime.ctx,
toolRow: row,
args,
elicit: buildElicit(toolId, args, handler),
elicit: buildElicit(resolvedToolId, args, handler),
}),
).pipe(Effect.withSpan("executor.tool.handler"));
}).pipe(
Expand Down
Loading