Skip to content
Open
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"**/*.css",
"packages/appkit/src/plugins/vector-search/**",
"packages/appkit/src/plugin/index.ts",
"packages/appkit/src/plugin/to-plugin.ts",
"packages/appkit/src/plugins/agents/index.ts",
"packages/appkit/src/plugins/agents/tools/index.ts",
"packages/appkit/src/plugins/agents/from-plugin.ts",
Expand Down
2 changes: 2 additions & 0 deletions packages/appkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@
"@types/semver": "7.7.1",
"dotenv": "16.6.1",
"express": "4.22.0",
"js-yaml": "^4.1.1",
"obug": "2.1.1",
"pg": "8.18.0",
"picocolors": "1.1.1",
Expand All @@ -108,6 +109,7 @@
"@ai-sdk/openai": "4.0.0-beta.27",
"@langchain/core": "^1.1.39",
"@types/express": "4.17.25",
"@types/js-yaml": "^4.0.9",
"@types/json-schema": "7.0.15",
"@types/pg": "8.16.0",
"@types/ws": "8.18.1",
Expand Down
1 change: 1 addition & 0 deletions packages/appkit/src/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export * from "./files";
export * from "./genie";
export * from "./lakebase";
export * from "./lakebase-v1";
export * from "./mcp";
export * from "./sql-warehouse";
export * from "./vector-search";
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,16 @@
* transport.
*/
import type { AgentToolDefinition } from "shared";
import { createLogger } from "../../../logging/logger";
import type { McpEndpointConfig } from "./hosted-tools";
import { createLogger } from "../../logging/logger";
import {
assertResolvedHostSafe,
checkMcpUrl,
type DnsLookup,
type McpHostPolicy,
} from "./mcp-host-policy";
} from "./host-policy";
import type { McpEndpointConfig } from "./types";

const logger = createLogger("agent:mcp");
const logger = createLogger("connector:mcp");

interface JsonRpcRequest {
jsonrpc: "2.0";
Expand Down
6 changes: 6 additions & 0 deletions packages/appkit/src/connectors/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export { AppKitMcpClient } from "./client";
export {
buildMcpHostPolicy,
type McpHostPolicyConfig,
} from "./host-policy";
export type { McpEndpointConfig } from "./types";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AppKitMcpClient } from "../tools/mcp-client";
import type { DnsLookup, McpHostPolicy } from "../tools/mcp-host-policy";
import { AppKitMcpClient } from "../client";
import type { DnsLookup, McpHostPolicy } from "../host-policy";

const WORKSPACE = "https://test-workspace.cloud.databricks.com";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
isLoopbackHost,
type McpHostPolicy,
type McpHostPolicyConfig,
} from "../tools/mcp-host-policy";
} from "../host-policy";

function stubLookup(
addresses: Array<{ address: string; family?: number }>,
Expand Down
12 changes: 12 additions & 0 deletions packages/appkit/src/connectors/mcp/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Input shape consumed by {@link AppKitMcpClient.connect}. Produced by the
* agents plugin from user-facing `HostedTool` declarations (see
* `plugins/agents/tools/hosted-tools.ts`) and accepted directly by the
* connector to keep its surface free of agent-layer concepts.
*/
export interface McpEndpointConfig {
/** Stable logical name used as the `mcp.<name>.*` tool prefix and in logs. */
name: string;
/** Absolute URL (`https://…`) or workspace-relative path (`/api/2.0/mcp/…`). */
url: string;
}
53 changes: 53 additions & 0 deletions packages/appkit/src/core/create-agent-def.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ConfigurationError } from "../errors";
import type { AgentDefinition } from "../plugins/agents/types";

/**
* Pure factory for agent definitions. Returns the passed-in definition after
* cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape
* and is safe to call at module top-level.
*
* The returned value is a plain `AgentDefinition` — no adapter construction,
* no side effects. Register it with `agents({ agents: { name: def } })` or run
* it standalone via `runAgent(def, input)`.
*
* @example
* ```ts
* const support = createAgent({
* instructions: "You help customers.",
* model: "databricks-claude-sonnet-4-5",
* tools: {
* get_weather: tool({ ... }),
* },
* });
* ```
*/
export function createAgent(def: AgentDefinition): AgentDefinition {
detectCycles(def);
return def;
}

/**
* Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is
* found. Cycles would cause infinite recursion at tool-invocation time.
*/
function detectCycles(def: AgentDefinition): void {
const visiting = new Set<AgentDefinition>();
const visited = new Set<AgentDefinition>();

const walk = (current: AgentDefinition, path: string[]): void => {
if (visited.has(current)) return;
if (visiting.has(current)) {
throw new ConfigurationError(
`Agent sub-agent cycle detected: ${path.join(" -> ")}`,
);
}
visiting.add(current);
for (const [childKey, child] of Object.entries(current.agents ?? {})) {
walk(child, [...path, childKey]);
}
visiting.delete(current);
visited.add(current);
};

walk(def, [def.name ?? "(root)"]);
}
226 changes: 226 additions & 0 deletions packages/appkit/src/core/run-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { randomUUID } from "node:crypto";
import type {
AgentAdapter,
AgentEvent,
AgentToolDefinition,
Message,
} from "shared";
import {
type FunctionTool,
functionToolToDefinition,
isFunctionTool,
} from "../plugins/agents/tools/function-tool";
import { isHostedTool } from "../plugins/agents/tools/hosted-tools";
import type {
AgentDefinition,
AgentTool,
ToolkitEntry,
} from "../plugins/agents/types";
import { isToolkitEntry } from "../plugins/agents/types";

