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
145 changes: 136 additions & 9 deletions packages/appkit/src/core/run-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import type {
AgentEvent,
AgentToolDefinition,
Message,
PluginConstructor,
PluginData,
ToolProvider,
} from "shared";
import { isFromPluginMarker } from "../plugins/agents/from-plugin";
import { resolveToolkitFromProvider } from "../plugins/agents/toolkit-resolver";
import {
type FunctionTool,
functionToolToDefinition,
Expand All @@ -23,6 +28,14 @@ export interface RunAgentInput {
messages: string | Message[];
/** Abort signal for cancellation. */
signal?: AbortSignal;
/**
* Optional plugin list used to resolve `fromPlugin` markers in `def.tools`.
* Required when the def contains any `...fromPlugin(factory)` spreads;
* ignored otherwise. `runAgent` constructs a fresh instance per plugin
* and dispatches tool calls against it as the service principal (no
* OBO — there is no HTTP request in standalone mode).
*/
plugins?: PluginData<PluginConstructor, unknown, string>[];
}

export interface RunAgentResult {
Expand All @@ -39,19 +52,20 @@ export interface RunAgentResult {
* 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.
* - Hosted tools (MCP) are not supported — they require a live MCP client
* that only exists inside the agents plugin.
* - Sub-agents (`agents: { ... }` on the def) are executed as nested
* `runAgent` calls with no shared thread state.
* - Plugin tools (`fromPlugin` markers or `ToolkitEntry` spreads) require
* passing `plugins: [...]` via `RunAgentInput`.
*/
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 toolIndex = buildStandaloneToolIndex(def, input.plugins ?? []);
const tools = Array.from(toolIndex.values()).map((e) => e.def);

const signal = input.signal;
Expand All @@ -62,6 +76,13 @@ export async function runAgent(
if (entry.kind === "function") {
return entry.tool.execute(args as Record<string, unknown>);
}
if (entry.kind === "toolkit") {
return entry.provider.executeAgentTool(
entry.localName,
args as Record<string, unknown>,
signal,
);
}
if (entry.kind === "subagent") {
const subInput: RunAgentInput = {
messages:
Expand All @@ -71,13 +92,14 @@ export async function runAgent(
? (args as { input: string }).input
: JSON.stringify(args),
signal,
plugins: input.plugins,
};
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(...)] }).",
"Hosted/MCP tools are only usable via createApp({ plugins: [..., agents(...)] }).",
);
};

Expand Down Expand Up @@ -158,20 +180,61 @@ type StandaloneEntry =
| {
kind: "toolkit";
def: AgentToolDefinition;
entry: ToolkitEntry;
provider: ToolProvider;
pluginName: string;
localName: string;
}
| {
kind: "hosted";
def: AgentToolDefinition;
};

/**
* Resolves `def.tools` (string-keyed entries + symbol-keyed `fromPlugin`
* markers) and `def.agents` (sub-agents) into a flat dispatch index.
* Symbol-keyed markers are resolved against `plugins`; missing references
* throw with an `Available: …` listing.
*/
function buildStandaloneToolIndex(
def: AgentDefinition,
plugins: PluginData<PluginConstructor, unknown, string>[],
): Map<string, StandaloneEntry> {
const index = new Map<string, StandaloneEntry>();
const tools = def.tools;

for (const [key, tool] of Object.entries(def.tools ?? {})) {
index.set(key, classifyTool(key, tool));
const symbolKeys = tools ? Object.getOwnPropertySymbols(tools) : [];
if (symbolKeys.length > 0) {
const providerCache = new Map<string, ToolProvider>();
for (const sym of symbolKeys) {
const marker = (tools as Record<symbol, unknown>)[sym];
if (!isFromPluginMarker(marker)) continue;

const provider = resolveStandaloneProvider(
marker.pluginName,
plugins,
providerCache,
);
const entries = resolveToolkitFromProvider(
marker.pluginName,
provider,
marker.opts,
);
for (const [key, entry] of Object.entries(entries)) {
index.set(key, {
kind: "toolkit",
provider,
pluginName: entry.pluginName,
localName: entry.localName,
def: { ...entry.def, name: key },
});
}
}
}

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

for (const [childKey, child] of Object.entries(def.agents ?? {})) {
Expand Down Expand Up @@ -203,7 +266,7 @@ function buildStandaloneToolIndex(

function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
if (isToolkitEntry(tool)) {
return { kind: "toolkit", def: { ...tool.def, name: key }, entry: tool };
return toolkitEntryToStandalone(key, tool);
}
if (isFunctionTool(tool)) {
return {
Expand All @@ -224,3 +287,67 @@ function classifyTool(key: string, tool: AgentTool): StandaloneEntry {
}
throw new Error(`runAgent: unrecognized tool shape at key "${key}"`);
}

/**
* Pre-`fromPlugin` code could reach a `ToolkitEntry` by calling
* `.toolkit()` at module scope (which requires an instance). Those entries
* still flow through `def.tools` but without a provider we can dispatch
* against — runAgent cannot execute them and errors clearly.
*/
function toolkitEntryToStandalone(
key: string,
entry: ToolkitEntry,
): StandaloneEntry {
const def: AgentToolDefinition = { ...entry.def, name: key };
return {
kind: "hosted",
def: {
...def,
description:
`${def.description ?? ""} ` +
`[runAgent: this ToolkitEntry refers to plugin '${entry.pluginName}' but ` +
"runAgent cannot dispatch it without the plugin instance. Pass the " +
"plugin via plugins: [...] and use fromPlugin(factory) instead of " +
".toolkit() spreads.]".trim(),
},
};
}

function resolveStandaloneProvider(
pluginName: string,
plugins: PluginData<PluginConstructor, unknown, string>[],
cache: Map<string, ToolProvider>,
): ToolProvider {
const cached = cache.get(pluginName);
if (cached) return cached;

const match = plugins.find((p) => p.name === pluginName);
if (!match) {
const available = plugins.map((p) => p.name).join(", ") || "(none)";
throw new Error(
`runAgent: agent references plugin '${pluginName}' via fromPlugin(), but ` +
"that plugin is missing from RunAgentInput.plugins. " +
`Available: ${available}.`,
);
}

const instance = new match.plugin({
...(match.config ?? {}),
name: pluginName,
});
const provider = instance as unknown as ToolProvider;
if (
typeof (provider as { getAgentTools?: unknown }).getAgentTools !==
"function" ||
typeof (provider as { executeAgentTool?: unknown }).executeAgentTool !==
"function"
) {
throw new Error(
`runAgent: plugin '${pluginName}' is not a ToolProvider ` +
"(missing getAgentTools/executeAgentTool). Only ToolProvider plugins " +
"are supported via fromPlugin() in runAgent.",
);
}
cache.set(pluginName, provider);
return provider;
}
4 changes: 4 additions & 0 deletions packages/appkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@ export {
type AgentDefinition,
type AgentsPluginConfig,
type AgentTool,
type AgentTools,
agentIdFromMarkdownPath,
agents,
type BaseSystemPromptOption,
type FromPluginMarker,
fromPlugin,
isFromPluginMarker,
isToolkitEntry,
loadAgentFromFile,
loadAgentsFromDir,
Expand Down
98 changes: 70 additions & 28 deletions packages/appkit/src/plugins/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { PluginManifest } from "../../registry";
import { agentStreamDefaults } from "./defaults";
import { EventChannel } from "./event-channel";
import { AgentEventTranslator } from "./event-translator";
import { isFromPluginMarker } from "./from-plugin";
import { loadAgentsFromDir } from "./load-agents";
import manifest from "./manifest.json";
import {
Expand All @@ -30,6 +31,7 @@ import {
import { buildBaseSystemPrompt, composeSystemPrompt } from "./system-prompt";
import { InMemoryThreadStore } from "./thread-store";
import { ToolApprovalGate } from "./tool-approval-gate";
import { resolveToolkitFromProvider } from "./toolkit-resolver";
import {
AppKitMcpClient,
functionToolToDefinition,
Expand Down Expand Up @@ -325,7 +327,11 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
src: AgentSource,
): Promise<Map<string, ResolvedToolEntry>> {
const index = new Map<string, ResolvedToolEntry>();
const hasExplicitTools = def.tools && Object.keys(def.tools).length > 0;
const toolsRecord = def.tools ?? {};
const hasExplicitTools =
def.tools !== undefined &&
(Object.keys(toolsRecord).length > 0 ||
Object.getOwnPropertySymbols(toolsRecord).length > 0);
const hasExplicitSubAgents =
def.agents && Object.keys(def.agents).length > 0;

Expand Down Expand Up @@ -364,9 +370,13 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
});
}

// 2. Explicit tools (toolkit entries, function tools, hosted tools)
// 2. fromPlugin markers — resolve against registered ToolProviders first so
// explicit string-keyed tools can still overwrite on the same key.
this.resolveFromPluginMarkers(agentName, toolsRecord, index);

// 3. Explicit tools (toolkit entries, function tools, hosted tools)
const hostedToCollect: import("./tools/hosted-tools").HostedTool[] = [];
for (const [key, tool] of Object.entries(def.tools ?? {})) {
for (const [key, tool] of Object.entries(toolsRecord)) {
if (isToolkitEntry(tool)) {
index.set(key, {
source: "toolkit",
Expand Down Expand Up @@ -418,32 +428,19 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
provider,
} of this.context.getToolProviders()) {
if (pluginName === this.name) continue;
const withToolkit = provider as ToolProvider & {
toolkit?: (opts?: unknown) => Record<string, unknown>;
};
if (typeof withToolkit.toolkit === "function") {
const entries = withToolkit.toolkit() as Record<string, unknown>;
for (const [key, maybeEntry] of Object.entries(entries)) {
if (!isToolkitEntry(maybeEntry)) continue;
if (maybeEntry.autoInheritable !== true) {
recordSkip(maybeEntry.pluginName, maybeEntry.localName);
continue;
}
index.set(key, {
source: "toolkit",
pluginName: maybeEntry.pluginName,
localName: maybeEntry.localName,
def: { ...maybeEntry.def, name: key },
});
inherited.push(key);
const entries = resolveToolkitFromProvider(pluginName, provider);
for (const [key, entry] of Object.entries(entries)) {
if (entry.autoInheritable !== true) {
recordSkip(entry.pluginName, entry.localName);
continue;
}
continue;
}
// Fallback: providers without a toolkit() still expose getAgentTools().
// These cannot be selectively opted in per tool, so we conservatively
// skip them during auto-inherit and require explicit `tools:` wiring.
for (const tool of provider.getAgentTools()) {
recordSkip(pluginName, tool.name);
index.set(key, {
source: "toolkit",
pluginName: entry.pluginName,
localName: entry.localName,
def: { ...entry.def, name: key },
});
inherited.push(key);
}
}

Expand Down Expand Up @@ -471,6 +468,51 @@ export class AgentsPlugin extends Plugin implements ToolProvider {
}
}

/**
* Walks the symbol-keyed `fromPlugin` markers in an agent's `tools` record
* and resolves each one against a registered `ToolProvider`. Throws with a
* helpful `Available: …` listing if a referenced plugin isn't registered.
*/
private resolveFromPluginMarkers(
agentName: string,
toolsRecord: Record<string | symbol, unknown>,
index: Map<string, ResolvedToolEntry>,
): void {
const symbolKeys = Object.getOwnPropertySymbols(toolsRecord);
if (symbolKeys.length === 0) return;

const providers = this.context?.getToolProviders() ?? [];

for (const sym of symbolKeys) {
const marker = (toolsRecord as Record<symbol, unknown>)[sym];
if (!isFromPluginMarker(marker)) continue;

const providerEntry = providers.find((p) => p.name === marker.pluginName);
if (!providerEntry) {
const available = providers.map((p) => p.name).join(", ") || "(none)";
throw new Error(
`Agent '${agentName}' references plugin '${marker.pluginName}' via ` +
`fromPlugin(), but that plugin is not registered in createApp. ` +
`Available: ${available}.`,
);
}

const entries = resolveToolkitFromProvider(
marker.pluginName,
providerEntry.provider,
marker.opts,
);
for (const [key, entry] of Object.entries(entries)) {
index.set(key, {
source: "toolkit",
pluginName: entry.pluginName,
localName: entry.localName,
def: { ...entry.def, name: key },
});
}
}
}

private async connectHostedTools(
hostedTools: import("./tools/hosted-tools").HostedTool[],
index: Map<string, ResolvedToolEntry>,
Expand Down
Loading