loopengine 0.0.10 → 0.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,40 +2,35 @@
2
2
 
3
3
  [![CI](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml/badge.svg)](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml)
4
4
 
5
- LoopEngine is a runtime for defining and running multiple AI agents through a
6
- transparent ReAct loop. Write an agent as a plain config object — persona,
7
- tools, permission rules — and run it over the CLI, an HTTP API, or both. No
8
- chain DSL, no hidden control flow: `run-agent.ts` is a single readable
9
- function you can step through top to bottom.
5
+ LoopEngine is a runtime for building AI agents: a persona, a set of tools,
6
+ and permission rules, run through a transparent ReAct loop. No chain DSL,
7
+ no hidden control flow — `run-agent.ts` is a single function you can read
8
+ top to bottom. Run agents over a CLI, an HTTP API, or both.
9
+
10
+ Once you define an agent, the loop turns it into something you can put in
11
+ front of real users or real systems:
12
+
13
+ - **Callable over CLI or HTTP** — a script you run locally, or a real
14
+ service other apps can hit.
15
+ - **Does real work, safely** — tools can hit a database, send emails, call
16
+ GitHub/Slack, anything with an `execute` function. Permission rules gate
17
+ what happens without a human: safe reads auto-run, risky actions (a
18
+ refund, a send) get approval or are denied outright, so you can wire up
19
+ powerful tools without trusting the model blindly.
20
+ - **Remembers** — conversations persist across requests, so a caller can
21
+ send one message, get a reply, and continue later.
22
+ - **Multi-tenant out of the box** — the same deployed agent can serve
23
+ different customers/orgs with different permission levels, with no
24
+ forking required.
25
+ - **Composable into bigger agents** — drop one agent's folder under
26
+ another's `subagents/` and it becomes a tool the parent can delegate
27
+ to, own tools/rules/loop intact. No chain DSL for orchestration either:
28
+ the parent's model just decides when to call it, like any other tool.
29
+
30
+ So the payoff: you write one `AgentConfig`, and get a deployable,
31
+ persistent, permission-safe service — not just a prompt-and-response demo.
10
32
 
