Skip to content
Open
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
24 changes: 24 additions & 0 deletions packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,20 @@

// Types from shared
export type {
AgentAdapter,
AgentEvent,
AgentInput,
AgentRunContext,
AgentToolDefinition,
BasePluginConfig,
CacheConfig,
IAppRouter,
Message,
PluginData,
StreamExecutionSettings,
Thread,
ThreadStore,
ToolProvider,
} from "shared";
export { isSQLTypeMarker, sql } from "shared";
export { CacheManager } from "./cache";
Expand Down Expand Up @@ -54,6 +63,21 @@ export {
toPlugin,
} from "./plugin";
export { analytics, files, genie, lakebase, server, serving } from "./plugins";
export {
type FunctionTool,
type HostedTool,
isFunctionTool,
isHostedTool,
mcpServer,
type ToolConfig,
tool,
} from "./plugins/agents/tools";
export {
type AgentTool,
isToolkitEntry,
type ToolkitEntry,
type ToolkitOptions,
} from "./plugins/agents/types";
// Files plugin types (for custom policy authoring)
export type {
FileAction,
Expand Down
63 changes: 63 additions & 0 deletions packages/appkit/src/plugins/agents/build-toolkit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { AgentToolDefinition } from "shared";
import type { ToolRegistry } from "./tools/define-tool";
import { toToolJSONSchema } from "./tools/json-schema";
import type { ToolkitEntry, ToolkitOptions } from "./types";

/**
* Converts a plugin's internal `ToolRegistry` into a keyed record of
* `ToolkitEntry` markers suitable for spreading into an `AgentDefinition.tools`
* record.
*
* The `opts` record controls shape and filtering:
* - `prefix` — overrides the default `${pluginName}.` prefix; `""` drops it.
* - `only` — allowlist of local tool names to include (post-prefix).
* - `except` — denylist of local names.
* - `rename` — per-tool key remapping (applied after prefix/filter).
*
* Each entry carries `pluginName` + `localName` so the agents plugin can
* dispatch back through `PluginContext.executeTool` for OBO + telemetry.
*/
export function buildToolkitEntries(
pluginName: string,
registry: ToolRegistry,
opts: ToolkitOptions = {},
): Record<string, ToolkitEntry> {
const prefix = opts.prefix ?? `${pluginName}.`;
const only = opts.only ? new Set(opts.only) : null;
const except = opts.except ? new Set(opts.except) : null;
const rename = opts.rename ?? {};

const out: Record<string, ToolkitEntry> = {};

for (const [localName, entry] of Object.entries(registry)) {
if (only && !only.has(localName)) continue;
if (except?.has(localName)) continue;

const keyAfterPrefix = `${prefix}${localName}`;
const key = rename[localName] ?? keyAfterPrefix;

const parameters = toToolJSONSchema(
entry.schema,
) as unknown as AgentToolDefinition["parameters"];

const def: AgentToolDefinition = {
name: key,
description: entry.description,
parameters,
};
if (entry.annotations) {
def.annotations = entry.annotations;
}

out[key] = {
__toolkitRef: true,
pluginName,
localName,
def,
annotations: entry.annotations,
autoInheritable: entry.autoInheritable,
};
}

return out;
}
101 changes: 101 additions & 0 deletions packages/appkit/src/plugins/agents/tests/build-toolkit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { buildToolkitEntries } from "../build-toolkit";
import { defineTool, type ToolRegistry } from "../tools/define-tool";
import { isToolkitEntry } from "../types";

const registry: ToolRegistry = {
query: defineTool({
description: "Run a query",
schema: z.object({ sql: z.string() }),
handler: () => "ok",
}),
history: defineTool({
description: "Get query history",
schema: z.object({}),
handler: () => [],
}),
};

describe("buildToolkitEntries", () => {
test("produces ToolkitEntry per registry item with default dotted prefix", () => {
const entries = buildToolkitEntries("analytics", registry);
expect(Object.keys(entries).sort()).toEqual([
"analytics.history",
"analytics.query",
]);
for (const entry of Object.values(entries)) {
expect(isToolkitEntry(entry)).toBe(true);
expect(entry.pluginName).toBe("analytics");
}
});

test("respects prefix option (empty drops the namespace)", () => {
const entries = buildToolkitEntries("analytics", registry, { prefix: "" });
expect(Object.keys(entries).sort()).toEqual(["history", "query"]);
});

test("respects custom prefix", () => {
const entries = buildToolkitEntries("analytics", registry, {
prefix: "db.",
});
expect(Object.keys(entries).sort()).toEqual(["db.history", "db.query"]);
});

test("only filter keeps the listed local names", () => {
const entries = buildToolkitEntries("analytics", registry, {
only: ["query"],
});
expect(Object.keys(entries)).toEqual(["analytics.query"]);
});

test("except filter drops the listed local names", () => {
const entries = buildToolkitEntries("analytics", registry, {
except: ["history"],
});
expect(Object.keys(entries)).toEqual(["analytics.query"]);
});

test("rename remaps specific local names (overrides the prefix key)", () => {
const entries = buildToolkitEntries("analytics", registry, {
rename: { query: "sql" },
});
expect(Object.keys(entries).sort()).toEqual(["analytics.history", "sql"]);
});

test("exposes the original plugin+local name so dispatch can route", () => {
const entries = buildToolkitEntries("analytics", registry, {
prefix: "db.",
});
const qEntry = entries["db.query"];
expect(qEntry.pluginName).toBe("analytics");
expect(qEntry.localName).toBe("query");
expect(qEntry.def.name).toBe("db.query");
});

test("propagates autoInheritable from the source registry", () => {
const mixed: ToolRegistry = {
readIt: defineTool({
description: "safe read",
schema: z.object({}),
autoInheritable: true,
handler: () => "ok",
}),
writeIt: defineTool({
description: "unsafe write",
schema: z.object({}),
autoInheritable: false,
handler: () => "ok",
}),
unmarked: defineTool({
description: "default: not auto-inheritable",
schema: z.object({}),
handler: () => "ok",
}),
};
const entries = buildToolkitEntries("p", mixed);
expect(entries["p.readIt"].autoInheritable).toBe(true);
expect(entries["p.writeIt"].autoInheritable).toBe(false);
expect(entries["p.unmarked"].autoInheritable).toBeUndefined();
});
});
133 changes: 133 additions & 0 deletions packages/appkit/src/plugins/agents/tests/define-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import {
defineTool,
executeFromRegistry,
type ToolRegistry,
toolsFromRegistry,
} from "../tools/define-tool";

describe("defineTool()", () => {
test("returns an entry matching the input config", () => {
const entry = defineTool({
description: "echo",
schema: z.object({ msg: z.string() }),
annotations: { readOnly: true },
handler: ({ msg }) => msg,
});

expect(entry.description).toBe("echo");
expect(entry.annotations).toEqual({ readOnly: true });
expect(typeof entry.handler).toBe("function");
});
});

describe("executeFromRegistry", () => {
const registry: ToolRegistry = {
echo: defineTool({
description: "echo",
schema: z.object({ msg: z.string() }),
handler: ({ msg }) => `got ${msg}`,
}),
};

test("validates args and calls handler on success", async () => {
const result = await executeFromRegistry(registry, "echo", { msg: "hi" });
expect(result).toBe("got hi");
});

test("returns formatted error string on validation failure", async () => {
const result = await executeFromRegistry(registry, "echo", {});
expect(typeof result).toBe("string");
expect(result).toContain("Invalid arguments for echo");
expect(result).toContain("msg");
});

test("throws for unknown tool names", async () => {
await expect(executeFromRegistry(registry, "missing", {})).rejects.toThrow(
/Unknown tool: missing/,
);
});

test("forwards AbortSignal to the handler", async () => {
const handler = vi.fn(async (_args: { x: string }, signal?: AbortSignal) =>
signal?.aborted ? "aborted" : "ok",
);
const reg: ToolRegistry = {
t: defineTool({
description: "t",
schema: z.object({ x: z.string() }),
handler,
}),
};

const controller = new AbortController();
controller.abort();
await executeFromRegistry(reg, "t", { x: "hi" }, controller.signal);

expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][1]).toBe(controller.signal);
});
});

