Skip to content
Merged
39 changes: 39 additions & 0 deletions packages/appkit/src/beta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,44 @@
//
// The exports below are auto-generated from each plugin's manifest.json
// "stability" field. See tools/generate-plugin-entries.ts.

// Agent types from shared
export type {
AgentAdapter,
AgentEvent,
AgentInput,
AgentRunContext,
AgentToolDefinition,
Message,
Thread,
ThreadStore,
ToolProvider,
} from "shared";
export { DatabricksAdapter, parseTextToolCalls } from "./agents/databricks";

// Tool authoring primitives
export {
AppKitMcpClient,
defineTool,
executeFromRegistry,
type FunctionTool,
functionToolToDefinition,
type HostedTool,
isFunctionTool,
isHostedTool,
mcpServer,
resolveHostedTools,
type ToolConfig,
type ToolEntry,
type ToolRegistry,
tool,
toolsFromRegistry,
} from "./core/agent/tools";
export {
type AgentTool,
isToolkitEntry,
type ToolkitEntry,
type ToolkitOptions,
} from "./core/agent/types";

export * from "./plugins/beta-exports.generated";
63 changes: 63 additions & 0 deletions packages/appkit/src/core/agent/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/core/agent/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/core/agent/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
Loading