@leuduan/lagent
v56.0.1
Published
Plugin-based agent execution engine built on @leuduan/cordis
Readme
@leuduan/lagent
A plugin-based agent execution engine built on Cordis. One application owns one append-only journal; conversations are threads in that journal. The host chooses providers, capabilities, credentials and user-interface policy.
The journal is the state. Reopening reconstructs conversation history and unfinished work from recorded facts. Plugins and process resources are mounted again by the application.
Quick start
This creates a chat thread using a host-owned Codex login. Run on Node 22 or newer for SQLite.
import { homedir } from 'node:os'
import { resolve } from 'node:path'
import { Context } from '@leuduan/cordis'
import { core, registerProvider } from '@leuduan/lagent'
import { base } from '@leuduan/lagent/compositions'
import { codexAuthPath, codexProvider } from '@leuduan/lagent/providers'
import { threadDirs } from '@leuduan/lagent/plugins/threadDirs'
import { truncateToolOutput } from '@leuduan/lagent/plugins/truncateToolOutput'
import { SqliteJournalStore } from '@leuduan/lagent/stores/sqlite'
const root = new Context()
const cwd = process.cwd()
const dataDir = resolve('.lagent')
try {
await root.plugin(core, {
store: new SqliteJournalStore(`${dataDir}/journal`, {
lockDirectory: `${dataDir}/locks`,
locking: { kind: 'posix', busyTimeoutMs: 1_000 },
}),
cwd,
retention: 'unload',
reactiveTurnLimit: 10,
})
// Where each thread keeps files of its own; a host with no file system mounts none.
await root.plugin(threadDirs, { dataDir })
registerProvider(
root,
codexProvider({
authPath: codexAuthPath(process.env, homedir()),
refreshTimeoutMs: 30_000,
streamIdleTimeoutMs: 300_000,
}),
{ retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 10_000 } },
)
await root.plugin(base)
await root.plugin(truncateToolOutput, { spill: true })
// Nothing runs before this: every plugin a thread needs is mounted.
await root.threads.start({ servePending: 'none', maxConcurrentRuns: 4 })
// Every thread is born on a complete route: which model runs is the application's choice. A creation
// also says where its configuration comes from (`birth.from`) and what an existing id must match (`ifExists`).
const thread = await root.threads.create('main', {
id: 'main',
ifExists: 'match-identity',
birth: { from: 'defaults', route: { provider: 'codex', model: 'gpt-6-luna', thinking_effort: 'medium' } },
})
root.threads.onChunk(({ chunk }) => process.stdout.write(chunk.text_delta))
// Resolves once this input is answered, or the run responsible for it needs someone.
const sent = await thread.send('Say hello.', { requestRun: {}, until: 'answered' })
if (sent.status === 'refused') throw new Error(sent.refusal.message)
console.log(sent.end)
} finally {
await root.fiber.dispose()
}base supplies plumbing and a main agent with no granted tools. For coding, the host registers
workspace, command and other capability plugins, then installs codePreset on a thread, or mounts
it on the root for an agent selector, to grant them to that thread's agent. Composition and required host configuration are explained in Plugin authoring.
Core model
| Concept | Responsibility | | ---------- | ------------------------------------------------------------------------- | | Root | Shared journal, core services and application-owned plugins. | | Thread | Durable conversation on an agent, with its environment, prompt and route. | | Activation | Reconstructible scope owning a thread's plugins, readiness and resources. | | Origin | The caller a delegated thread sits under, and its call or assignment. | | Fork | A conversation continuing another thread's history at a settled cut. |
Creating or reading a thread does not activate its plugins. Work activates it lazily and waits for readiness. One exclusive operation holds a thread at a time; conflicting work fails busy, and a run waits its turn. The engine runs a thread when a recorded request asks it to and nothing holds it.
Tools and grants are live registrations. Prompts are rendered once after readiness and recorded;
plugin guidance uses that same prompt. Rebuild explicitly (thread.configure({ rebuildPrompt: true }))
when a conversation should receive new instructions. Dynamic thread facts enter the conversation as sourced notices.
Tool results render as <tool:output success="true"> or <tool:output success="false">, with
optional notes in a following <tool:notes> section. Non-text payloads accompany the rendered text.
Reading and driving threads
await thread.send('Another message', { requestRun: {} })
const { state } = root.threads.inspect(thread.id)!
const transcript = await thread.read((view) => view.transcript())send queues durable input; with requestRun it also asks for a run, which the engine serves once
nothing holds the thread, and its outcome names that run (runId). Without it, input waits for the
next run and does not keep a running one going. With until: 'answered' (which needs the requestRun) the send waits for that input to end,
as the quick start does: end is answered with the first answer accepted after a step claimed it, within the run that claimed it,
discarded when it was withdrawn or cleared before that, or held or settled with the state of
the run responsible for it when that run will not answer without someone acting. It follows the
input itself, through a successor run when its cause landed as a run ended, and later input does not
extend the wait; sending the same id again waits again. waitForRun(root.threads, id, runId, until)
waits for one exact run, which is what a cleanup that owns that run needs.
The host records what a person did, presents confirmations, failures and background work from
threads.inspect and run events, and decides when to start. Threads and recovery
explains holds, cancellation, input ordering, forks, read lifetimes and recovery.
The host supplies environment-dependent configuration. Lagent does not choose a workspace,
credential file, skill root or subprocess environment from its own process, and refuses a relative
core cwd, thread-directories dataDir or thread cwd rather than resolve it against the process
directory. Core has no data directory: a host with a file system mounts threadDirs with one, and the
plugins that keep files there (commands, and truncateToolOutput with spill: true) stay pending until it
is. A host without one, such as a Durable Object, mounts none and gives truncation spill: false. One process holds one
journal; incompatible journal formats and extension payloads are refused rather than migrated.
The package's major version is the journal version (JOURNAL_VERSION in src/journal/store.ts): whenever it
increases, bump the major version in package.json to match, with minor and patch reset to zero.
Imports
Use the explicit package entry points. The root serves applications, including the
processes a host owns (spawnCommand, runToCompletion; a tool never uses them);
@leuduan/lagent/plugin serves plugin authors; compositions holds the shipped compositions (base, and codePreset with the policy and prompts it shares); llm and records expose their contracts, the provider
contract included; providers holds the shipped providers; stores/sqlite holds the SQLite store and openSqlite, which opens a host's own databases under the same
required locking choice (posix, or none for a file system whose POSIX locks fail), the one
door that needs node:sqlite; stores/durableObject holds the store over a Cloudflare Durable Object's SQLite storage; plugins/sandbox holds the optional OS sandbox, the confinement handler of ctx.confinement for commands and, through one policy, for file access, whose
worker thread is the one module that loads @anthropic-ai/sandbox-runtime; plugins/<name> exposes a feature (plugins/claudeCode loads @anthropic-ai/claude-agent-sdk lazily, in its runtime module only; plugins/mcp imports @modelcontextprotocol/sdk statically, so a host that imports the door installs that optional peer); testing holds test helpers. Each
door lists its names; implementation subpaths are private.
In Cloudflare Workers with nodejs_compat, every door imports except plugins/sandbox, which also needs
new_module_registry. Features that need a filesystem outside /tmp, processes or node:sqlite fail
when used. A Workers composition is core, base and truncateToolOutput with spill: false, plus tools that work there:
no threadDirs, commands or sandbox. The Workers tests run one in workerd with pnpm -F @leuduan/lagent run test:workers.
MCP clients identify themselves with the version statically imported from the package metadata,
so bundled hosts carry the same version as source and dist/ consumers.
Guides
Start with the documentation map. It distinguishes usage contracts from the design records explaining decisions. Architecture gives the ownership model. The lagentic host is an application example, documented in its guide.
Scripts
Run from the repository root:
pnpm -F @leuduan/lagent run typecheck
pnpm -F @leuduan/lagent run test
pnpm -F @leuduan/lagent run test:related <files> # tests that reach the files, plus the architecture test
pnpm -F @leuduan/lagent run buildtest:related is what the pre-commit hook runs for changed files. It follows import edges only, so
it always lists src/architecture.test.ts, which reads the source tree rather than importing it, and
forceRerunTriggers in vitest.config.ts names the inputs tests reach without an import (the sandbox
worker). The pre-push gate runs the whole suite.
