loopengine 0.0.14 → 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 +303 -573
- package/dist/agent-as-tool.d.ts +7 -0
- package/dist/agent-as-tool.js +32 -0
- package/dist/agent-config.d.ts +17 -1
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +174 -0
- package/dist/discover-agents.d.ts +11 -2
- package/dist/discover-agents.js +25 -13
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/model-calls/anthropic-model-call.d.ts +1 -1
- package/dist/model-calls/deepseek-model-call.d.ts +1 -1
- package/dist/model-calls/openai-model-call.d.ts +1 -1
- package/dist/run-agent.d.ts +24 -1
- package/dist/run-agent.js +98 -6
- package/dist/session-store.d.ts +1 -1
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -2,40 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
LoopEngine is a runtime for
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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,619 +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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
56
|
-
|
|
57
|
-
## Using loopengine as a library
|
|
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.
|
|
58
47
|
|
|
59
|
-
|
|
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
|
-
|
|
51
|
+
npx loopengine add-agent weather-agent
|
|
52
|
+
# -> Created agents/weather-agent/index.ts
|
|
65
53
|
```
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
it
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
import { runAgent, type AgentConfig, type ToolDefinition } from 'loopengine'
|
|
76
|
-
|
|
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
|
-
}
|
|
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.
|
|
83
61
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
}
|
|
62
|
+
Most fields don't even need to be written out — they default to a
|
|
63
|
+
conventional path under the agent's own folder:
|
|
91
64
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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) |
|
|
96
70
|
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
100
|
-
`embed`/`cosineSimilarity` (for RAG — see below), `discoverAgents` (scans a
|
|
101
|
-
directory for modules exporting `config` — a flat `.ts`/`.js` file, or a
|
|
102
|
-
subdirectory with an `index.ts`/`index.js`, the same "a directory can be a
|
|
103
|
-
module" convention Node's own `require()` already uses, for an agent whose
|
|
104
|
-
own implementation outgrows one file, like `agents/customer-service/` —
|
|
105
|
-
plus either that module's own `createModelCall` or an `AgentConfig.model`
|
|
106
|
-
to synthesize one from, and builds a name → entry map, keyed by
|
|
107
|
-
`AgentConfig.name` — see `agent-registry.ts` for the ~15-line wrapper this
|
|
108
|
-
repo's own adapters use), and `createAnthropicModelCall`/
|
|
109
|
-
`createOpenAIModelCall`/`createDeepSeekModelCall` (real, ready-to-use
|
|
110
|
-
`ModelCall`s — what `AgentConfig.model` builds under the hood). See
|
|
111
|
-
`index.ts` for the exact list.
|
|
112
|
-
|
|
113
|
-
**Not** part of the package: `agents/*.ts` (this repo's own demo agents),
|
|
114
|
-
`agent-registry.ts` (this repo's own name → config lookup table, built on
|
|
115
|
-
`discoverAgents` — build your own the same way), `adapters/cli.ts`/
|
|
116
|
-
`adapters/http.ts` (one specific CLI/HTTP wiring choice, not imposed on
|
|
117
|
-
every consumer), and the `Dockerfile`. Everything below this point
|
|
118
|
-
describes that reference app — read it as a worked example of what to
|
|
119
|
-
build with the library, not as library API itself.
|
|
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.
|
|
120
74
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
```bash
|
|
124
|
-
npm install
|
|
125
|
-
npx tsx adapters/cli.ts --agent file-agent "Summarize examples/file-agent/a.txt and examples/file-agent/b.txt into examples/file-agent/summary.txt."
|
|
126
|
-
npx tsx adapters/cli.ts --agent customer-service "order A-1001 arrived broken and wants a refund" # needs DEEPSEEK_API_KEY
|
|
127
|
-
npx tsx adapters/cli.ts --agent rag-agent "How does ActAuth record human approval decisions?"
|
|
128
|
-
```
|
|
75
|
+
`rules` is how you gate what a tool can do without approval — see
|
|
76
|
+
"Tool permission and multi-tenancy" below.
|
|
129
77
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
needs `DEEPSEEK_API_KEY` set in the environment.
|
|
134
|
-
|
|
135
|
-
## Defining your own agent
|
|
136
|
-
|
|
137
|
-
An agent is an `AgentConfig` (`agent-config.ts`): a name, a system prompt, a
|
|
138
|
-
list of tools, and ActAuth permission rules. There are two ways to add one
|
|
139
|
-
under `agents/`, and `agent-registry.ts` (built on `discoverAgents`) finds
|
|
140
|
-
either automatically at startup — by `AgentConfig.name`, not the filename
|
|
141
|
-
or folder name. Nothing to edit, no import to add, no adapter change,
|
|
142
|
-
either way:
|
|
143
|
-
|
|
144
|
-
1. **A flat file**, `agents/<name>.ts` — the default; start here.
|
|
145
|
-
`agents/rag-agent.ts` is a complete, working example.
|
|
146
|
-
2. **A folder**, `agents/<name>/index.ts` — for an agent whose
|
|
147
|
-
implementation outgrows one file. `discoverAgents` finds a
|
|
148
|
-
subdirectory's `index.ts`/`index.js` the same way it finds a flat
|
|
149
|
-
file, the same "a directory can be a module" convention Node's own
|
|
150
|
-
`require()` resolution already uses. `agents/customer-service/` and
|
|
151
|
-
`agents/file-agent/` are complete, working examples.
|
|
152
|
-
|
|
153
|
-
Either way, the module must export `config`, plus either its own
|
|
154
|
-
`createModelCall` or an `AgentConfig.model` for `discoverAgents` to
|
|
155
|
-
synthesize one from (see "Wiring a real model" below) — `discoverAgents`
|
|
156
|
-
throws at startup on a module in `agents/` that exports neither (a real,
|
|
157
|
-
load-bearing check: a module you forgot to finish wiring up, or an
|
|
158
|
-
unrelated `.ts` file that doesn't belong in `agents/` at all, should fail
|
|
159
|
-
loudly here rather than silently not showing up):
|
|
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/`:
|
|
160
81
|
|
|
161
82
|
```ts
|
|
162
|
-
import type { AgentConfig } from '
|
|
83
|
+
import type { AgentConfig } from '../../agent-config.js'
|
|
163
84
|
|
|
164
85
|
export const config: AgentConfig = {
|
|
165
|
-
name: '
|
|
166
|
-
systemPrompt: 'You
|
|
167
|
-
model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
|
|
168
|
-
tools: [
|
|
169
|
-
{
|
|
170
|
-
name: 'do_thing',
|
|
171
|
-
description: 'What the model sees when deciding to call this',
|
|
172
|
-
input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
173
|
-
execute: async (input) => {
|
|
174
|
-
// real implementation — DB call, HTTP request, whatever
|
|
175
|
-
return { ok: true }
|
|
176
|
-
},
|
|
177
|
-
},
|
|
178
|
-
],
|
|
179
|
-
rules: [
|
|
180
|
-
{ scopePattern: 'default/production/my-agent', tool: 'do_thing', decision: 'allow' },
|
|
181
|
-
],
|
|
182
|
-
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
|
|
183
89
|
}
|
|
184
90
|
```
|
|
185
91
|
|
|
186
|
-
`agents/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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:
|
|
212
159
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
to a conventional path under this agent's own folder, `agents/<name>/…`,
|
|
217
|
-
instead of something you write out:
|
|
160
|
+
```ts
|
|
161
|
+
// Global skill: add the root path alongside the agent's own
|
|
162
|
+
skillsDirs: ['agents/my-agent/skills', 'skills']
|
|
218
163
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
| `skillsDirs` | `['agents/<name>/skills']` | no skills discovered, no `Skill` tool declared |
|
|
224
|
-
|
|
225
|
-
None of these throw over a missing file/folder — only `rules` changes
|
|
226
|
-
behavior when it's missing (deny-by-default, not "no rules apply"), since
|
|
227
|
-
skipping every tool is safer than silently allowing them all. Pass an
|
|
228
|
-
explicit empty value (`[]`, or a `rules` path that doesn't exist) instead
|
|
229
|
-
of omitting the field to opt out of a default on purpose — an
|
|
230
|
-
*explicitly* wrong path is a real configuration bug and still throws.
|
|
231
|
-
|
|
232
|
-
The `actauth.yml` form is close to the shape `examples/actauth.yml` in the
|
|
233
|
-
actauth package itself uses, but each rule's `scope` only needs
|
|
234
|
-
*tenant/environment* (e.g. `"*/*"`, `"acme-corp/production"`) — no agent
|
|
235
|
-
segment, since a loopengine actauth.yml is always one-file-per-agent by
|
|
236
|
-
convention. `run-agent.ts` appends `/<name>` to every rule loaded from a
|
|
237
|
-
YAML file automatically; writing the agent's own name into every single
|
|
238
|
-
rule would be pure repetition. See `agents/customer-service/actauth.yml`.
|
|
239
|
-
|
|
240
|
-
An agent that needs to merge tools in from somewhere else too —
|
|
241
|
-
`agents/file-agent/index.ts`'s Composio-sourced ones — can't rely on the
|
|
242
|
-
`tools` default and sets it explicitly instead.
|
|
243
|
-
|
|
244
|
-
### Parallel tool execution
|
|
245
|
-
|
|
246
|
-
When a model requests several tool calls in one turn, `ToolLane` decides
|
|
247
|
-
which can run concurrently: mark a tool `safe: true` on its
|
|
248
|
-
`ToolDefinition` (read-only, no side effects, no shared mutable state —
|
|
249
|
-
`agents/customer-service/tools/lookup_order.ts`) and consecutive safe
|
|
250
|
-
calls run together in a parallel lane; anything else gets its own solo
|
|
251
|
-
lane. For per-call nuance beyond a tool's static definition (safety that
|
|
252
|
-
depends on the specific call's arguments, or varies by agent), set
|
|
253
|
-
`AgentConfig.isSafeTool: (call) => boolean` instead — it takes full
|
|
254
|
-
precedence over every tool's own `safe` flag when set
|
|
255
|
-
(`agents/file-agent/index.ts` uses this form). Neither set: every tool
|
|
256
|
-
runs solo.
|
|
257
|
-
|
|
258
|
-
### Human approval
|
|
259
|
-
|
|
260
|
-
`AgentConfig.approver` — an `Approver` from `actauth` — is what an `ask`
|
|
261
|
-
decision routes to; the default `ConsoleApprover` blocks on stdin, fine
|
|
262
|
-
for the CLI but wrong for an unattended server (see
|
|
263
|
-
`agents/customer-service/index.ts`'s own auto-approving demo `approver`,
|
|
264
|
-
and swap in a real one, e.g. Slack-backed, for production).
|
|
265
|
-
`AgentConfig.maxTurns` (default 25) caps model calls in one `runAgent()`
|
|
266
|
-
turn — the only thing stopping a model stuck re-requesting the same tool
|
|
267
|
-
forever; hitting it ends the turn with `RunAgentResult.stopReason` set to
|
|
268
|
-
`'max_turns'`, a real result rather than a thrown error.
|
|
269
|
-
|
|
270
|
-
## Retrieval (RAG)
|
|
271
|
-
|
|
272
|
-
RAG is just another tool — the model decides when to call it, `run-agent.ts`
|
|
273
|
-
doesn't know or care that "executing" this particular tool means a vector
|
|
274
|
-
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'
|
|
275
168
|
|
|
276
|
-
|
|
277
|
-
const searchDocs: ToolDefinition = {
|
|
278
|
-
name: 'search_docs',
|
|
279
|
-
description: 'Search the knowledge base for relevant passages',
|
|
280
|
-
input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
|
|
281
|
-
execute: async (input) => {
|
|
282
|
-
const hits = index.search(input.query as string, 3) // top-3 chunks
|
|
283
|
-
return hits.map((h) => h.text).join('\n\n')
|
|
284
|
-
},
|
|
285
|
-
}
|
|
169
|
+
tools: [...ownTools, sharedTool]
|
|
286
170
|
```
|
|
287
171
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
`
|
|
294
|
-
|
|
295
|
-
`Summarizer` or ActAuth's `Approver`. The retrieved text comes back as an
|
|
296
|
-
ordinary tool result, subject to the same ActAuth gating, ToolLane
|
|
297
|
-
scheduling, and ContextClip budget tracking as any other tool call.
|
|
298
|
-
|
|
299
|
-
## External tool gateways
|
|
300
|
-
|
|
301
|
-
A hand-written tool needs an `execute` function you write yourself; an
|
|
302
|
-
external SaaS action (list GitHub repos, send a Slack message) needs
|
|
303
|
-
someone to own that vendor's auth. Connecting straight to a vendor's own
|
|
304
|
-
MCP server means owning its OAuth flow (authorization code, PKCE, token
|
|
305
|
-
storage/refresh) yourself, repeated per vendor. Routing through a gateway
|
|
306
|
-
like [Composio](https://composio.dev) avoids that — it already holds the
|
|
307
|
-
OAuth relationship with 1000+ apps — and [`mcpplug`](https://www.npmjs.com/package/mcpplug)
|
|
308
|
-
wraps that gateway behind the same `ToolDefinition` shape as everything
|
|
309
|
-
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:
|
|
310
179
|
|
|
311
180
|
```ts
|
|
312
181
|
import { connectComposioSource } from 'mcpplug'
|
|
313
182
|
|
|
314
|
-
// Resolved once, at module-eval time — not per runAgent() call. A gateway
|
|
315
|
-
// connection is exactly the kind of setup cost runAgent() shouldn't pay on
|
|
316
|
-
// every turn; see agents/file-agent/index.ts for the working example this is
|
|
317
|
-
// drawn from.
|
|
318
183
|
const composioTools = await connectComposioSource('composio', {
|
|
319
184
|
slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
|
|
320
185
|
}).then((source) => source.loadTools())
|
|
321
186
|
|
|
322
187
|
const config: AgentConfig = {
|
|
323
188
|
name: 'my-agent',
|
|
324
|
-
systemPrompt: 'You are ...',
|
|
325
189
|
tools: [...handWrittenTools, ...composioTools],
|
|
326
|
-
rules: [
|
|
327
|
-
// mcpplug namespaces each tool `${sourceName}_${slug}` — falls
|
|
328
|
-
// through to defaultDecision unless a rule names it explicitly.
|
|
329
|
-
{ scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' },
|
|
330
|
-
],
|
|
190
|
+
rules: [{ scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' }],
|
|
331
191
|
defaultDecision: 'ask',
|
|
332
192
|
}
|
|
333
193
|
```
|
|
334
194
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
`agents/file-agent/index.ts` is a complete, working example, verified end-to-end
|
|
339
|
-
against a real linked GitHub account. `slugs` is explicit, not
|
|
340
|
-
auto-discovered — an agent should only see the Composio actions its own
|
|
341
|
-
config actually lists; find them with the `composio` CLI's
|
|
342
|
-
`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.
|
|
343
198
|
|
|
344
|
-
|
|
199
|
+
### 4. Subagents — an agent as another agent's tool
|
|
345
200
|
|
|
346
|
-
|
|
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:
|
|
347
204
|
|
|
348
|
-
```bash
|
|
349
|
-
npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
|
|
350
205
|
```
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
curl -X POST localhost:8787/agents/customer-service/messages \
|
|
357
|
-
-H 'content-type: application/json' \
|
|
358
|
-
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
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
|
|
359
211
|
```
|
|
360
212
|
|
|
361
|
-
|
|
362
|
-
`AgentConfig.sessionIdFor` (see "Sessions" below). Other
|
|
363
|
-
agents take a plain `sessionId` field instead — and it's optional: omit it
|
|
364
|
-
for a fresh, one-off session (a generated id, echoed back in the response
|
|
365
|
-
as `sessionId` so you can pass it in explicitly to continue that same
|
|
366
|
-
conversation on a later request):
|
|
213
|
+
Scaffold one directly:
|
|
367
214
|
|
|
368
215
|
```bash
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
-d '{"message":"Summarize a.txt and b.txt into summary.txt."}'
|
|
216
|
+
npx loopengine add-subagent support-orchestrator billing-agent
|
|
217
|
+
# -> Created agents/support-orchestrator/subagents/billing-agent/index.ts
|
|
372
218
|
```
|
|
373
219
|
|
|
374
|
-
|
|
375
|
-
`
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
`customer-service` also sets `AgentConfig.tenantFor` (see "Multi-tenancy"
|
|
380
|
-
below), resolving tenant from an `x-api-key` header. The request above
|
|
381
|
-
with no header behaves
|
|
382
|
-
exactly as before — `issue_refund` still asks for approval. Add a
|
|
383
|
-
trusted key and it doesn't:
|
|
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):
|
|
384
224
|
|
|
385
|
-
```
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
loop step instead of a single response after the whole thing finishes —
|
|
393
|
-
a `session` event first (announcing whatever `sessionId` got used, generated
|
|
394
|
-
or not), then `contextclip:check`, `actauth:decision`, `toollane:result`,
|
|
395
|
-
..., a final `done` with the answer:
|
|
396
|
-
|
|
397
|
-
```bash
|
|
398
|
-
curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
|
|
399
|
-
-H 'content-type: application/json' \
|
|
400
|
-
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
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
|
+
}
|
|
401
232
|
```
|
|
402
233
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
route just discards, over SSE instead of into a log. What this streams is
|
|
410
|
-
loop *steps* (tool calls, permission decisions, budget checks), not model
|
|
411
|
-
output tokens — no `ModelCall` implementation here (fake or
|
|
412
|
-
`createAnthropicModelCall`) streams partial text from the model itself, so
|
|
413
|
-
there's nothing at the token level to forward yet.
|
|
414
|
-
|
|
415
|
-
## Sessions
|
|
416
|
-
|
|
417
|
-
A session is one ongoing conversation — its message history persists
|
|
418
|
-
between requests via a `SessionStore`, so a caller can send one message,
|
|
419
|
-
get a reply, and come back later and continue where it left off.
|
|
420
|
-
|
|
421
|
-
**Storage.** `SessionStore` exposes one method, `withSession(sessionId,
|
|
422
|
-
fn)`: it loads that session's history, runs `fn` **exclusively** for that
|
|
423
|
-
session (so two concurrent requests for the same conversation can't race
|
|
424
|
-
on read-modify-write), and appends whatever new messages `fn` produced.
|
|
425
|
-
History is an append-only [`sessionknit`](https://www.npmjs.com/package/sessionknit)
|
|
426
|
-
log rather than a flat file rewritten every turn, which buys two things:
|
|
427
|
-
|
|
428
|
-
- **Non-blocking, debounced writes** — a turn only appends, it never
|
|
429
|
-
rewrites what's already durable.
|
|
430
|
-
- **Interruption detection** — a turn's assistant response (including any
|
|
431
|
-
tool calls) is saved *before* those tools run, then the results are
|
|
432
|
-
saved once they've all settled. If the process dies in between, the
|
|
433
|
-
next resume sees an unanswered tool call, flags the session as
|
|
434
|
-
resumed-after-interruption, and tells the model so in context — instead
|
|
435
|
-
of silently replaying a half-finished turn as if it were clean.
|
|
436
|
-
|
|
437
|
-
Two implementations, same interface — `createSessionStore()` picks
|
|
438
|
-
between them based on `REDIS_URL` (set it and you get Redis, otherwise
|
|
439
|
-
the file store):
|
|
440
|
-
|
|
441
|
-
- `FileSessionStore` — one JSONL log per session under `.sessions/`,
|
|
442
|
-
locked in-process. Fine for local dev and the CLI.
|
|
443
|
-
- `RedisSessionStore` — same log shape in a Redis list, with a real
|
|
444
|
-
distributed lock (safe across multiple server instances) that renews
|
|
445
|
-
itself for as long as a turn runs, instead of a flat TTL a long turn
|
|
446
|
-
could outlast.
|
|
447
|
-
|
|
448
|
-
**Session keys.** Two questions decide which log a message lands in:
|
|
449
|
-
*which conversation* is this, and *which agent's* conversation is it?
|
|
450
|
-
|
|
451
|
-
- *Which conversation* — `AgentConfig.sessionIdFor(body)`. This is
|
|
452
|
-
business logic ("what counts as one conversation" depends on what the
|
|
453
|
-
agent is for), so it lives on the agent, not the adapter.
|
|
454
|
-
`agents/customer-service/index.ts` hashes `customerEmail` from the
|
|
455
|
-
request body into a session id, so different customers can never read
|
|
456
|
-
or write each other's history, and a request with no `customerEmail` is
|
|
457
|
-
rejected (`400`) rather than silently starting an anonymous session. An
|
|
458
|
-
agent that doesn't define `sessionIdFor` falls back to a plain
|
|
459
|
-
client-supplied `sessionId` field — or, if that's missing too, a
|
|
460
|
-
generated one, returned in the response so the caller can pass it back
|
|
461
|
-
in to continue that exact conversation later.
|
|
462
|
-
- *Which agent* — `SessionStore` itself doesn't know or care which agent
|
|
463
|
-
is calling it, so both adapters prefix the id with the agent's name
|
|
464
|
-
before it ever reaches `SessionStore`. Without that, two different
|
|
465
|
-
agents handed the same raw session id (a client reusing one `sessionId`
|
|
466
|
-
value across agents) would read and write the exact same log.
|
|
467
|
-
|
|
468
|
-
`adapters/http.ts` also folds the caller's tenant and environment into
|
|
469
|
-
that key (see "Multi-tenancy" below) — without that, two different
|
|
470
|
-
tenants whose `sessionIdFor` happens to produce the same raw id would
|
|
471
|
-
collide on the same log too. Verified live: the same raw `sessionId` sent
|
|
472
|
-
under two different tenants lands in two separate session files, not one.
|
|
473
|
-
|
|
474
|
-
## Multi-tenancy
|
|
475
|
-
|
|
476
|
-
ActAuth's permission rules are scoped by `tenant`/`environment`/`agent` —
|
|
477
|
-
a support agent might allow more for a trusted tenant, or relax approval
|
|
478
|
-
requirements outside production. `agent` is always `AgentConfig.name`;
|
|
479
|
-
the other two work differently, because only one of them varies per request:
|
|
480
|
-
|
|
481
|
-
- **`environment`** is a deployment-wide setting, not a per-agent or
|
|
482
|
-
per-request one — always `process.env.LOOPENGINE_ENV` (default
|
|
483
|
-
`'production'`), the same for every agent this process runs. There's no
|
|
484
|
-
`AgentConfig` field for it at all.
|
|
485
|
-
- **`tenant`** varies by *who's calling*, so it's resolved per request
|
|
486
|
-
via `AgentConfig.tenantFor?: (headers, body) => string | undefined`.
|
|
487
|
-
Only `headers`, never `body` — tenant feeds permission decisions
|
|
488
|
-
directly, so it has to come from something verified (an
|
|
489
|
-
`Authorization`/API-key header checked against your own mapping), never
|
|
490
|
-
a client-asserted body field anyone could fake to claim another
|
|
491
|
-
tenant's permissions. Omit `tenantFor` entirely and every request is
|
|
492
|
-
the `'default'` tenant.
|
|
493
|
-
|
|
494
|
-
Only `adapters/http.ts` actually calls `tenantFor`, via `resolveTenant`.
|
|
495
|
-
Two rules govern the result:
|
|
496
|
-
|
|
497
|
-
- Returning `undefined` is a real auth failure (`401`) — not "fall back
|
|
498
|
-
to `'default'`." If no header at all should mean `'default'` rather
|
|
499
|
-
than a rejection, the resolver must return `'default'` itself.
|
|
500
|
-
- No `tenantFor` at all isn't a failure — it just means this agent has no
|
|
501
|
-
per-request tenants, so every request resolves to `'default'`.
|
|
502
|
-
|
|
503
|
-
`run-agent.ts` never sees a request, so it can't call `tenantFor` either
|
|
504
|
-
— the standalone/CLI paths that pass `config` to `runAgent` directly
|
|
505
|
-
(skipping `adapters/http.ts`'s resolution) always get `'default'`, the
|
|
506
|
-
same as `RunAgentOptions.tenant` being omitted. Verified live end to end:
|
|
507
|
-
a header-derived tenant resolves to different ActAuth rules per tenant (a
|
|
508
|
-
specific tenant's rule overrides a wildcard fallback), and an omitted
|
|
509
|
-
`tenant` option resolves the same as an explicit `'default'`.
|
|
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.
|
|
510
240
|
|
|
511
|
-
|
|
241
|
+
Subagents nest — a subagent's own `subagents/` folder works the same way,
|
|
242
|
+
one level down, addressed by joining names with `/`:
|
|
512
243
|
|
|
513
244
|
```bash
|
|
514
|
-
|
|
245
|
+
npx loopengine add-subagent support-orchestrator/billing-agent disputes-agent
|
|
515
246
|
```
|
|
516
247
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
280
|
+
```
|
|
522
281
|
|
|
523
|
-
|
|
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.
|
|
524
308
|
|
|
525
|
-
|
|
526
|
-
turn-counting `ModelCall` so the whole loop runs with no API key.
|
|
527
|
-
`agents/customer-service/index.ts` is already wired to a real one — a
|
|
528
|
-
working example, not just a snippet.
|
|
309
|
+
## Running an agent
|
|
529
310
|
|
|
530
|
-
**
|
|
531
|
-
entirely; `discoverAgents` builds it for you:
|
|
311
|
+
**CLI:**
|
|
532
312
|
|
|
533
|
-
```
|
|
534
|
-
|
|
535
|
-
name: 'my-agent',
|
|
536
|
-
// ...
|
|
537
|
-
model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
|
|
538
|
-
}
|
|
313
|
+
```bash
|
|
314
|
+
npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
|
|
539
315
|
```
|
|
540
316
|
|
|
541
|
-
|
|
542
|
-
| --- | --- | --- | --- |
|
|
543
|
-
| `'anthropic'` | `ANTHROPIC_API_KEY` | no — defaults to `claude-sonnet-5` | `{ provider: 'anthropic' }` |
|
|
544
|
-
| `'openai'` | `OPENAI_API_KEY` | yes — no safe hardcoded default | `{ provider: 'openai', model: 'gpt-4.1' }` |
|
|
545
|
-
| `'deepseek'` | `DEEPSEEK_API_KEY` | yes — no safe hardcoded default | `{ provider: 'deepseek', model: 'deepseek-chat' }` |
|
|
546
|
-
|
|
547
|
-
`agents/customer-service/index.ts` uses exactly this —
|
|
548
|
-
`model: { provider: 'deepseek', model: 'deepseek-v4-pro' }`, nothing
|
|
549
|
-
else. Built lazily, on first actual call, and memoized after that, not at
|
|
550
|
-
module load: `discoverAgents()` imports every agent at startup, so
|
|
551
|
-
building the real client eagerly would crash the whole server without
|
|
552
|
-
`DEEPSEEK_API_KEY`, even just to run `file-agent`.
|
|
553
|
-
|
|
554
|
-
**The manual way** — for anything the table above can't express (a
|
|
555
|
-
canned/simulated `ModelCall` like `file-agent`/`rag-agent`'s own, a
|
|
556
|
-
custom SDK client, a provider not listed), export `createModelCall`
|
|
557
|
-
yourself and call the same factories `AgentConfig.model` uses under the
|
|
558
|
-
hood:
|
|
559
|
-
|
|
560
|
-
```ts
|
|
561
|
-
import { createAnthropicModelCall } from './model-calls/anthropic-model-call.js'
|
|
562
|
-
// createOpenAIModelCall / createDeepSeekModelCall work the same way
|
|
317
|
+
**HTTP:**
|
|
563
318
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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"}'
|
|
567
324
|
```
|
|
568
325
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
and
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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`.
|
|
579
355
|
|
|
580
|
-
##
|
|
356
|
+
## Wiring a real model
|
|
581
357
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
`description`) stays in context at all times, and the full body is loaded
|
|
585
|
-
only when the model actually invokes that skill — through a real `Skill`
|
|
586
|
-
tool schema (`{skill: string, args?: string}`) declared to the model
|
|
587
|
-
whenever any skills are actually found, not just handled after the fact.
|
|
358
|
+
Declare `AgentConfig.model` and the runtime builds a real `ModelCall` for
|
|
359
|
+
you, using the matching API key from the environment:
|
|
588
360
|
|
|
589
|
-
`
|
|
590
|
-
|
|
591
|
-
`
|
|
592
|
-
|
|
593
|
-
`
|
|
594
|
-
the two kinds below live in different places.
|
|
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 |
|
|
595
366
|
|
|
596
|
-
|
|
597
|
-
|
|
367
|
+
For anything else (a custom SDK client, a canned/simulated model for
|
|
368
|
+
testing), export your own `createModelCall(): ModelCall` instead.
|
|
598
369
|
|
|
599
|
-
|
|
600
|
-
agents/customer-service/skills/
|
|
601
|
-
firecrawl/SKILL.md Only customer-service's skillsDirs points here
|
|
602
|
-
```
|
|
370
|
+
## Built on
|
|
603
371
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
`
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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) |
|
|
613
381
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
folder unless you mean for it to see both; and don't move a skill to the
|
|
617
|
-
global root just because it feels reusable in theory — wait until a
|
|
618
|
-
second agent actually needs the exact same one.
|
|
382
|
+
Installing `loopengine` pulls all of these in as regular dependencies —
|
|
383
|
+
install one directly only if you want to use it standalone.
|
|
619
384
|
|
|
620
|
-
|
|
621
|
-
agent-specific folder, pass `--agent` and no `--dir` — it defaults to
|
|
622
|
-
`agents/<agent>/skills/<skill>/SKILL.md`, matching how every skill in this
|
|
623
|
-
repo is actually laid out:
|
|
385
|
+
## Deployment
|
|
624
386
|
|
|
625
387
|
```bash
|
|
626
|
-
|
|
627
|
-
# -> Added file-agent:firecrawl -> agents/file-agent/skills/firecrawl/SKILL.md
|
|
628
|
-
```
|
|
629
|
-
|
|
630
|
-
## Project layout
|
|
631
|
-
|
|
632
|
-
```
|
|
633
|
-
index.ts Public API surface — what actually ships to npm
|
|
634
|
-
agent-config.ts AgentConfig type — the thing you fill in to define an agent
|
|
635
|
-
run-agent.ts The generic ReAct loop every agent and adapter runs through
|
|
636
|
-
vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
|
|
637
|
-
discover-agents.ts discoverAgents — scans a directory, builds {name -> {config, createModelCall}}
|
|
638
|
-
agent-registry.ts This repo's ~15-line wrapper: discoverAgents('agents/') + listAgents()/getEntry()
|
|
639
|
-
session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
|
|
640
|
-
model-calls/anthropic-model-call.ts createAnthropicModelCall — a real ModelCall this repo ships
|
|
641
|
-
model-calls/openai-model-call.ts createOpenAIModelCall — same seam, OpenAI's Chat Completions API
|
|
642
|
-
model-calls/deepseek-model-call.ts createDeepSeekModelCall — same seam, reuses openai-model-call.ts's translation
|
|
643
|
-
agents/file-agent/index.ts Example agent: summarizes text files
|
|
644
|
-
agents/file-agent/tools/ Its hand-written tools, one file per tool
|
|
645
|
-
agents/file-agent/skills/ Its SKILL.md files
|
|
646
|
-
agents/file-agent/actauth.yml Its ActAuth rules
|
|
647
|
-
agents/customer-service/index.ts Example agent: order/shipment lookup, refund, email
|
|
648
|
-
agents/customer-service/tools/ Its tools, one file per tool
|
|
649
|
-
agents/customer-service/skills/ Its SKILL.md files
|
|
650
|
-
agents/customer-service/actauth.yml Its ActAuth rules
|
|
651
|
-
agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
|
|
652
|
-
adapters/cli.ts Channel adapter: command line
|
|
653
|
-
adapters/http.ts Channel adapter: HTTP API
|
|
654
|
-
tools/README.md Root tools/ — for a tool genuinely shared across agents; empty today
|
|
655
|
-
skills/README.md Root skills/ — same, for a shared SKILL.md; empty today
|
|
656
|
-
examples/file-agent/ Sample input files + generated output for the file-agent demo
|
|
657
|
-
Dockerfile, docker-compose.yml Container build + local Redis for testing
|
|
388
|
+
docker compose up --build
|
|
658
389
|
```
|
|
659
390
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
repo yet (see each directory's own `README.md`).
|
|
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.
|