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
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
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}"`);
}
25 changes: 19 additions & 6 deletions packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export {
} from "./connectors/lakebase";
export { getExecutionContext } from "./context";
export { createApp } from "./core";
export { createAgent } from "./core/create-agent-def";
export {
type RunAgentInput,
type RunAgentResult,
runAgent,
} from "./core/run-agent";
// Errors
export {
AppKitError,
Expand All @@ -63,6 +69,19 @@ export {
toPlugin,
} from "./plugin";
export { analytics, files, genie, lakebase, server, serving } from "./plugins";
export {
type AgentDefinition,
type AgentsPluginConfig,
type AgentTool,
agents,
type BaseSystemPromptOption,
isToolkitEntry,
loadAgentFromFile,
loadAgentsFromDir,
type PromptContext,
type ToolkitEntry,
type ToolkitOptions,
} from "./plugins/agents";
export {
type FunctionTool,
type HostedTool,
Expand All @@ -72,12 +91,6 @@ export {
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
Loading