export interface RunAgentInput {
/** Seed messages for the run. Either a single user string or a full message list. */
messages: string | Message[];
/** Abort signal for cancellation. */
signal?: AbortSignal;
}

export interface RunAgentResult {
/** Aggregated text output from all `message_delta` events. */
text: string;
/** Every event the adapter yielded, in order. Useful for inspection/tests. */
events: AgentEvent[];
}

/**
* Standalone agent execution without `createApp`. Resolves the adapter, binds
* inline tools, and drives the adapter's `run()` loop to completion.
*
* Limitations vs. running through the agents() plugin:
* - No OBO: there is no HTTP request, so plugin tools run as the service
* principal (when they work at all).
* - Plugin tools (`ToolkitEntry`) are not supported — they require a live
* `PluginContext` that only exists when registered in a `createApp`
* instance. This function throws a clear error if encountered.
* - Sub-agents (`agents: { ... }` on the def) are executed as nested
* `runAgent` calls with no shared thread state.
*/
export async function runAgent(
def: AgentDefinition,
input: RunAgentInput,
): Promise<RunAgentResult> {
const adapter = await resolveAdapter(def);
const messages = normalizeMessages(input.messages, def.instructions);
const toolIndex = buildStandaloneToolIndex(def);
const tools = Array.from(toolIndex.values()).map((e) => e.def);

const signal = input.signal;

const executeTool = async (name: string, args: unknown): Promise<unknown> => {
const entry = toolIndex.get(name);
if (!entry) throw new Error(`Unknown tool: ${name}`);
if (entry.kind === "function") {
return entry.tool.execute(args as Record<string, unknown>);
}
if (entry.kind === "subagent") {
const subInput: RunAgentInput = {
messages:
typeof args === "object" &&
args !== null &&
typeof (args as { input?: unknown }).input === "string"
? (args as { input: string }).input
: JSON.stringify(args),
signal,
};
const res = await runAgent(entry.agentDef, subInput);
return res.text;
}
throw new Error(
`runAgent: tool "${name}" is a ${entry.kind} tool. ` +
"Plugin toolkits and MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
);
};

const events: AgentEvent[] = [];
let text = "";

const stream = adapter.run(
{
messages,
tools,
threadId: randomUUID(),
signal,
},
{ executeTool, signal },
);

for await (const event of stream) {
if (signal?.aborted) break;
events.push(event);
if (event.type === "message_delta") {
text += event.content;
} else if (event.type === "message") {
text = event.content;
}
}

return { text, events };
}

async function resolveAdapter(def: AgentDefinition): Promise<AgentAdapter> {
const { model } = def;
if (!model) {
const { DatabricksAdapter } = await import("../agents/databricks");
return DatabricksAdapter.fromModelServing();
}
if (typeof model === "string") {
const { DatabricksAdapter } = await import("../agents/databricks");
return DatabricksAdapter.fromModelServing(model);
}
return await model;
}

function normalizeMessages(
input: string | Message[],
instructions: string,
): Message[] {
const systemMessage: Message = {
id: "system",
role: "system",
content: instructions,
createdAt: new Date(),
};
if (typeof input === "string") {
return [
systemMessage,
{
id: randomUUID(),
role: "user",
content: input,
createdAt: new Date(),
},
];
}
return [systemMessage, ...input];
}

type StandaloneEntry =
| {
kind: "function";
def: AgentToolDefinition;
tool: FunctionTool;
}
| {
kind: "subagent";
def: AgentToolDefinition;
agentDef: AgentDefinition;
}
| {
kind: "toolkit";
def: AgentToolDefinition;
entry: ToolkitEntry;
}
| {
kind: "hosted";
def: AgentToolDefinition;
};

function buildStandaloneToolIndex(
def: AgentDefinition,
): Map<string, StandaloneEntry> {
const index = new Map<string, StandaloneEntry>();

for (const [key, tool] of Object.entries(def.tools ?? {})) {
index.set(key, classifyTool(key, tool));
}

for (const [childKey, child] of Object.entries(def.agents ?? {})) {
const toolName = `agent-${childKey}`;
index.set(toolName, {
kind: "subagent",
agentDef: { ...child, name: child.name ?? childKey },
def: {
name: toolName,
description:
child.instructions.slice(0, 120) ||
`Delegate to the ${childKey} sub-agent`,
parameters: {
type: "object",
properties: {
input: {
type: "string",
description: "Message to send to the sub-agent.",
},
},
required: ["input"],
},
},
});
}

return index;
}

function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
if (isToolkitEntry(tool)) {
return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool };
}
if (isFunctionTool(tool)) {
return {
kind: "function",
tool,
def: { ...functionToolToDefinition(tool), name: key },
};
}
if (isHostedTool(tool)) {
return {
kind: "hosted",
def: {
name: key,
description: `Hosted tool: ${tool.type}`,
parameters: { type: "object", properties: {} },
},
};
}
throw new Error(`runAgent: unrecognized tool shape at key "${key}"`);
}
Loading