Skip to content

Latest commit

 

History

History
294 lines (189 loc) · 9.67 KB

File metadata and controls

294 lines (189 loc) · 9.67 KB

Architecture

openFunctions is easiest to understand if you start from the runtime it is built on:

ToolDefinition -> ToolRegistry -> AIAdapter

Everything else in the framework composes on top of that path.

Core Runtime

1. Tool definition

defineTool() is the source of truth.

A tool bundles:

  • a stable name
  • a description the AI reads
  • a JSON schema for parameters
  • an async handler
  • optional tags, examples, and tests
  • an optional capability contract for authority, side effects, idempotency, and verification

The TypeScript generic helps the developer. The inputSchema helps the model.

2. Registration

ToolRegistry stores tools once and makes them available everywhere:

  • direct execution with registry.execute()
  • MCP exposure through startServer()
  • provider adapters through toGeminiFormat(), toAnthropicFormat(), and toOpenAIFormat()
  • workflow steps through toolStep()
  • agent execution through filtered registries

This is the framework’s universal primitive.

3. Adapter translation

AIAdapter is the provider boundary. Adapters translate the same registered tools into each provider’s tool schema and normalize responses back into:

  • text
  • or toolCall

That is why the same tool set works with Claude, Gemini, OpenAI, xAI, and OpenRouter.

Execution Paths

Tool execution with validation

Normal flow:

  1. registry.execute(name, params)
  2. validate params against inputSchema
  3. run the handler
  4. return ToolResult

This same path is used by:

  • the MCP server
  • the interactive chat loop
  • agents
  • crews
  • workflows

MCP server

startServer() exposes the registry through Model Context Protocol:

  1. clients ask for tool schemas
  2. the server returns registered tools
  3. clients call a tool
  4. the server delegates to registry.execute()

The MCP layer is thin by design. The registry remains the execution engine.

Interactive chat

The chat loop repeatedly:

  1. sends conversation history plus registry tools to an adapter
  2. checks whether the model returned text or a toolCall
  3. executes tool calls through the registry
  4. feeds tool results back into the conversation

This is the simplest place to see the runtime in action without MCP.

Composition Layers

Workflows

Workflows are deterministic composition.

Use them when you know the control flow ahead of time. pipe(), .then(), .parallel(), and .branch() let you build explicit pipelines around:

  • tool steps
  • transformation steps
  • LLM steps

The macro flow is controlled by code. Only the LLM steps are adaptive.

Agents

An agent is not a new execution engine. It is:

  • a system prompt
  • a filtered registry
  • a reason/act/observe loop

The filtered registry is the key abstraction. Each agent only sees the tools it should use, selected by explicit tool name or by tag.

Agent loop:

  1. build the filtered registry
  2. compose the agent prompt
  3. call adapter.chat()
  4. if the model requests a tool, execute it through the filtered registry
  5. add the tool result to history
  6. repeat until the model returns text or max rounds is hit

That is why agents stay legible: they are still just tools plus a loop.

Every agent loop also emits a RunRecord. The run captures the exact resolved model, instruction and capability digests, correlation IDs, limits, and its own terminal status. A successful text response may create an OutcomeClaim, but verification and task fulfillment remain separate records. See Legible runs.

Crews

Crews are composition over agents.

  • sequential mode threads one agent’s output into the next agent’s prompt context
  • parallel mode runs all agents on the same task independently

Delegation is implemented with synthetic tools such as delegate_to_researcher. The model sees delegation as a tool call; the runtime turns that into another agent execution.

Repeating Design Patterns

Filtered registry

This is the main agent pattern.

Instead of giving every model every tool, openFunctions builds a smaller registry for the current task or persona. That keeps behavior tighter and makes agent design understandable.

Synthetic tool

Several advanced features are built by manufacturing tools at runtime:

  • crew delegation tools
  • structured-output extraction tools

This is an important design choice: provider-specific features are avoided when a tool-shaped abstraction can solve the same problem portably.

Tool-wrapped subsystem

Higher-level modules can expose themselves back into the runtime as tools:

  • createMemoryTools(...)
  • rag.createTools()
  • connectedProvider.createTools()

That means storage, retrieval, and context systems do not bypass the runtime. They re-enter it.

