Multi-agent framework with pluggable modules, persistent state, streaming inference, and concurrent tool execution.
Agent Framework orchestrates one or more LLM-powered agents that interact with the world through modules (pluggable capability providers). It handles the full lifecycle: event processing, context compilation, inference (streaming or request/response), tool dispatch, and state persistence via Chronicle.
External events (Discord, API, MCPL servers, timers)
↓
ProcessQueue → Module.onProcess() → EventResponse
↓ ↓
Inference trigger Messages / state updates
↓
Context compilation (with injections from modules + MCPL hooks)
↓
Membrane (LLM abstraction) → YieldingStream
↓
Tool calls → Module.handleToolCall() → results → stream resumes
↓
Agent speech → Module.onAgentSpeech() → external delivery
import { AgentFramework, ApiModule, ApiServer } from '@connectome/agent-framework';
import { Membrane, AnthropicAdapter } from 'membrane';
const membrane = new Membrane({ adapter: new AnthropicAdapter() });
const framework = await AgentFramework.create({
storePath: './data/store',
membrane,
agents: [{
name: 'assistant',
model: 'claude-sonnet-4-20250514',
systemPrompt: 'You are a helpful assistant.',
}],
modules: [new ApiModule()],
});
framework.start();
const server = new ApiServer(framework, { port: 8765 });
await server.start();An agent wraps an LLM identity: model, system prompt, context strategy, and tool permissions. Multiple agents can coexist, each with independent context and inference state.
{
name: 'researcher',
model: 'claude-opus-4-20250514',
systemPrompt: '...',
strategy: new AutobiographicalStrategy({ recentWindowTokens: 30000 }),
allowedTools: ['search', 'read', 'write'], // or 'all'
triggerSources: ['discord'], // or 'all'
maxTokens: 8192,
maxStreamTokens: 150_000, // input token budget before stream restart
}Inference modes:
- Streaming (default in framework):
startStreamWithInjections()returns aYieldingStreamthat emits tokens, tool calls, and completion events. The framework drives the stream, dispatches tools, and resumes automatically. - Request/response:
runInferenceWithInjections()for simple complete-and-return usage with optionalAbortSignalfor cancellation.
State machine:
idle → inferring → streaming ⇄ waiting_for_tools → ready → streaming → ... → idle
↘ (abort) → idle
Modules are pluggable capability providers. They process events, expose tools, deliver speech, and optionally inject context before inference.
interface Module {
readonly name: string;
start(ctx: ModuleContext): Promise<void>;
stop(): Promise<void>;
getTools(): ToolDefinition[];
handleToolCall(call: ToolCall): Promise<ToolResult>;
onProcess(event: ProcessEvent, state: ProcessState): Promise<EventResponse>;
// Optional
onAgentSpeech?(agentName: string, content: ContentBlock[], context: SpeechContext): Promise<void>;
gatherContext?(agentName: string): Promise<ContextInjection[]>;
}An EventResponse can add/edit/remove messages, request inference, signal tool changes, and atomically update module state:
return {
addMessages: [{ participant: 'user', content: [{ type: 'text', text: '...' }] }],
requestInference: true,
stateUpdate: { lastProcessed: Date.now() },
};Built-in modules:
- DiscordModule - Discord bot integration (messages, reactions, threads, typing indicators)
- ApiModule - WebSocket API event processing
- WorkspaceModule - Mountable filesystem abstraction with Chronicle tree state, filesystem watching, and materialization
The framework runs an event loop over a ProcessQueue. Each event is processed by all modules, which return EventResponse objects. If any response requests inference, the framework compiles context and starts a stream.
Event types: ExternalMessageEvent, ToolCallEvent, ToolResultEvent, InferenceRequestEvent, McplPushEvent, McplChannelIncomingEvent, TimerFiredEvent, ApiMessageEvent, ModuleEvent, CustomEvent
Each agent has a ContextManager (from @connectome/context-manager) that maintains conversation history with optional compression strategies:
- PassthroughStrategy - No compression, raw message replay up to budget
- AutobiographicalStrategy - Chunks old messages and summarizes them into diary entries, preserving recent context uncompressed
Before inference, modules and MCPL hooks can inject additional context via gatherContext().
All state is persisted in a Chronicle store:
| State | Strategy | Description |
|---|---|---|
framework/state |
snapshot | Agent configs |
framework/inference-log |
append_log | Raw LLM requests/responses |
messages |
(context-manager) | Shared conversation log |
agents/{name}/context |
(context-manager) | Per-agent context log |
modules/{name}/state |
snapshot | Per-module persistent state |
Chronicle's branching support enables time-travel and what-if exploration across all state.
If an inference API rejects message history and the normal host must remain stopped, create a safe branch with the recovery CLI:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--message-id 123456789012345678 \
--branch recovery/cairn/poisoned-tailThe specified Discord message is the last message the agent will still see;
everything after it is left on the old branch. The command scans in bounded
windows, never compiles message content, activates
the new Chronicle branch, and writes only Discord {serverId, channelId, messageId} metadata to
<store>/recovery/discord-awareness-outbox.json. When the host and that
agent's discord-mcpl bot reconnect, each addressable discarded message is
marked with 💤. Delivery state is recorded per message: retryable failures
remain queued, permanent deleted/inaccessible-message failures remain in the
audit ledger without blocking later markers. Portal and other non-Discord
records are ignored.
When the safe point is an assistant/tool-side record rather than a Discord message, use its exact ContextManager message ID:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--context-id 15184Use --dry-run to inspect the proposed branch and message IDs without writing.
--message-id also accepts a Discord message link. The older --messages N
mode remains available when a count of internal context entries is genuinely
desired.
To branch at the current message while suppressing selected earlier messages only on the new branch:
agent-framework-recover \
--store ./data/agent-store \
--agent cairn \
--message-id <current-discord-message-id> \
--suppress <message-id-to-hide> \
--suppress <another-message-id>--suppress may be repeated or given a comma-separated list.
--suppress-range <first>..<last> suppresses the inclusive context interval
between two Discord messages, including intervening agent/tool entries. These
removals exist only on the recovery branch; the source branch remains intact.
Suppressed Discord messages are queued for the same 💤 awareness marker.
Selections that split a sharded body group or a tool-use/tool-result exchange
are rejected. The suppression plan is journaled before the branch switch; if
the recovery process is interrupted between interval removals, framework
startup resumes the remaining atomic intervals before connecting MCPL.
For old stores whose messages lack metadata.serverId, supply
--discord-server discord (or the configured Discord MCPL server id). The
normal agent host must be stopped while this command has the Chronicle store
open.
The marker sidecar is a retained operation ledger, not a delete-on-success
queue. Switching back to the source branch queues removal of the bot's marker;
returning to the recovery branch queues it again. Initial MCPL events remain
buffered until the ledger has been reconciled. Startup, reconnect, runtime
list-change, and online undo use one framework-global generation: every MCPL
data plane waits while all control planes remain live for registration and
marker service. Awareness marker calls also have a mandatory deadline that is
independent of requestTimeoutMs (including when that value is 0); configure
it with discordAwarenessDeadlineMs (default 10000ms, clamped to 50..60000ms).
Configure the online marker with discordAwarenessEmoji; the offline CLI
accepts --emoji.
Optional host-side implementation of the MCP Live protocol. External servers (game engines, dev tools, etc.) can:
- Push events into agent context
- Hook into inference lifecycle (beforeInference/afterInference)
- Initiate inference requests
- Publish and observe channels
- Provide tools (namespaced automatically)
{
mcplServers: [{
id: 'game-engine',
command: 'node',
args: ['./game-server.js'],
toolPrefix: 'game',
}],
}- Start: Framework calls
agent.startStreamWithInjections()→YieldingStream - Drive: Framework iterates stream events (tokens, tool-calls, complete, error)
- Tool yield: Stream yields tool calls → framework dispatches concurrently → collects results
- Resume: All results in → stream resumes with tool results
- Budget restart: If input tokens exceed
maxStreamTokens, the stream is cancelled, context is recompressed, and a fresh stream starts - Complete: Final response saved to context, agent returns to idle
// Cancel in-flight inference for an agent
framework.abortInference('assistant', 'user requested stop');The non-streaming path uses AbortSignal forwarded to membrane.stream(), which returns an AbortedResponse with partial content. The streaming path cancels the YieldingStream directly.
WebSocket server for external clients (UIs, scripts, other agents).
const server = new ApiServer(framework, { port: 8765 });
await server.start();Commands: message.send, message.list, inference.request, inference.abort, branch.*, agent.*, module.*, store.*, inference.tail/inspect/search, events.*
Also available as an MCP server via the agent-framework-mcp binary.
Subscribe to trace events for logging, UI updates, or debugging:
framework.onTrace((event) => {
// inference:started, inference:completed, inference:aborted
// tool:started, tool:completed, tool:failed
// process:received, process:completed
// message:added, module:added, module:removed
console.log(event.type, event);
});Trace events are observability-only. They are intended for logs, metrics, UI updates, and debugging, not framework-internal control flow. Internal lifecycle code should use state-machine signals or process events instead of consuming traces, so trace emit order does not become an API contract.
Query raw inference logs:
const logs = framework.queryInferenceLogs({ agentName: 'assistant', limit: 10 });| Package | Role |
|---|---|
| membrane | LLM provider abstraction (Anthropic, Bedrock, OpenRouter) |
| @connectome/context-manager | Context window management and compression |
| chronicle | Branchable persistent event store (Rust + N-API) |
npm install
npm run build # TypeScript compilation
npm run dev # Watch mode
npm test # Run tests
npm run typecheck # Type-check without emittingRequires Node.js >= 20. Chronicle requires a Rust toolchain for native module compilation (npm run build in the chronicle directory).