Skip to content

Commit cb7fe2b

Browse files
committed
feat(appkit): agents() plugin, createAgent(def), and markdown-driven agents
The main product layer. Turns an AppKit app into an AI-agent host with markdown-driven agent discovery, code-defined agents, sub-agents, and a standalone run-without-HTTP executor. ### `createAgent(def)` — pure factory `packages/appkit/src/core/create-agent-def.ts`. Returns the passed-in definition after cycle-detecting the sub-agent graph. No adapter construction, no side effects — safe at module top-level. The returned `AgentDefinition` is plain data, consumable by either `agents({ agents })` or `runAgent(def, input)`. ### `agents()` plugin `packages/appkit/src/plugins/agents/agents.ts`. `AgentsPlugin` class: - Loads markdown agents from `config/agents/*.md` (configurable dir) via real YAML frontmatter parsing (`js-yaml`). Frontmatter schema: `endpoint`, `model`, `toolkits`, `tools`, `default`, `maxSteps`, `maxTokens`, `baseSystemPrompt`. Unknown keys logged, invalid YAML throws at boot. - Merges code-defined agents passed via `agents({ agents: { name: def } })`. Code wins on key collision. - For each agent, builds a per-agent tool index from: 1. Sub-agents (`agents: {...}`) — synthesized as `agent-<key>` tools on the parent. 2. Explicit tool record entries — `ToolkitEntry`s, inline `FunctionTool`s, or `HostedTool`s. 3. Auto-inherit (if nothing explicit) — pulls every registered `ToolProvider` plugin's tools. Asymmetric default: markdown agents inherit (`file: true`), code-defined agents don't (`code: false`). - Mounts `POST /invocations` (OpenAI Responses compatible) + `POST /chat`, `POST /cancel`, `GET /threads/:id`, `DELETE /threads/:id`, `GET /info`. - SSE streaming via `executeStream`. Tool calls dispatch through `PluginContext.executeTool(req, pluginName, localName, args, signal)` for OBO, telemetry, and timeout. - Exposes `appkit.agent.{register, list, get, reload, getDefault, getThreads}` runtime helpers. ### `runAgent(def, input)` — standalone executor `packages/appkit/src/core/run-agent.ts`. Runs an `AgentDefinition` without `createApp` or HTTP. Drives the adapter's event stream to completion, executing inline tools + sub-agents along the way. Aggregates events into `{ text, events }`. Useful for tests, CLI scripts, and offline pipelines. Hosted/MCP tools and plugin toolkits require the agents plugin and throw clear errors with guidance. ### Event translation and thread storage - `AgentEventTranslator` — stateful converter from internal `AgentEvent`s to OpenAI Responses API `ResponseStreamEvent`s with sequence numbers and output indices. - `InMemoryThreadStore` — per-user conversation persistence. Nested `Map<userId, Map<threadId, Thread>>`. Implements `ThreadStore` from shared types. - `buildBaseSystemPrompt` + `composeSystemPrompt` — formats the AppKit base prompt (with plugin names and tool names) and layers the agent's instructions on top. ### Frontmatter loader `load-agents.ts` — reads `*.md` files, parses YAML frontmatter with `js-yaml`, resolves `toolkits: [...]` entries against the plugin provider index at load time, wraps ambient tools (from `agents({ tools: {...} })`) for `tools: [...]` frontmatter references. ### Plumbing - Adds `js-yaml` + `@types/js-yaml` deps. - Manifest mounts routes at `/api/agent/*` (singular — matches `appkit.agent.*` runtime handle). - Exports from the main barrel: `agents`, `createAgent`, `runAgent`, `AgentDefinition`, `AgentsPluginConfig`, `AgentTool`, `ToolkitEntry`, `ToolkitOptions`, `BaseSystemPromptOption`, `PromptContext`, `isToolkitEntry`, `loadAgentFromFile`, `loadAgentsFromDir`. ### Test plan - 60 new tests: agents plugin lifecycle, markdown loading, code-agent registration, auto-inherit asymmetry, sub-agent tool synthesis, cycle detection, event translator, thread store, system prompt composition, standalone `runAgent`. - Full appkit vitest suite: 1297 tests passing. - Typecheck clean across all 8 workspace projects. Signed-off-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent e26795b commit cb7fe2b

22 files changed

Lines changed: 3071 additions & 4 deletions