11
- It's built on six small, independently-published packages that each solve
12
- one piece of the agent-loop problem:
13
-
14
- | Package | Responsibility |
15
- |---|---|
16
- | [`actauth`](https://www.npmjs.com/package/actauth) | Rule-based permission gating (allow/ask/deny) with human-approval hooks |
17
- | [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking with staged recovery |
18
- | [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
19
- | [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable, DAG-shaped session log with crash-interruption detection |
20
- | [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md`-based skill discovery and lazy loading |
21
- | [`toollane`](https://www.npmjs.com/package/toollane) | Schedules approved tool calls into parallel/solo execution lanes |
22
-
23
- `run-agent.ts` wires the first five together — `sessionknit` sits one layer
24
- out, in `session-store.ts`, since it persists what `run-agent.ts` produces
25
- rather than participating in the loop itself. Everything else in this repo
26
- — agent definitions, channel adapters — is built on top of those.
27
-
28
- A seventh package, [`mcpplug`](https://www.npmjs.com/package/mcpplug), sits
29
- one layer further out still: it's not part of the loop either, just a
30
- `ToolSource` an agent's own config can draw from — `loadTools()` returns
31
- `ToolDefinition[]`, the exact shape `AgentConfig.tools` already expects, so
32
- its output merges straight in with no adapter code. See "External tool
33
- gateways" below.
34
-
35
- ## Creating a new project
36
-
37
- The fastest way to get a running agent server, in your own repo, is
38
- [`create-loopengine`](https://www.npmjs.com/package/create-loopengine):
33
+ ## Quick start
39
34
 
40
35
  ```bash
41
36
  npx create-loopengine@latest my-agents
@@ -45,466 +40,354 @@ cp .env.example .env # fill in ANTHROPIC_API_KEY
45
40
  npm run dev # HTTP server on :8787
46
41
  ```
47
42
 
48
- That scaffolds a real, standalone project — its own `package.json`
49
- depending on `loopengine` (not a clone of this repo), an HTTP + CLI
50
- adapter, and one starter agent — rather than a bare library import. `npm
51
- install loopengine` alone (below) only gets you the library; the server,
52
- adapters, and a runnable example are deliberately not part of that
53
- package, which is the gap `create-loopengine` fills. See
54
- [`create-loopengine`'s own README](https://github.com/evanyan22/create-loopengine)
55
- for what the generated project looks like.
43
+ This scaffolds a standalone project — its own repo, with `loopengine` as
44
+ a dependency, a starter agent, and CLI + HTTP adapters already wired up.
45
+ That's the intended way to use LoopEngine: an independent project you own
46
+ and build agents in, not a library you import into an existing app.
56
47
 
57
- ## Using loopengine as a library
58
-
59
- This repo is two things in one: a small library (the loop itself) and a
60
- reference app built on top of it (demo agents, a CLI, an HTTP server). Only
61
- the library half ships to npm:
48
+ ### Define your first agent
62
49
 
63
50
  ```bash
64
- npm install loopengine
51
+ npx loopengine add-agent weather-agent
52
+ # -> Created agents/weather-agent/index.ts
65
53
  ```
66
54
 
67
- `actauth`, `contextclip`, `reflowkit`, `toollane`, `skillgarden`, and the
68
- rest are regular dependencies of `loopengine` itself — installing it alone
69
- already pulls them into `node_modules`, fully resolvable for both imports
70
- and TypeScript types. Install one of them directly only if you want to use
71
- it standalone, outside `runAgent` (e.g. just `toollane`'s parallel/solo
72
- scheduling in your own loop).
73
-
74
- ```ts
75
- import { runAgent, type AgentConfig, type ToolDefinition } from 'loopengine'
55
+ That's the fastest way to start: it generates `agents/<name>/index.ts`
56
+ with a working `AgentConfig` stub, so you don't have to memorize the
57
+ folder shape by hand. Or write it yourself — an agent is just an
58
+ `AgentConfig`: `name`, `systemPrompt`, `tools`, `rules`, and a `model`.
59
+ Drop it in a folder under `agents/` — `agents/<name>/index.ts` — and it's
60
+ picked up automatically, no registry to edit, no import to add.
76
61
 
77
- const getWeather: ToolDefinition = {
78
- name: 'get_weather',
79
- description: 'Look up the weather for a city',
80
- input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
81
- execute: async (input) => `Sunny in ${input.city}`,
82
- }
62
+ Most fields don't even need to be written out — they default to a
63
+ conventional path under the agent's own folder:
83
64
 
84
- const config: AgentConfig = {
85
- name: 'weather-agent',
86
- systemPrompt: 'You answer questions about the weather.',
87
- tools: [getWeather],
88
- rules: [{ scopePattern: 'default/production/weather-agent', tool: 'get_weather', decision: 'allow' }],
89
- defaultDecision: 'ask',
90
- }
65
+ | Field | Defaults to |
66
+ | --- | --- |
67
+ | `rules` | `agents/<name>/actauth.yml` (missing → deny everything) |
68
+ | `tools` | `agents/<name>/tools/index.ts`'s exported `tools` (missing → no tools) |
69
+ | `skillsDirs` | `agents/<name>/skills` (missing → no skills) |
91
70
 
92
- // bring your own ModelCall, or use the one below
93
- const result = await runAgent(config, myModelCall, "What's the weather in Boston?", [])
94
- console.log(result.text)
95
- ```
71
+ There's no `subagents` field to omit — `agents/<name>/subagents/*` is its
72
+ own folder convention, always merged into `tools` on top of whatever the
73
+ table above resolves to. See "Subagents" below.
96
74
 
97
- The full exported surface: `runAgent`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
98
- `FileSessionStore`/`RedisSessionStore`/`createSessionStore`, `VectorIndex`/
99
- `embed`/`cosineSimilarity` (for RAG — see below), `discoverAgents` (scans a
100
- directory for `config`/`createModelCall` modules and builds a name → entry
101
- map, keyed by `AgentConfig.name` — see `agent-registry.ts` for the ~15-line
102
- wrapper this repo's own adapters use), and `createAnthropicModelCall`/
103
- `createOpenAIModelCall`/`createDeepSeekModelCall` (real, ready-to-use
104
- `ModelCall`s). See `index.ts` for the exact list.
105
-
106
- **Not** part of the package: `agents/*.ts` (this repo's own demo agents),
107
- `agent-registry.ts` (this repo's own name → config lookup table, built on
108
- `discoverAgents` — build your own the same way), `adapters/cli.ts`/
109
- `adapters/http.ts` (one specific CLI/HTTP wiring choice, not imposed on
110
- every consumer), and the `Dockerfile`. Everything below this point
111
- describes that reference app — read it as a worked example of what to
112
- build with the library, not as library API itself.
75
+ `rules` is how you gate what a tool can do without approval — see
76
+ "Tool permission and multi-tenancy" below.
113
77
 
114
- ## Quick start
115
-
116
- ```bash
117
- npm install
118
- npx tsx agents/file-agent.ts # run the file-summarizer demo agent
119
- npx tsx agents/customer-service-agent.ts # run the customer-service demo agent
120
- npx tsx agents/rag-agent.ts # run the retrieval-augmented demo agent
121
- ```
122
-
123
- All demo agents use a **simulated** model call (no `ANTHROPIC_API_KEY` is
124
- wired up) — see "Wiring a real model" below.
125
-
126
- ## Defining your own agent
127
-
128
- An agent is an `AgentConfig` (`agent-config.ts`): a name, a system prompt, a
129
- list of tools, and ActAuth permission rules.
78
+ The simplest possible agent, `agents/weather-agent/index.ts` — `tools`,
79
+ `rules`, and `skillsDirs` all omitted, so they default to
80
+ `agents/weather-agent/tools/`, `actauth.yml`, and `skills/`:
130
81
 
131
82
  ```ts
132
- import type { AgentConfig } from './agent-config.js'
83
+ import type { AgentConfig } from '../../agent-config.js'
133
84
 
134
85
  export const config: AgentConfig = {
135
- name: 'my-agent',
136
- systemPrompt: 'You are ...',
137
- tools: [
138
- {
139
- name: 'do_thing',
140
- description: 'What the model sees when deciding to call this',
141
- input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
142
- execute: async (input) => {
143
- // real implementation — DB call, HTTP request, whatever
144
- return { ok: true }
145
- },
146
- },
147
- ],
148
- rules: [
149
- { scopePattern: 'default/production/my-agent', tool: 'do_thing', decision: 'allow' },
150
- ],
151
- defaultDecision: 'ask', // anything not covered by a rule requires approval
86
+ name: 'weather-agent',
87
+ systemPrompt: 'You answer questions about the weather.',
88
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
152
89
  }
153
90
  ```
154
91
 
155
- `agents/file-agent.ts` and `agents/customer-service-agent.ts` are two complete, working
156
- examples — same `runAgent` loop, entirely different personas and tools.
157
-
158
- A file exporting `config` and `createModelCall` and saved into `agents/` is
159
- runnable through both adapters immediately — `agent-registry.ts` finds it
160
- automatically (by `AgentConfig.name`, not the filename) at startup.
161
- Nothing to edit, no import to add, no adapter change.
92
+ See `agents/customer-service/` and `agents/file-agent/` for complete,
93
+ working examples.
94
+
95
+ ## Core concepts
96
+
97
+ ### 1. The Loop
98
+
99
+ `run-agent.ts` is the whole engine — a single, readable ReAct loop with no
100
+ hidden control flow: call the model, act on what it asks for, repeat.
101
+
102
+ 1. Call the model with the conversation so far.
103
+ 2. If it responds with tool calls, each one is checked against
104
+ `AgentConfig.rules` — allowed calls run, denied calls are refused,
105
+ "ask" calls wait on `AgentConfig.approver` (see "Tool permission and
106
+ multi-tenancy" below).
107
+ 3. Approved calls execute and their results feed back into the
108
+ conversation.
109
+ 4. Repeat from step 1 — until the model stops requesting tools
110
+ (`end_turn`) or `AgentConfig.maxTurns` (default 25) is hit, which ends
111
+ the turn with `RunAgentResult.stopReason: 'max_turns'`, a real result
112
+ rather than a thrown error.
113
+
114
+ `runAgent` itself does no I/O — callers own conversation history and the
115
+ model call — which is what lets the same loop serve a one-shot CLI call,
116
+ a long-lived HTTP chat session, and a fully simulated test, unchanged.
117
+
118
+ ### 2. Agents — convention over configuration
119
+
120
+ Beyond the `AgentConfig` shape and folder convention shown in "Define
121
+ your first agent" above, the discovery mechanism is what makes this
122
+ convention-over-configuration: `discoverAgents` scans `agents/` at
123
+ startup, keyed by each module's own `AgentConfig.name` — not the
124
+ filename or folder name — and builds the name → config lookup every
125
+ adapter uses. Nothing to register by hand; a module that exports neither
126
+ `config` nor a way to build a model call fails loudly at startup instead
127
+ of silently not showing up.
128
+
129
+ ### 3. Skills and tools: agent-specific vs. global
130
+
131
+ Both skills and tools follow the same rule: **default to agent-specific,
132
+ promote to global only once something is genuinely shared.**
133
+
134
+ - **Agent-specific** (the default) — lives under that agent's own folder,
135
+ seen only by that agent:
136
+ ```
137
+ agents/<name>/skills/<skill>/SKILL.md
138
+ agents/<name>/tools/<tool>.ts
139
+ ```
140
+ - **Global** — lives at the repo root, seen by every agent that opts in:
141
+ ```
142
+ skills/<skill>/SKILL.md # pointed at by any agent whose skillsDirs includes it
143
+ tools/<tool>.ts # imported explicitly into any agent's tools array
144
+ ```
145
+
146
+ Skills are discovered via `AgentConfig.skillsDirs` (an array of
147
+ directories, `SKILL.md`-based: a short description stays in context, the
148
+ full body loads only when the model invokes it). Tools have no
149
+ auto-discovery — an agent's `tools` array just imports whichever tool
150
+ files it wants, agent-specific or global.
151
+
152
+ Both root directories (`tools/`, `skills/`) are empty today — every skill
153
+ and tool in this repo is agent-specific. Move something there only when a
154
+ second agent actually needs the exact same one, not because it feels
155
+ reusable in theory.
156
+
157
+ **Opting an agent into a global skill or tool** is explicit either way —
158
+ nothing scans the root for you:
162
159
 
163
- ## Retrieval (RAG)
160
+ ```ts
161
+ // Global skill: add the root path alongside the agent's own
162
+ skillsDirs: ['agents/my-agent/skills', 'skills']
164
163
 
165
- RAG is just another tool — the model decides when to call it, `run-agent.ts`
166
- doesn't know or care that "executing" this particular tool means a vector
167
- search instead of a plain HTTP call:
164
+ // Global tool: tools has no auto-discovery, so import it and merge it
165
+ // into the agent's own array by hand
166
+ import { sharedTool } from '../../tools/shared-tool.js'
167
+ import { tools as ownTools } from './tools/index.js'
168
168
 
169
- ```ts
170
- const searchDocs: ToolDefinition = {
171
- name: 'search_docs',
172
- description: 'Search the knowledge base for relevant passages',
173
- input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
174
- execute: async (input) => {
175
- const hits = index.search(input.query as string, 3) // top-3 chunks
176
- return hits.map((h) => h.text).join('\n\n')
177
- },
178
- }
169
+ tools: [...ownTools, sharedTool]
179
170
  ```
180
171
 
181
- `agents/rag-agent.ts` is a complete example, backed by `vector-index.ts` — a
182
- small, dependency-free in-memory vector index (feature-hashing embeddings,
183
- cosine-similarity search) good enough to prove the retrieval mechanics
184
- end-to-end without wiring up a real embedding API. Swap `embed()` for a real
185
- embedding model, or swap `VectorIndex` for a real vector DB client, and the
186
- `search_docs` tool works unchanged — same "host provides the real
187
- implementation, the package proves the pipeline" pattern as ContextClip's
188
- `Summarizer` or ActAuth's `Approver`. The retrieved text comes back as an
189
- ordinary tool result, subject to the same ActAuth gating, ToolLane
190
- scheduling, and ContextClip budget tracking as any other tool call.
191
-
192
- ## External tool gateways
193
-
194
- A hand-written tool needs an `execute` function you write yourself; an
195
- external SaaS action (list GitHub repos, send a Slack message) needs
196
- someone to own that vendor's auth. Connecting straight to a vendor's own
197
- MCP server means owning its OAuth flow (authorization code, PKCE, token
198
- storage/refresh) yourself, repeated per vendor. Routing through a gateway
199
- like [Composio](https://composio.dev) avoids that — it already holds the
200
- OAuth relationship with 1000+ apps — and [`mcpplug`](https://www.npmjs.com/package/mcpplug)
201
- wraps that gateway behind the same `ToolDefinition` shape as everything
202
- else:
172
+ Tools don't have to be hand-written either. For SaaS actions (GitHub,
173
+ Slack, etc.) where you'd rather not own the vendor's OAuth flow yourself,
174
+ [`mcpplug`](https://www.npmjs.com/package/mcpplug) connects through
175
+ [Composio](https://composio.dev), which already holds the OAuth
176
+ relationship with 1000+ apps, and returns tools in the same
177
+ `ToolDefinition` shape — so they drop into `tools` right alongside your
178
+ own:
203
179
 
204
180
  ```ts
205
181
  import { connectComposioSource } from 'mcpplug'
206
182
 
207
- // Resolved once, at module-eval time — not per runAgent() call. A gateway
208
- // connection is exactly the kind of setup cost runAgent() shouldn't pay on
209
- // every turn; see agents/file-agent.ts for the working example this is
210
- // drawn from.
211
183
  const composioTools = await connectComposioSource('composio', {
212
184
  slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
213
185
  }).then((source) => source.loadTools())
214
186
 
215
187
  const config: AgentConfig = {
216
188
  name: 'my-agent',
217
- systemPrompt: 'You are ...',
218
189
  tools: [...handWrittenTools, ...composioTools],
219
- rules: [
220
- // mcpplug namespaces each tool `${sourceName}_${slug}` — falls
221
- // through to defaultDecision unless a rule names it explicitly.
222
- { scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' },
223
- ],
190
+ rules: [{ scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' }],
224
191
  defaultDecision: 'ask',
225
192
  }
226
193
  ```
227
194
 
228
- Same ActAuth gating, ToolLane scheduling, and ContextClip budget tracking
229
- as any other tool call — `runAgent` has no idea one `execute` shells out
230
- through Composio instead of touching the filesystem or a vector index.
231
- `agents/file-agent.ts` is a complete, working example, verified end-to-end
232
- against a real linked GitHub account. `slugs` is explicit, not
233
- auto-discovered — an agent should only see the Composio actions its own
234
- config actually lists; find them with the `composio` CLI's
235
- `composio tools list <toolkit>` or `composio search "<use case>"`.
195
+ Either kind of tool goes through the same permission gating, parallel
196
+ scheduling, and context-budget tracking. See `agents/file-agent/index.ts`
197
+ for a working example.
236
198
 
237
- ## Running an agent
199
+ ### 4. Subagents — an agent as another agent's tool
238
200
 
239
- **CLI:**
201
+ Drop a folder under `agents/<name>/subagents/<child>/` and `child` becomes
202
+ one of `name`'s tools automatically — no import, no `AgentConfig.tools`
203
+ edit, nothing to register by hand:
240
204
 
241
- ```bash
242
- npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
205
+ ```
206
+ agents/support-orchestrator/
207
+ index.ts # the orchestrator's own AgentConfig
208
+ subagents/
209
+ billing-agent/index.ts # a full AgentConfig, same shape as a top-level agent
210
+ refunds-agent/index.ts
243
211
  ```
244
212
 
245
- **HTTP:**
213
+ Scaffold one directly:
246
214
 
247
215
  ```bash
248
- npx tsx adapters/http.ts
249
- curl -X POST localhost:8787/agents/customer-service/messages \
250
- -H 'content-type: application/json' \
251
- -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
216
+ npx loopengine add-subagent support-orchestrator billing-agent
217
+ # -> Created agents/support-orchestrator/subagents/billing-agent/index.ts
252
218
  ```
253
219
 
254
- `customerEmail` is specific to `customer-service` — it defines its own
255
- `AgentConfig.sessionIdFor` (see "Sessions and multi-tenancy" below). Other
256
- agents take a plain `sessionId` field instead — and it's optional: omit it
257
- for a fresh, one-off session (a generated id, echoed back in the response
258
- as `sessionId` so you can pass it in explicitly to continue that same
259
- conversation on a later request):
220
+ A subagent is a normal `AgentConfig` plus one required field —
221
+ `toolDescription`, the text the *parent's* model reads to decide when to
222
+ delegate to it (its own `systemPrompt` is instructions for itself, not a
223
+ pitch to a caller deciding whether to invoke it):
260
224
 
261
- ```bash
262
- curl -X POST localhost:8787/agents/file-agent/messages \
263
- -H 'content-type: application/json' \
264
- -d '{"message":"Summarize a.txt and b.txt into summary.txt."}'
225
+ ```ts
226
+ export const config: AgentConfig = {
227
+ name: 'billing-agent',
228
+ systemPrompt: 'You answer billing questions.',
229
+ toolDescription: 'Call this for any billing-related question.',
230
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' },
231
+ }
265
232
  ```
266
233
 
267
- `customer-service` is the exception here too — a request with no
268
- `customerEmail` is a genuine validation error (`400`), not "start me an
269
- anonymous session," since `customerEmail` is a real, meaningful identity
270
- for that agent, not an arbitrary key.
234
+ Calling that tool runs the subagent's whole ReAct loop — its own tools,
235
+ rules, permissions — to completion, and only its final text comes back;
236
+ the parent never sees the subagent's turns or tool calls. Orchestration
237
+ needs no separate concept on top of this: an "orchestrator" is just an
238
+ agent whose tools happen to be other agents, and the model decides which
239
+ to call, in what order, the same as any other tool — no chain DSL.
271
240
 
272
- **HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
273
- loop step instead of a single response after the whole thing finishes —
274
- a `session` event first (announcing whatever `sessionId` got used, generated
275
- or not), then `contextclip:check`, `actauth:decision`, `toollane:result`,
276
- ..., a final `done` with the answer:
241
+ Subagents nest — a subagent's own `subagents/` folder works the same way,
242
+ one level down, addressed by joining names with `/`:
277
243
 
278
244
  ```bash
279
- curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
280
- -H 'content-type: application/json' \
281
- -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
245
+ npx loopengine add-subagent support-orchestrator/billing-agent disputes-agent
282
246
  ```
283
247
 
284
- Both adapters are thin: they own request parsing and session persistence,
285
- and call the same `runAgent(config, modelCall, message, history)` underneath.
286
- `runAgent` itself does no I/O — callers own conversation history, which is
287
- what lets it serve a one-shot CLI call and a long-lived chat session with no
288
- code difference. The streaming route isn't a separate code path through
289
- `runAgent` either — it forwards the same `onEvent` hook the non-streaming
290
- route just discards, over SSE instead of into a log. What this streams is
291
- loop *steps* (tool calls, permission decisions, budget checks), not model
292
- output tokens — no `ModelCall` implementation here (fake or
293
- `createAnthropicModelCall`) streams partial text from the model itself, so
294
- there's nothing at the token level to forward yet.
295
-
296
- ## Sessions and multi-tenancy
297
-
298
- `session-store.ts` exposes one method, `withSession(sessionId, fn)`, that
299
- resumes history, runs `fn` **exclusively** for that session, and appends
300
- whatever new messages `fn` produced — this is what stops two concurrent
301
- requests for the same conversation from racing on read-modify-write.
302
-
303
- History itself is a [`sessionknit`](https://www.npmjs.com/package/sessionknit)
304
- log, not a flat file rewritten whole on every turn: each message is one
305
- entry in an append-only, parent-linked chain, so a turn only ever appends,
306
- never rewrites what's already durable. That buys two things a flat
307
- rewrite can't:
308
-
309
- - **Non-blocking, debounced writes** instead of a full read-modify-write of
310
- the entire session every turn.
311
- - **Interruption detection.** `run-agent.ts` pushes the assistant's full
312
- response — tool_use blocks included — as one message before any tool
313
- runs, then one message bundling every tool_result once they've all
314
- settled. If the process dies in that window, the next `resume()` sees an
315
- assistant message with an unanswered tool_use block, flags the session
316
- as resumed-after-interruption, and injects a note into context saying so
317
- — instead of silently resending an incomplete turn as if it were clean.
318
-
319
- Two implementations, same `SessionStore` interface:
320
-
321
- - `FileSessionStore` — one JSONL entry log per session under `.sessions/`,
322
- locked in-process. Fine for local dev and the CLI.
323
- - `RedisSessionStore` — same log shape (a Redis list of entries, one
324
- `RPUSH` per append), with a real distributed lock, safe across multiple
325
- server instances. The lock renews itself (`lockTtlMs / 3` by default)
326
- for as long as a turn runs, instead of a flat TTL that a long turn (a
327
- real model call plus several tool round-trips) could outlast — if
328
- renewal ever confirms the lock was lost anyway (not just a failed
329
- renewal attempt — a network blip there isn't proof of loss), the turn's
330
- result is rejected rather than silently returned as if mutual exclusion
331
- held the whole time.
332
-
333
- `createSessionStore()` picks between them based on `REDIS_URL` — set it and
334
- you get Redis, otherwise it falls back to the file store. Either way,
335
- turn-level exclusivity (no two concurrent turns for the same session
336
- interleaving) is still this module's job, not SessionKnit's — SessionKnit's
337
- own topology repair (reattaching sibling branches under a shared
338
- `parentId`) is a defensive read-side repair for crash/corruption recovery,
339
- not something normal operation exercises. `run-agent.ts` bundles a whole
340
- turn's `tool_use`/`tool_result` blocks into one message each, and
341
- `withSession` appends new messages sequentially, so even a turn with
342
- several parallel tool calls only ever produces a linear chain in the
343
- durable log — not races between two full concurrent turns, which is what
344
- the lock above actually prevents.
345
-
346
- Session-key derivation is `AgentConfig.sessionIdFor(body)` — deliberately
347
- not the HTTP adapter's call, since what counts as "one conversation" is
348
- business logic specific to what the agent is for, not a transport concern.
349
- `customer-service-agent.ts` defines its own, hashing `customerEmail` so raw
350
- addresses never end up in storage keys — different customers can never
351
- read or write each other's history, and concurrent messages from the same
352
- customer are serialized rather than dropped. A missing `customerEmail` is a
353
- real validation failure for that agent (`400`), not something to paper over.
354
-
355
- An agent that doesn't define `sessionIdFor` falls back to a plain
356
- client-supplied `sessionId` field (`adapters/http.ts`'s
357
- `defaultSessionIdFor`) — the same shape `adapters/cli.ts`'s `--session`
358
- flag already uses — and if that's missing too, one gets generated rather
359
- than the request being rejected, mirroring what omitting `--session` now
360
- does for the CLI: a fresh, isolated, one-off session rather than an error
361
- or (worse) a shared bucket every untagged caller collides into. The
362
- generated id comes back in the response (`sessionId` in the JSON body, a
363
- `session` SSE event first for the streamed route) so a caller that wants to
364
- continue that exact conversation can pass it in explicitly next time.
365
-
366
- `SessionStore` itself is agent-agnostic — nothing in `session-store.ts` or
367
- `sessionknit` knows which agent is calling `withSession`, so both adapters
368
- namespace the *actual* key by agent name before it ever reaches
369
- `SessionStore`: `` `${agentName}:${sessionIdFor(...)}` `` in
370
- `adapters/http.ts`, `` `${agent}:${session}` `` in `adapters/cli.ts`.
371
- Without that, two different agents given the same session identifier (a
372
- client reusing one `sessionId`/`--session` value across agents, most
373
- likely) would read and write the exact same underlying log — one agent's
374
- tool calls and results spliced straight into another's conversation as if
375
- it had always been there.
376
-
377
- ## Deployment
378
-
379
- ```bash
380
- docker compose up --build
248
+ A few things worth knowing before reaching for this:
249
+
250
+ - **The call blocks.** Every tool call in the loop is awaited before the
251
+ next model turn, so a slow subagent blocks its parent's turn — and if
252
+ the parent is itself a subagent of another agent, that blocks too.
253
+ There's no background/async or streaming pattern here.
254
+ - **Permissions don't inherit.** Each agent's own `actauth.yml` governs
255
+ only its own tools; delegating to a subagent neither grants nor
256
+ inherits permissions in either direction.
257
+ - **`maxTurns` compounds.** A request that reaches a grandchild subagent
258
+ spends turns at every level (default 25 each) — a "simple" answer can
259
+ cost far more than any one agent's own turn budget suggests.
260
+
261
+ ### 5. Tool permission and multi-tenancy
262
+
263
+ Every tool call is gated by [`actauth`](https://www.npmjs.com/package/actauth):
264
+ each rule in `AgentConfig.rules` maps a `scope` (tenant/environment) + tool
265
+ name to `allow` / `ask` / `deny`. Anything not covered falls through to
266
+ `defaultDecision`. An `ask` decision routes to `AgentConfig.approver`
267
+ (defaults to blocking on stdin — swap in a real one, e.g. Slack-backed,
268
+ for production).
269
+
270
+ A real agent's rules live in `agents/<name>/actauth.yml`:
271
+
272
+ ```yaml
273
+ default_decision: deny
274
+
275
+ rules:
276
+ - name: weather-lookup-always-allowed
277
+ scope: "*/*"
278
+ tool: get_weather
279
+ decision: allow
381
280
  ```
382
281
 
383
- builds the HTTP adapter into a container (`Dockerfile`, multi-stage:
384
- `npm ci` + `tsc`, then a slim runtime image) and starts it alongside a Redis
385
- container (`docker-compose.yml`). For a real deployment, push the same
386
- image to any container platform (Cloud Run, Fargate, Fly.io) with `REDIS_URL`
387
- and any tool-specific secrets set as environment variables.
282
+ `scope` is `tenant/environment` — `"*/*"` here means "every tenant, every
283
+ environment." Rules are matched most-specific-scope-first, so an agent
284
+ that needs different behavior per tenant just adds a more specific rule
285
+ (e.g. `acme-corp/production`) alongside the wildcard fallback.
286
+
287
+ That `scope` is what makes rules multi-tenant: the same agent can behave
288
+ differently for different customers. `environment` is a deployment-wide
289
+ setting (`LOOPENGINE_ENV`, default `production`). `tenant` is resolved per
290
+ request via `AgentConfig.tenantFor?: (headers, body) => string |
291
+ undefined` — from headers only, never the request body, since it feeds
292
+ permission decisions directly. No `tenantFor` means every request is the
293
+ `'default'` tenant.
294
+
295
+ ### 6. Sessions
296
+
297
+ A session is one ongoing conversation. Message history persists between
298
+ requests automatically — send a message, get a reply, come back later with
299
+ the same `sessionId` and continue where you left off. Two storage backends,
300
+ picked automatically: a local file store, or Redis if `REDIS_URL` is set
301
+ (needed for multiple server instances).
302
+
303
+ An agent can define `AgentConfig.sessionIdFor(body)` to control what
304
+ counts as "one conversation" for its own domain (e.g. `customer-service`
305
+ hashes `customerEmail`, so a request with no email is rejected rather than
306
+ starting an anonymous session). Without it, the caller just supplies a
307
+ `sessionId`, or gets one generated for them.
388
308
 
389
- ## Wiring a real model
309
+ ## Running an agent
390
310
 
391
- Every example agent (`agents/file-agent.ts`, `agents/customer-service-agent.ts`,
392
- `agents/rag-agent.ts`) still uses a canned, turn-counting `ModelCall` so the
393
- whole loop is runnable and testable with no API key. To go live, swap it for
394
- one of the two real `ModelCall` implementations this repo ships:
311
+ **CLI:**
395
312
 
396
- ```ts
397
- import { createAnthropicModelCall } from './anthropic-model-call.js'
313
+ ```bash
314
+ npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
315
+ ```
398
316
 
399
- const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // reads ANTHROPIC_API_KEY from the env
317
+ **HTTP:**
400
318
 
401
- const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
319
+ ```bash
320
+ npx tsx adapters/http.ts
321
+ curl -X POST localhost:8787/agents/customer-service/messages \
322
+ -H 'content-type: application/json' \
323
+ -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
402
324
  ```
403
325
 
404
- ```ts
405
- import { createOpenAIModelCall } from './openai-model-call.js'
326
+ Add `/stream` to the URL for a Server-Sent Events response — one event per
327
+ loop step (tool call, permission decision, budget check) instead of a
328
+ single reply at the end.
329
+
330
+ Three small browser pages, all cross-linked, share one look
331
+ (`adapters/dev-ui-styles.ts`), and never need a build step — each is a
332
+ self-contained HTML string served straight out of `adapters/http.ts`:
333
+
334
+ **Agents list:** open `http://localhost:8787/agents` in a browser (the
335
+ same route returns plain `{agents: [...]}` JSON to a non-browser client —
336
+ content-negotiated on the `Accept` header, so nothing that already calls
337
+ it as an API needs to change) to see every registered agent with links
338
+ into the playground and config page below.
339
+
340
+ **Dev playground:** open `http://localhost:8787/playground` (optionally
341
+ `?agent=<name>` to preselect one) — pick an agent, chat with it, and watch
342
+ that same loop-step event stream render live instead of reading raw SSE
343
+ frames. Same `/messages/stream` route underneath.
344
+
345
+ **Agent config page:** open `http://localhost:8787/agents/config`
346
+ (optionally `?agent=<name>`) to browse every registered agent's resolved
347
+ config — system prompt, model, tools (with their JSON schemas and
348
+ parallel-safety), the actual ActAuth rules that would be enforced (source,
349
+ default decision, per-rule scope/tool/decision/`when`), and whether
350
+ `sessionIdFor`/`tenantFor`/`isSafeTool`/`approver` are custom or
351
+ defaulted. Backed by `GET /agents/:name/config`, which reuses the same
352
+ rule/tool resolution `runAgent()` itself uses, so it can't drift out of
353
+ sync with what a real request actually gets. Never returns
354
+ `AgentModelConfig.apiKey`.
406
355
 
407
- // model is required (no built-in default) — OpenAI's current flagship
408
- // name changes too often to hardcode safely; reads OPENAI_API_KEY from the env
409
- const modelCall = createOpenAIModelCall({ model: 'gpt-4.1' })
356
+ ## Wiring a real model
410
357
 
411
- const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
412
- ```
358
+ Declare `AgentConfig.model` and the runtime builds a real `ModelCall` for
359
+ you, using the matching API key from the environment:
413
360
 
414
- ```ts
415
- import { createDeepSeekModelCall } from './deepseek-model-call.js'
361
+ | `provider` | env var | `model` required? |
362
+ | --- | --- | --- |
363
+ | `'anthropic'` | `ANTHROPIC_API_KEY` | no — defaults to `claude-sonnet-5` |
364
+ | `'openai'` | `OPENAI_API_KEY` | yes |
365
+ | `'deepseek'` | `DEEPSEEK_API_KEY` | yes |
416
366
 
417
- // model is required, same reasoning as OpenAI's; reads DEEPSEEK_API_KEY
418
- // from the env — throws immediately, with a DeepSeek-specific message, if
419
- // neither that nor apiKey is set, rather than the OpenAI SDK's own
420
- // constructor silently accepting OPENAI_API_KEY instead.
421
- const modelCall = createDeepSeekModelCall({ model: 'deepseek-chat' })
422
-
423
- const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
424
- ```
367
+ For anything else (a custom SDK client, a canned/simulated model for
368
+ testing), export your own `createModelCall(): ModelCall` instead.
425
369
 
426
- Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
427
- change — `ModelCall` is the only seam a real API call needs.
428
-
429
- loopengine's own `Message` type carries real block-native history: `content`
430
- is either a plain string or a `ModelContentBlock[]` — the same shape
431
- `ModelResponse.content` already used — so a model's `tool_use` requests and
432
- this loop's `tool_result` replies round-trip with real per-call identity
433
- (`tool_use_id`) rather than being flattened into prose. That's what makes
434
- parallel tool calls unambiguous (a result links back to the exact call that
435
- requested it, not just "a call to this tool name") and lets a denied or
436
- failed call carry `is_error: true`, which Claude is specifically trained to
437
- react to. `anthropic-model-call.ts` translates directly to and from
438
- Anthropic's native `TextBlockParam`/`ToolUseBlockParam`/`ToolResultBlockParam`
439
- types — no flattening step in between. This was verified against the real
440
- SDK (with a stubbed `fetch`, not a live call) — request shape, tool schemas,
441
- and response mapping all round-trip correctly end to end.
442
-
443
- `openai-model-call.ts` translates to and from OpenAI's Chat Completions
444
- shape instead, verified the same way (stubbed `fetch`, real SDK, full
445
- `runAgent` round trip including a tool call). The one structural mismatch:
446
- loopengine bundles a whole turn's `tool_result` blocks into a single
447
- user-role message (mirroring Anthropic's shape); OpenAI has no equivalent —
448
- each becomes its own top-level `role: 'tool'` message, so one loopengine
449
- `Message` can expand into several OpenAI messages, never the reverse.
450
-
451
- `deepseek-model-call.ts` reuses `openai-model-call.ts`'s translation
452
- verbatim (exported, not duplicated) rather than reimplementing it —
453
- DeepSeek's Chat Completions API is wire-compatible with OpenAI's (verified
454
- against DeepSeek's own docs), down to the same `tool_calls` response
455
- shape. The one real difference: DeepSeek documents `max_tokens`, not the
456
- newer `max_completion_tokens` OpenAI's endpoint expects, so only the
457
- request-building call site differs, not the shared message/response
458
- translation functions.
459
-
460
- ## Skills
461
-
462
- Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
463
- convention production coding agents use: a short frontmatter index (`name`,
464
- `description`) stays in context at all times, and the full body is loaded
465
- only when the model actually invokes that skill — through a real `Skill`
466
- tool schema (`{skill: string, args?: string}`) declared to the model
467
- whenever `skillsDirs` is set, not just handled after the fact. Set
468
- `skillsDirs` on an `AgentConfig` to enable it; omit it for agents that
469
- don't need skills (`agents/rag-agent.ts` doesn't).
470
-
471
- Point `skillsDirs` at a subdirectory scoped to that agent
472
- (`skills/file-agent/`, not the shared `skills/` root) — `SkillGarden`'s
473
- discovery recursively walks whatever root it's given with no per-agent
474
- filtering, so two agents both pointed at `skills/` would each see every
475
- skill under it, not just their own.
476
-
477
- To install one of SkillGarden's bundled skills (e.g. `firecrawl`) into an
478
- agent's own subdirectory, use its `add` CLI:
370
+ ## Built on
479
371
 
480
- ```bash
481
- npx skillgarden add firecrawl --agent file-agent
482
- # -> Added file-agent:firecrawl -> skills/file-agent/firecrawl/SKILL.md
483
- ```
372
+ | Package | Responsibility |
373
+ |---|---|
374
+ | [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
375
+ | [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking |
376
+ | [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
377
+ | [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
378
+ | [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
379
+ | [`toollane`](https://www.npmjs.com/package/toollane) | Parallel/solo tool-call scheduling |
380
+ | [`mcpplug`](https://www.npmjs.com/package/mcpplug) | Gateway tool sourcing (e.g. Composio) |
484
381
 
485
- That writes `skills/file-agent/firecrawl/SKILL.md`, which discovery then
486
- surfaces as `file-agent:firecrawl` — no code change needed beyond having
487
- already set `skillsDirs: 'skills/file-agent'` on that agent's config.
382
+ Installing `loopengine` pulls all of these in as regular dependencies —
383
+ install one directly only if you want to use it standalone.
488
384
 
489
- ## Project layout
385
+ ## Deployment
490
386
 
387
+ ```bash
388
+ docker compose up --build
491
389
  ```
492
- index.ts Public API surface — what actually ships to npm
493
- agent-config.ts AgentConfig type — the thing you fill in to define an agent
494
- run-agent.ts The generic ReAct loop every agent and adapter runs through
495
- vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
496
- discover-agents.ts discoverAgents — scans a directory, builds {name -> {config, createModelCall}}
497
- agent-registry.ts This repo's ~15-line wrapper: discoverAgents('agents/') + listAgents()/getEntry()
498
- session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
499
- anthropic-model-call.ts createAnthropicModelCall — a real ModelCall this repo ships
500
- openai-model-call.ts createOpenAIModelCall — same seam, OpenAI's Chat Completions API
501
- deepseek-model-call.ts createDeepSeekModelCall — same seam, reuses openai-model-call.ts's translation
502
- agents/file-agent.ts Example agent: summarizes text files
503
- agents/customer-service-agent.ts Example agent: order/shipment lookup, refund, email
504
- agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
505
- adapters/cli.ts Channel adapter: command line
506
- adapters/http.ts Channel adapter: HTTP API
507
- skills/ SKILL.md files discoverable by agents, scoped per agent subdirectory
508
- examples/file-agent/ Sample input files + generated output for the file-agent demo
509
- Dockerfile, docker-compose.yml Container build + local Redis for testing
510
- ```
390
+
391
+ Builds the HTTP adapter into a container and starts it alongside Redis.
392
+ For production, push the image to any container platform with `REDIS_URL`
393
+ and your model/tool API keys set as environment variables.