Skip to content

Latest commit

 

History

History
118 lines (86 loc) · 4.2 KB

File metadata and controls

118 lines (86 loc) · 4.2 KB

Architecture

Pulse has a tiny core and a large ecosystem of plugins. This document defines the core concepts, the package boundaries, and the rules that keep them small.

Six concepts

The entire runtime is built from six primitives. If a seventh wants in, it must delete one first.

Runtime            the entry point; owns a run
  └─ Context       what a run receives: signal, emit, tools, state
  └─ Scheduler     decides when/how work executes (order, concurrency)
  └─ EventBus      every step emits start/end events through here
  └─ Registry      where plugins, tools, and models are looked up
  └─ Executor      runs one Runnable and returns a Result

Everything else — Agent, Workflow, Memory, RAG, Planner — is a Runnable produced by a plugin and executed by the Executor. None of them are core concepts.

The one interface everything shares

interface Runnable<In = unknown, Out = unknown> {
  run(ctx: Context, input: In): Promise<Out>;
}

agent(), workflow(), sequence(), tool() — all return a Runnable. This is why they compose without glue code.

Context

A run receives exactly one object. It grows only when a capability truly needs it.

interface Context {
  signal: AbortSignal;          // cancellation
  emit: (event: Event) => void; // events
  tools: ToolRegistry;          // available tools
  state: StateStore;            // scoped key/value for this run
  log: Logger;
}

Cancellation, retries, timeouts, and streaming are all expressed through this one object — not through new top-level APIs.

Package boundaries

core   ──────────────► (nothing)
runtime ─────────────► core
events ──────────────► core
tool ────────────────► core
model ───────────────► core            (interfaces only, no providers)
memory ──────────────► core            (interfaces only, no stores)
workflow ────────────► core, runtime
agent ───────────────► core, model, tool, memory   (never a provider)
rag ─────────────────► core, model, memory
plugin ──────────────► core

@pulse/openai ───────► model            (a plugin, ships separately)
@pulse/anthropic ────► model
@pulse/redis ────────► memory
@pulse/postgres ─────► memory

Rules:

  • core never imports another Pulse package.
  • agent imports the model interface, never @pulse/openai. The provider is injected at the call site. This is the dependency inversion that lets any provider drop in.
  • Providers and stores are always separate packages loaded with runtime.use(...). Nothing is built in.

Dependency inversion in one picture

agent  ──uses──►  ChatModel (interface, in @pulse/model)
                        ▲
                        │ implements
                 @pulse/openai
                 @pulse/anthropic

The agent does not know OpenAI exists. It knows ChatModel.

Plugins

A plugin is a function that registers capabilities on a runtime.

interface Plugin {
  name: string;
  setup(registry: Registry): void;
}

runtime.use(openai());   // registers a ChatModel
runtime.use(redis());    // registers a MemoryStore

Providers, stores, tracers, and integrations are all plugins. The core ships none of them.

Interfaces we define but do not implement

Memory is interfaces first, implementations later (as plugins).

interface MemoryStore   { get(k): Promise<V>; set(k, v): Promise<void> }
interface VectorStore   { query(embedding, k): Promise<Match[]> }
interface CheckpointStore { save(id, state): Promise<void>; load(id): Promise<State> }

@pulse/redis, @pulse/sqlite, @pulse/postgres implement these. Core stays clean.

Why this holds up

  • New provider? New package. Core untouched.
  • New execution shape (e.g. race)? New Runnable in workflow. Core untouched.
  • New observability tool? Subscribe to the EventBus. No API change.

Core almost never changes. That's the design goal.