Context Providers

A context provider connects an external system to the agent runtime. The pattern follows the same composition model as memory and RAG:

ContextProvider → .connect() → ConnectedProvider → .createTools() → registry
                                                  → .buildContext() → system prompt

Provider interface

A context provider has two phases:

  1. Define — a factory function returns a ContextProvider with cheap metadata and a connect() method
  2. Connect — connect() initializes auth and resources, returns a ConnectedProvider with tools
const exf = await connectProvider(
  createExecuFunctionProvider({ token: "..." }),
  registry,
);

connectProvider() handles tagging: all tools are automatically tagged with "context" and "context:<providerId>" so agents can filter by provider or by the general context tag.

Context prompt injection

Providers can implement buildContext() to return a text block for system prompts:

const context = await contextPrompt([exf, obsidian]);
const systemPrompt = composePrompt({
  role: "Personal assistant",
  context,
  toolGuide: autoToolGuide(registry),
});

This is how agents get situational awareness (active tasks, upcoming events) without explicit tool calls. Context is best-effort — if a provider fails, the agent continues without that context.

Capability declaration

Providers declare capability domains in their metadata:

  • tasks, projects, calendar, knowledge, people, organizations, codebase, vault, datasets, documents

This lets the framework and setup UX know what a provider offers without loading its tools. The ExecuFunction reference provider covers 7 of these domains with 17 tools.

Health checks

checkProviderHealth() runs health checks across all connected providers, useful for setup UX and monitoring.

State Layers

Store

createStore() and createPgStore() provide persistence with the same interface. Tools can move from local JSON storage to Postgres with minimal code changes.

Memory

Memory is structured state for threads and facts. It is not the same as retrieval:

  • conversation memory stores thread history
  • fact memory stores extracted or saved facts

Memory becomes AI-usable through generated tools.

RAG

RAG is semantic retrieval over document chunks. It combines:

  • chunking
  • embeddings
  • pgvector storage
  • cosine similarity search
  • optional tool exposure

See RAG.md for the full ingestion and search flow.

Chat Agent

createChatAgent() is the highest-level composition primitive. It wires together an adapter, a registry, memory, and context providers into a single embeddable agent.

createChatAgent(config)
  → resolve adapter (provider + model)
  → build filtered registry (tools, tags, exclusions)
  → set up memory (conversation + facts → memory tools → registry)
  → connect context providers (tools + context prompt → registry + system prompt)
  → compose system prompt (preset/prompt/markdown + context + memory awareness)
  → return ChatAgent { chat(), interactive(), serve(), reset(), destroy() }

The factory is async because context providers require connect() calls.

Three interfaces, one agent

  • agent.chat(message) — programmatic, returns ChatResult { text, toolCalls, rounds, metadata }
  • agent.chat(message, { stream: true }) — returns AsyncIterable<ChatStreamChunk> with tool_call, tool_result, text, and done events
  • agent.interactive() — readline CLI loop
  • agent.serve({ port }) — HTTP server with /chat, /chat/stream (SSE), /health, /reset

Config resolution

The config drives everything declaratively:

  • provider resolves to an adapter factory (auto-detected from env vars if omitted)
  • preset / prompt resolves to a system prompt (preset name, inline string, markdown file path, or structured PromptOptions)
  • tools / toolTags / excludeTools build a filtered registry
  • memory (default: on) creates conversation + fact memory and registers memory tools
  • providers resolves string names (e.g., "execufunction") to ContextProvider instances via dynamic import

Relationship to defineAgent()

defineAgent() creates task-scoped agents for use inside crews and workflows. createChatAgent() creates session-scoped agents for direct user interaction. They use the same underlying primitives (registry, adapters, prompts) but serve different roles.

How To Think About openFunctions

Use this order:

  1. write a tool
  2. register it
  3. execute it through MCP or chat
  4. use createChatAgent() when you want a full agent with memory and context
  5. compose multiple tools with workflows when the control flow is known
  6. use defineAgent() inside crews when specialized agents need to collaborate
  7. add memory or RAG only when state or retrieval is required
  8. add context providers when you need external systems (task management, calendars, CRM, etc.)

If you keep the runtime in mind, the rest of the framework stays predictable.