describe("toolsFromRegistry", () => {
test("produces AgentToolDefinition[] with JSON Schema parameters", () => {
const registry: ToolRegistry = {
query: defineTool({
description: "Execute a SQL query",
schema: z.object({
query: z.string().describe("SQL query"),
}),
annotations: { readOnly: true, requiresUserContext: true },
handler: () => "ok",
}),
};

const defs = toolsFromRegistry(registry);
expect(defs).toHaveLength(1);
expect(defs[0].name).toBe("query");
expect(defs[0].description).toBe("Execute a SQL query");
expect(defs[0].parameters).toMatchObject({
type: "object",
properties: {
query: { type: "string", description: "SQL query" },
},
required: ["query"],
});
expect(defs[0].annotations).toEqual({
readOnly: true,
requiresUserContext: true,
});
});

test("preserves dotted names like uploads.list from the registry keys", () => {
const registry: ToolRegistry = {
"uploads.list": defineTool({
description: "list uploads",
schema: z.object({}),
handler: () => [],
}),
"documents.list": defineTool({
description: "list documents",
schema: z.object({}),
handler: () => [],
}),
};

const names = toolsFromRegistry(registry).map((d) => d.name);
expect(names).toContain("uploads.list");
expect(names).toContain("documents.list");
});

test("omits annotations when none are provided", () => {
const registry: ToolRegistry = {
plain: defineTool({
description: "plain",
schema: z.object({}),
handler: () => "ok",
}),
};
const [def] = toolsFromRegistry(registry);
expect(def.annotations).toBeUndefined();
});
});
Loading