@ellie-ai/agent-plugin
v0.2.0
Published
Agent middleware for Ellie runtime - orchestrates model calls and tool execution
Readme
@ellie-ai/agent-plugin
Agent runtime plugin for Ellie – orchestrates model calls and tool execution.
Overview
This package provides a runtime plugin for building AI agents with Ellie. It composes four middleware layers:
- Agent orchestration - Manages the agentic loop (model → tools → model)
- Model driver - Calls the LLM provider
- Tools executor - Executes tools with concurrency control
- Output truncation - Truncates large tool outputs to prevent context overflow
All agent actions are prefixed with AGENT_… (for example AGENT_MODEL_REQUESTED, AGENT_TOOL_CALL_EXECUTED). When creating your own plugin, follow the same pattern with a unique prefix so runtime diagnostics clearly show where each action originates.
First time with the runtime itself? Walk through
packages/runtime/README.mdto understand the execution lifecycle and middleware helpers this plugin relies on.
Key difference from v1: agentPlugin() returns a runtime plugin, not a runtime. You can register it alongside your own plugins and middleware.
Installation
bun add @ellie-ai/agent-plugin @ellie-ai/runtimeQuick Start
import { createRuntime } from '@ellie-ai/runtime';
import { agentPlugin } from '@ellie-ai/agent-plugin';
import { openAI } from '@ellie-ai/model-providers';
const agent = agentPlugin({
model: openAI('gpt-4'),
tools: [calculatorTool, searchTool],
systemMessage: "You are a helpful assistant",
maxLoops: 10,
});
const runtime = createRuntime({
plugins: [agent],
});
const handle = runtime.execute("What is 2 + 2?");
await handle.completed;
// State is fully typed - no casts needed!
console.log(runtime.getState().agent.conversation);API
agentPlugin(config: AgentConfig)
Creates an AgentPlugin. Register it with the runtime (via plugins) to enable agent behaviour.
Config:
model: ModelProvider- LLM provider (required)tools?: Tool[]- Available tools (optional)toolPlugins?: AgentToolPlugin[]- Tool plugins (e.g.,@ellie-ai/agent-tool-ocr) that callinitTool()and optionally contribute middleware/reducer statesystemMessage?: string- System message (optional)maxLoops?: number- Maximum agentic loops before stopping (optional)initialConversation?: ConversationItem[]- Initial conversation (optional)concurrency?: number- Tool execution concurrency limit (optional)maxToolOutputChars?: number- Maximum characters for tool output before truncation (default: 50,000)
Returns: AgentPlugin
AgentPlugin exposes:
key: "agent"– reducer key used by the runtimereducer– manages the agent state sliceinitialState– bootstrapped from your configmiddleware– the composed orchestration → model → tools → truncation pipelinestages– individual middleware stages (orchestration,model,tools,truncation) for advanced composition
Need raw pieces?
agentReducerandinitialAgentStateare still exported for advanced scenarios, but most users should rely on the plugin.
Tool Plugins & Runtime Registration
Tool plugins let you package capabilities (OCR, vector search, etc.) independently. We recommend naming them @ellie-ai/agent-tool-<feature> so it’s clear they target the agent (e.g. @ellie-ai/agent-tool-ocr).
import type { AgentToolPlugin } from "@ellie-ai/agent-plugin";
const ocrPlugin: AgentToolPlugin<"ocrCache", { executions: number }> = {
key: "ocrCache",
reducer: (state = { executions: 0 }, action) =>
action.type === "AGENT_TOOL_CALL_EXECUTED"
? { executions: state.executions + 1 }
: state,
middleware: () => (next) => (action) => next(action),
initTool() {
return {
name: "ocr_document",
description: "Extract text from PDFs",
parameters: { type: "object", properties: { file: { type: "string" } } },
execute: async ({ file }) => runOcr(file as string),
};
},
};
const agent = agentPlugin({
model,
toolPlugins: [ocrPlugin],
});Plugins that need to register or remove tools at runtime can dispatch the provided helpers:
import { registerAgentTool, deregisterAgentTool } from "@ellie-ai/agent-plugin";
runtime.dispatch(
registerAgentTool({
name: "feature_flag_tool",
description: "Only available for beta users",
parameters: {},
execute: featureFlaggedExecute,
})
);
runtime.dispatch(deregisterAgentTool("feature_flag_tool"));The helpers simply emit AGENT_REGISTER_TOOL / AGENT_DEREGISTER_TOOL actions, so the standard tooling middleware sees the updated registry immediately.
How It Works
1. Execution Flow
User: runtime.execute("Hello")
↓
RUNTIME_EXECUTION_STARTED (runtime)
↓
Agent middleware:
- Tracks execution turns and dispatches AGENT_MODEL_REQUESTED
↓
Model middleware:
- Calls model provider
- Dispatches AGENT_MODEL_RESPONDED
↓
Agent middleware:
- Checks for tool calls
- Dispatches AGENT_TOOL_CALL_REQUESTED for each tool
↓
Tools middleware:
- Executes tools
- Dispatches AGENT_TOOL_CALL_EXECUTED
↓
Agent middleware:
- When all tools done, dispatches AGENT_MODEL_REQUESTED again
- Loop continues until model responds without tool calls
↓
RUNTIME_EXECUTION_COMPLETED (runtime)2. Actions
The agent middleware dispatches and listens for these actions:
Dispatched:
AGENT_MODEL_REQUESTED- Kick off model generationAGENT_TOOL_CALL_REQUESTED- Request tool executionAGENT_LOOP_INCREMENTED- Track agentic loop count
Listened:
RUNTIME_EXECUTION_STARTED/RUNTIME_EXECUTION_INPUT- From runtimeAGENT_MODEL_RESPONDED- From model middlewareAGENT_TOOL_CALL_EXECUTED/AGENT_TOOL_CALL_FAILED/AGENT_TOOL_MISSING- From tools middlewareAGENT_OUTPUT_TRUNCATED- From truncation middleware (when tool output exceedsmaxToolOutputChars)
See AgentActionDescriptions export for LLM-friendly descriptions.
3. MaxLoops
The maxLoops config prevents infinite loops:
const agent = agentPlugin({
model: openAI('gpt-4'),
maxLoops: 5 // Stop after 5 agentic loops
});When the limit is reached, the agent stops requesting the model, and execution completes naturally.
4. Async work metadata
Every asynchronous task the agent schedules (model calls, tool execution, loop bookkeeping) uses dispatchWork from @ellie-ai/runtime. This helper wraps the thunk and guarantees that RUNTIME_WORK_* actions carry the triggering action’s ID/type so you can follow the work tree in logs. When you build additional middleware or extend the agent, prefer dispatchWork(api, { action, kind, run }) over dispatching raw thunks so the runtime keeps the graph observable.
Advanced Usage
Custom State Composition
Combine the agent plugin with your own plugins:
import { createRuntime, type RuntimePlugin } from '@ellie-ai/runtime';
import { agentPlugin } from '@ellie-ai/agent-plugin';
const agent = agentPlugin({ model, tools });
const analyticsPlugin: RuntimePlugin = {
key: "analytics",
reducer: analyticsReducer,
initialState: { events: [] },
middleware: analyticsMiddleware,
};
const runtime = createRuntime({
plugins: [agent, analyticsPlugin],
});Manual Middleware Composition
For advanced users who want more control:
import {
createAgentMiddleware,
createModelMiddleware,
createToolsMiddleware,
createTruncationMiddleware
} from '@ellie-ai/agent-plugin';
const orchestration = createAgentMiddleware({ maxLoops: 10 });
const modelDriver = createModelMiddleware({ provider: openAI('gpt-4') });
const toolsExecutor = createToolsMiddleware({ concurrency: 3 });
const truncation = createTruncationMiddleware({ maxOutputChars: 50_000 });
const agent = {
key: "agent",
reducer: agentReducer,
initialState: buildInitialAgentState({ tools }),
middleware: (api) => (next) => {
// Order: orchestration -> model -> tools -> truncation -> next
const withTruncation = truncation(api)(next);
const withTools = toolsExecutor(api)(withTruncation);
const withModel = modelDriver(api)(withTools);
return orchestration(api)(withModel);
},
};
const runtime = createRuntime({
plugins: [agent],
});Types
ConversationItem
type ConversationItem =
| { type: "message"; role: "user" | "assistant" | "system"; content: string }
| { type: "function_call"; id: string; name: string; args: Record<string, unknown> }
| { type: "function_call_output"; call_id: string; output: string }
| { type: "reasoning"; summary: string };Tool
interface Tool {
name: string;
description: string;
parameters: unknown; // JSON Schema
execute: (args: Record<string, unknown>) => Promise<string>;
}ModelProvider
interface ModelProvider {
generate(
conversation: ConversationItem[],
tools: Tool[]
): Promise<ModelResponse>;
generateStream?(
conversation: ConversationItem[],
tools: Tool[],
onToken: (chunk: { type: "content" | "function_call"; contentChunk: string }) => void,
onReasoning: (chunk: { type: "reasoning"; reasoningChunk: string }) => void,
signal?: AbortSignal
): Promise<ModelResponse>;
}ModelResponse
interface ModelResponse {
newConversationItems: ConversationItem[];
toolCalls?: ToolCall[];
usage?: {
inputTokens: number;
outputTokens: number;
reasoningTokens?: number;
};
reasoning?: string;
}Example: Building a Calculator Tool
import type { Tool } from '@ellie-ai/agent-plugin';
const calculatorTool: Tool = {
name: "calculate",
description: "Performs basic arithmetic operations",
parameters: {
type: "object",
properties: {
operation: { type: "string", enum: ["add", "subtract", "multiply", "divide"] },
a: { type: "number" },
b: { type: "number" }
},
required: ["operation", "a", "b"]
},
execute: async (args) => {
const { operation, a, b } = args as { operation: string; a: number; b: number };
switch (operation) {
case "add": return String(a + b);
case "subtract": return String(a - b);
case "multiply": return String(a * b);
case "divide": return String(a / b);
default: throw new Error(`Unknown operation: ${operation}`);
}
}
};Building Plugins
When building your own runtime plugins, follow this structure:
Plugin Structure
import type { RuntimePlugin, Middleware, Action } from '@ellie-ai/runtime';
// 1. Define your action types with a unique prefix
type MyAction =
| { type: 'MY_PLUGIN_STARTED'; data: string }
| { type: 'MY_PLUGIN_COMPLETED'; result: number };
// 2. Define your state shape
interface MyState {
items: string[];
count: number;
}
// 3. Create a combined action type if you handle execution actions
import type { ExecutionAction } from '@ellie-ai/runtime';
type AllActions = ExecutionAction | MyAction;
// 4. Define your plugin with typed key for state inference
export interface MyPlugin extends RuntimePlugin<'myPlugin', MyState, AllActions> {
key: 'myPlugin';
reducer: (state: MyState | undefined, action: AllActions) => MyState;
initialState: MyState;
middleware: Middleware<Record<string, unknown>, AllActions>;
}
// 5. Export a factory function
export function createMyPlugin(config: MyConfig): MyPlugin {
return {
key: 'myPlugin',
reducer: myReducer,
initialState: { items: [], count: 0 },
middleware: createMyMiddleware(config),
};
}Type Inference
The runtime infers state types from plugins automatically:
const myPlugin = createMyPlugin({ ... });
const agent = agentPlugin({ model });
const runtime = createRuntime({
plugins: [myPlugin, agent],
});
// State is fully typed!
const state = runtime.getState();
state.myPlugin.count; // number
state.agent.conversation; // ConversationItem[]Action Naming Convention
- Use a unique prefix for all your actions (e.g.,
MY_PLUGIN_...) - This makes it easy to trace action flow in logs and DevTools
- The agent uses
AGENT_..., runtime usesRUNTIME_...
Architecture
This package demonstrates Ellie's middleware pattern:
- Single responsibility: Each middleware does ONE thing (orchestrate, call model, execute tools)
- Functional style: Pure functions, no side effects outside thunks
- Observable: All actions flow through the store, fully debuggable
- Composable: Mix with your own middleware
For detailed middleware patterns, see packages/runtime/README.md.
License
MIT