packages/appkit/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
"@types/semver": "7.7.1",
8484
"dotenv": "16.6.1",
8585
"express": "4.22.0",
86+
"js-yaml": "^4.1.1",
8687
"obug": "2.1.1",
8788
"pg": "8.18.0",
8889
"picocolors": "1.1.1",
@@ -108,6 +109,7 @@
108109
"@ai-sdk/openai": "4.0.0-beta.27",
109110
"@langchain/core": "^1.1.39",
110111
"@types/express": "4.17.25",
112+
"@types/js-yaml": "^4.0.9",
111113
"@types/json-schema": "7.0.15",
112114
"@types/pg": "8.16.0",
113115
"@types/ws": "8.18.1",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ConfigurationError } from "../errors";
2+
import type { AgentDefinition } from "../plugins/agents/types";
3+
4+
/**
5+
* Pure factory for agent definitions. Returns the passed-in definition after
6+
* cycle-detecting the sub-agent graph. Accepts the full `AgentDefinition` shape
7+
* and is safe to call at module top-level.
8+
*
9+
* The returned value is a plain `AgentDefinition` — no adapter construction,
10+
* no side effects. Register it with `agents({ agents: { name: def } })` or run
11+
* it standalone via `runAgent(def, input)`.
12+
*
13+
* @example
14+
* ```ts
15+
* const support = createAgent({
16+
* instructions: "You help customers.",
17+
* model: "databricks-claude-sonnet-4-5",
18+
* tools: {
19+
* get_weather: tool({ ... }),
20+
* },
21+
* });
22+
* ```
23+
*/
24+
export function createAgent(def: AgentDefinition): AgentDefinition {
25+
detectCycles(def);
26+
return def;
27+
}
28+
29+
/**
30+
* Walks the `agents: { ... }` sub-agent tree via DFS and throws if a cycle is
31+
* found. Cycles would cause infinite recursion at tool-invocation time.
32+
*/
33+
function detectCycles(def: AgentDefinition): void {
34+
const visiting = new Set<AgentDefinition>();
35+
const visited = new Set<AgentDefinition>();
36+
37+
const walk = (current: AgentDefinition, path: string[]): void => {
38+
if (visited.has(current)) return;
39+
if (visiting.has(current)) {
40+
throw new ConfigurationError(
41+
`Agent sub-agent cycle detected: ${path.join(" -> ")}`,
42+
);
43+
}
44+
visiting.add(current);
45+
for (const [childKey, child] of Object.entries(current.agents ?? {})) {
46+
walk(child, [...path, childKey]);
47+
}
48+
visiting.delete(current);
49+
visited.add(current);
50+
};
51+
52+
walk(def, [def.name ?? "(root)"]);
53+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { randomUUID } from "node:crypto";
2+
import type {
3+
AgentAdapter,
4+
AgentEvent,
5+
AgentToolDefinition,
6+
Message,
7+
} from "shared";
8+
import {
9+
type FunctionTool,
10+
functionToolToDefinition,
11+
isFunctionTool,
12+
} from "../plugins/agents/tools/function-tool";
13+
import { isHostedTool } from "../plugins/agents/tools/hosted-tools";
14+
import type {
15+
AgentDefinition,
16+
AgentTool,
17+
ToolkitEntry,
18+
} from "../plugins/agents/types";
19+
import { isToolkitEntry } from "../plugins/agents/types";
20+
21+
export interface RunAgentInput {
22+
/** Seed messages for the run. Either a single user string or a full message list. */
23+
messages: string | Message[];
24+
/** Abort signal for cancellation. */
25+
signal?: AbortSignal;
26+
}
27+
28+
export interface RunAgentResult {
29+
/** Aggregated text output from all `message_delta` events. */
30+
text: string;
31+
/** Every event the adapter yielded, in order. Useful for inspection/tests. */
32+
events: AgentEvent[];
33+
}
34+
35+
/**
36+
* Standalone agent execution without `createApp`. Resolves the adapter, binds
37+
* inline tools, and drives the adapter's `run()` loop to completion.
38+
*
39+
* Limitations vs. running through the agents() plugin:
40+
* - No OBO: there is no HTTP request, so plugin tools run as the service
41+
* principal (when they work at all).
42+
* - Plugin tools (`ToolkitEntry`) are not supported — they require a live
43+
* `PluginContext` that only exists when registered in a `createApp`
44+
* instance. This function throws a clear error if encountered.
45+
* - Sub-agents (`agents: { ... }` on the def) are executed as nested
46+
* `runAgent` calls with no shared thread state.
47+
*/
48+
export async function runAgent(
49+
def: AgentDefinition,
50+
input: RunAgentInput,
51+
): Promise<RunAgentResult> {
52+
const adapter = await resolveAdapter(def);
53+
const messages = normalizeMessages(input.messages, def.instructions);
54+
const toolIndex = buildStandaloneToolIndex(def);
55+
const tools = Array.from(toolIndex.values()).map((e) => e.def);
56+
57+
const signal = input.signal;
58+
59+
const executeTool = async (name: string, args: unknown): Promise<unknown> => {
60+
const entry = toolIndex.get(name);
61+
if (!entry) throw new Error(`Unknown tool: ${name}`);
62+
if (entry.kind === "function") {
63+
return entry.tool.execute(args as Record<string, unknown>);
64+
}
65+
if (entry.kind === "subagent") {
66+
const subInput: RunAgentInput = {
67+
messages:
68+
typeof args === "object" &&
69+
args !== null &&
70+
typeof (args as { input?: unknown }).input === "string"
71+
? (args as { input: string }).input
72+
: JSON.stringify(args),
73+
signal,
74+
};
75+
const res = await runAgent(entry.agentDef, subInput);
76+
return res.text;
77+
}
78+
throw new Error(
79+
`runAgent: tool "${name}" is a ${entry.kind} tool. ` +
80+
"Plugin toolkits and MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
81+
);
82+
};
83+
84+
const events: AgentEvent[] = [];
85+
let text = "";
86+
87+
const stream = adapter.run(
88+
{
89+
messages,
90+
tools,
91+
threadId: randomUUID(),
92+
signal,
93+
},
94+
{ executeTool, signal },
95+
);
96+
97+
for await (const event of stream) {
98+
if (signal?.aborted) break;
99+
events.push(event);
100+
if (event.type === "message_delta") {
101+
text += event.content;
102+
} else if (event.type === "message") {
103+
text = event.content;
104+
}
105+
}
106+
107+
return { text, events };
108+
}
109+
110+
async function resolveAdapter(def: AgentDefinition): Promise<AgentAdapter> {
111+
const { model } = def;
112+
if (!model) {
113+
const { DatabricksAdapter } = await import("../agents/databricks");
114+
return DatabricksAdapter.fromModelServing();
115+
}
116+
if (typeof model === "string") {
117+
const { DatabricksAdapter } = await import("../agents/databricks");
118+
return DatabricksAdapter.fromModelServing(model);
119+
}
120+
return await model;
121+
}
122+
123+
function normalizeMessages(
124+
input: string | Message[],
125+
instructions: string,
126+
): Message[] {
127+
const systemMessage: Message = {
128+
id: "system",
129+
role: "system",
130+
content: instructions,
131+
createdAt: new Date(),
132+
};
133+
if (typeof input === "string") {
134+
return [
135+
systemMessage,
136+
{
137+
id: randomUUID(),
138+
role: "user",
139+
content: input,
140+
createdAt: new Date(),
141+
},
142+
];
143+
}
144+
return [systemMessage, ...input];
145+
}
146+
147+
type StandaloneEntry =
148+
| {
149+
kind: "function";
150+
def: AgentToolDefinition;
151+
tool: FunctionTool;
152+
}
153+
| {
154+
kind: "subagent";
155+
def: AgentToolDefinition;
156+
agentDef: AgentDefinition;
157+
}
158+
| {
159+
kind: "toolkit";
160+
def: AgentToolDefinition;
161+
entry: ToolkitEntry;
162+
}
163+
| {
164+
kind: "hosted";
165+
def: AgentToolDefinition;
166+
};
167+
168+
function buildStandaloneToolIndex(
169+
def: AgentDefinition,
170+
): Map<string, StandaloneEntry> {
171+
const index = new Map<string, StandaloneEntry>();
172+
173+
for (const [key, tool] of Object.entries(def.tools ?? {})) {
174+
index.set(key, classifyTool(key, tool));
175+
}
176+
177+
for (const [childKey, child] of Object.entries(def.agents ?? {})) {
178+
const toolName = `agent-${childKey}`;
179+
index.set(toolName, {
180+
kind: "subagent",
181+
agentDef: { ...child, name: child.name ?? childKey },
182+
def: {
183+
name: toolName,
184+
description:
185+
child.instructions.slice(0, 120) ||
186+
`Delegate to the ${childKey} sub-agent`,
187+
parameters: {
188+
type: "object",
189+
properties: {
190+
input: {
191+
type: "string",
192+
description: "Message to send to the sub-agent.",
193+
},
194+
},
195+
required: ["input"],
196+
},
197+
},
198+
});
199+
}
200+
201+
return index;
202+
}
203+
204+
function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
205+
if (isToolkitEntry(tool)) {
206+
return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool };
207+
}
208+
if (isFunctionTool(tool)) {
209+
return {
210+
kind: "function",
211+
tool,
212+
def: { ...functionToolToDefinition(tool), name: key },
213+
};
214+
}
215+
if (isHostedTool(tool)) {
216+
return {
217+
kind: "hosted",
218+
def: {
219+
name: key,
220+
description: `Hosted tool: ${tool.type}`,
221+
parameters: { type: "object", properties: {} },
222+
},
223+
};
224+
}
225+
throw new Error(`runAgent: unrecognized tool shape at key "${key}"`);
226+
}

packages/appkit/src/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export {
4343
} from "./connectors/lakebase";
4444
export { getExecutionContext } from "./context";
4545
export { createApp } from "./core";
46+
export { createAgent } from "./core/create-agent-def";
47+
export {
48+
type RunAgentInput,
49+
type RunAgentResult,
50+
runAgent,
51+
} from "./core/run-agent";
4652
// Errors
4753
export {
4854
AppKitError,
@@ -64,11 +70,18 @@ export {
6470
} from "./plugin";
6571
export { analytics, files, genie, lakebase, server, serving } from "./plugins";
6672
export {
73+
type AgentDefinition,
74+
type AgentsPluginConfig,
6775
type AgentTool,
76+
agents,
77+
type BaseSystemPromptOption,
6878
isToolkitEntry,
79+
loadAgentFromFile,
80+
loadAgentsFromDir,
81+
type PromptContext,
6982
type ToolkitEntry,
7083
type ToolkitOptions,
71-
} from "./plugins/agents/types";
84+
} from "./plugins/agents";
7285
export {
7386
type FunctionTool,
7487
type HostedTool,

0 commit comments

Comments
 (0)