loopengine 0.0.7 → 0.0.14
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 +337 -184
- package/dist/agent-config.d.ts +123 -9
- package/dist/discover-agents.d.ts +7 -8
- package/dist/discover-agents.js +73 -24
- package/dist/index.d.ts +7 -7
- package/dist/index.js +3 -3
- package/dist/{anthropic-model-call.d.ts → model-calls/anthropic-model-call.d.ts} +1 -1
- package/dist/{anthropic-model-call.js → model-calls/anthropic-model-call.js} +8 -6
- package/dist/{deepseek-model-call.d.ts → model-calls/deepseek-model-call.d.ts} +1 -1
- package/dist/{openai-model-call.d.ts → model-calls/openai-model-call.d.ts} +1 -1
- package/dist/run-agent.d.ts +7 -0
- package/dist/run-agent.js +127 -8
- package/package.json +11 -10
- /package/dist/{deepseek-model-call.js → model-calls/deepseek-model-call.js} +0 -0
- /package/dist/{openai-model-call.js → model-calls/openai-model-call.js} +0 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
agent as a plain config object — persona,
|
|
7
|
-
|
|
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
8
|
chain DSL, no hidden control flow: `run-agent.ts` is a single readable
|
|
9
9
|
function you can step through top to bottom.
|
|
10
10
|
|
|
@@ -94,14 +94,21 @@ const result = await runAgent(config, myModelCall, "What's the weather in Boston
|
|
|
94
94
|
console.log(result.text)
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
The full exported surface: `runAgent`,
|
|
97
|
+
The full exported surface: `runAgent`,
|
|
98
|
+
`AgentConfig`/`AgentModelConfig`/`ToolSchema`/`ToolDefinition`,
|
|
98
99
|
`FileSessionStore`/`RedisSessionStore`/`createSessionStore`, `VectorIndex`/
|
|
99
100
|
`embed`/`cosineSimilarity` (for RAG — see below), `discoverAgents` (scans a
|
|
100
|
-
directory for `config
|
|
101
|
-
|
|
102
|
-
|
|
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`/
|
|
103
109
|
`createOpenAIModelCall`/`createDeepSeekModelCall` (real, ready-to-use
|
|
104
|
-
`ModelCall`s
|
|
110
|
+
`ModelCall`s — what `AgentConfig.model` builds under the hood). See
|
|
111
|
+
`index.ts` for the exact list.
|
|
105
112
|
|
|
106
113
|
**Not** part of the package: `agents/*.ts` (this repo's own demo agents),
|
|
107
114
|
`agent-registry.ts` (this repo's own name → config lookup table, built on
|
|
@@ -115,18 +122,41 @@ build with the library, not as library API itself.
|
|
|
115
122
|
|
|
116
123
|
```bash
|
|
117
124
|
npm install
|
|
118
|
-
npx tsx
|
|
119
|
-
npx tsx
|
|
120
|
-
npx tsx
|
|
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?"
|
|
121
128
|
```
|
|
122
129
|
|
|
123
|
-
|
|
124
|
-
|
|
130
|
+
`file-agent` and `rag-agent` still use a **simulated**, turn-counting model
|
|
131
|
+
call (no API key needed) — see "Wiring a real model" below.
|
|
132
|
+
`customer-service` declares `model: { provider: 'deepseek', ... }` and
|
|
133
|
+
needs `DEEPSEEK_API_KEY` set in the environment.
|
|
125
134
|
|
|
126
135
|
## Defining your own agent
|
|
127
136
|
|
|
128
137
|
An agent is an `AgentConfig` (`agent-config.ts`): a name, a system prompt, a
|
|
129
|
-
list of tools, and ActAuth permission rules.
|
|
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):
|
|
130
160
|
|
|
131
161
|
```ts
|
|
132
162
|
import type { AgentConfig } from './agent-config.js'
|
|
@@ -134,6 +164,7 @@ import type { AgentConfig } from './agent-config.js'
|
|
|
134
164
|
export const config: AgentConfig = {
|
|
135
165
|
name: 'my-agent',
|
|
136
166
|
systemPrompt: 'You are ...',
|
|
167
|
+
model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY from the env
|
|
137
168
|
tools: [
|
|
138
169
|
{
|
|
139
170
|
name: 'do_thing',
|
|
@@ -152,13 +183,89 @@ export const config: AgentConfig = {
|
|
|
152
183
|
}
|
|
153
184
|
```
|
|
154
185
|
|
|
155
|
-
`agents/file-agent.ts` and `agents/customer-service
|
|
156
|
-
examples — same `runAgent` loop, entirely different
|
|
186
|
+
`agents/file-agent/index.ts` and `agents/customer-service/index.ts` are two
|
|
187
|
+
complete, working examples — same `runAgent` loop, entirely different
|
|
188
|
+
personas and tools. `agents/customer-service/` shows the folder form fully
|
|
189
|
+
filled in — everything specific to one agent lives under its own folder,
|
|
190
|
+
not scattered across separate top-level trees connected only by a
|
|
191
|
+
matching name (nothing enforces that staying in sync):
|
|
157
192
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
193
|
+
```
|
|
194
|
+
agents/customer-service/
|
|
195
|
+
index.ts AgentConfig assembly + tenant/session resolution (tenantFor, sessionIdFor)
|
|
196
|
+
actauth.yml ActAuth rules
|
|
197
|
+
tools/
|
|
198
|
+
index.ts Aggregates the tools below into AgentConfig.tools
|
|
199
|
+
lookup_order.ts One file per tool
|
|
200
|
+
get_shipment_details.ts
|
|
201
|
+
issue_refund.ts
|
|
202
|
+
send_email.ts
|
|
203
|
+
orders-store.ts Shared in-memory store the tools above read/write
|
|
204
|
+
skills/
|
|
205
|
+
firecrawl/SKILL.md One <skill-name>/SKILL.md per skill (see "Skills" below)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`agents/file-agent/` follows the same shape, minus `orders-store.ts` —
|
|
209
|
+
its one difference is a Composio-sourced GitHub tool, which isn't in
|
|
210
|
+
`tools/` at all since it's fetched dynamically at runtime (see "External
|
|
211
|
+
tool gateways" below), not static code.
|
|
212
|
+
|
|
213
|
+
### Smart defaults
|
|
214
|
+
|
|
215
|
+
`rules`, `tools`, and `skillsDirs` can all be omitted — each then defaults
|
|
216
|
+
to a conventional path under this agent's own folder, `agents/<name>/…`,
|
|
217
|
+
instead of something you write out:
|
|
218
|
+
|
|
219
|
+
| Field | Omitted, defaults to | Missing there means |
|
|
220
|
+
| --- | --- | --- |
|
|
221
|
+
| `rules` | `agents/<name>/actauth.yml` | empty ruleset, **deny** everything (no permission story was ever written — stricter than the inline-array form's `'ask'` default) |
|
|
222
|
+
| `tools` | import `agents/<name>/tools/index.{ts,js}`, use its exported `tools` | `[]` — same as an agent with no tools at all |
|
|
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.
|
|
162
269
|
|
|
163
270
|
## Retrieval (RAG)
|
|
164
271
|
|
|
@@ -206,7 +313,7 @@ import { connectComposioSource } from 'mcpplug'
|
|
|
206
313
|
|
|
207
314
|
// Resolved once, at module-eval time — not per runAgent() call. A gateway
|
|
208
315
|
// 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
|
|
316
|
+
// every turn; see agents/file-agent/index.ts for the working example this is
|
|
210
317
|
// drawn from.
|
|
211
318
|
const composioTools = await connectComposioSource('composio', {
|
|
212
319
|
slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
|
|
@@ -228,7 +335,7 @@ const config: AgentConfig = {
|
|
|
228
335
|
Same ActAuth gating, ToolLane scheduling, and ContextClip budget tracking
|
|
229
336
|
as any other tool call — `runAgent` has no idea one `execute` shells out
|
|
230
337
|
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
|
|
338
|
+
`agents/file-agent/index.ts` is a complete, working example, verified end-to-end
|
|
232
339
|
against a real linked GitHub account. `slugs` is explicit, not
|
|
233
340
|
auto-discovered — an agent should only see the Composio actions its own
|
|
234
341
|
config actually lists; find them with the `composio` CLI's
|
|
@@ -252,7 +359,7 @@ curl -X POST localhost:8787/agents/customer-service/messages \
|
|
|
252
359
|
```
|
|
253
360
|
|
|
254
361
|
`customerEmail` is specific to `customer-service` — it defines its own
|
|
255
|
-
`AgentConfig.sessionIdFor` (see "Sessions
|
|
362
|
+
`AgentConfig.sessionIdFor` (see "Sessions" below). Other
|
|
256
363
|
agents take a plain `sessionId` field instead — and it's optional: omit it
|
|
257
364
|
for a fresh, one-off session (a generated id, echoed back in the response
|
|
258
365
|
as `sessionId` so you can pass it in explicitly to continue that same
|
|
@@ -269,6 +376,18 @@ curl -X POST localhost:8787/agents/file-agent/messages \
|
|
|
269
376
|
anonymous session," since `customerEmail` is a real, meaningful identity
|
|
270
377
|
for that agent, not an arbitrary key.
|
|
271
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:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
curl -X POST localhost:8787/agents/customer-service/messages \
|
|
387
|
+
-H 'content-type: application/json' -H 'x-api-key: acme-trusted-key' \
|
|
388
|
+
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
389
|
+
```
|
|
390
|
+
|
|
272
391
|
**HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
|
|
273
392
|
loop step instead of a single response after the whole thing finishes —
|
|
274
393
|
a `session` event first (announcing whatever `sessionId` got used, generated
|
|
@@ -293,86 +412,101 @@ output tokens — no `ModelCall` implementation here (fake or
|
|
|
293
412
|
`createAnthropicModelCall`) streams partial text from the model itself, so
|
|
294
413
|
there's nothing at the token level to forward yet.
|
|
295
414
|
|
|
296
|
-
## Sessions
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
- **Non-blocking, debounced writes**
|
|
310
|
-
|
|
311
|
-
- **Interruption detection
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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/`,
|
|
322
442
|
locked in-process. Fine for local dev and the CLI.
|
|
323
|
-
- `RedisSessionStore` — same log shape
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
`
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
`
|
|
367
|
-
`
|
|
368
|
-
|
|
369
|
-
`
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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'`.
|
|
376
510
|
|
|
377
511
|
## Deployment
|
|
378
512
|
|
|
@@ -388,104 +522,111 @@ and any tool-specific secrets set as environment variables.
|
|
|
388
522
|
|
|
389
523
|
## Wiring a real model
|
|
390
524
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
525
|
+
`agents/file-agent/index.ts` and `agents/rag-agent.ts` still use a canned,
|
|
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.
|
|
395
529
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // reads ANTHROPIC_API_KEY from the env
|
|
400
|
-
|
|
401
|
-
const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
|
|
402
|
-
```
|
|
530
|
+
**The easy way** — declare `AgentConfig.model` and skip `createModelCall`
|
|
531
|
+
entirely; `discoverAgents` builds it for you:
|
|
403
532
|
|
|
404
533
|
```ts
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
|
|
534
|
+
export const config: AgentConfig = {
|
|
535
|
+
name: 'my-agent',
|
|
536
|
+
// ...
|
|
537
|
+
model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
|
|
538
|
+
}
|
|
412
539
|
```
|
|
413
540
|
|
|
414
|
-
|
|
415
|
-
|
|
541
|
+
| `provider` | env var read | `model` required? | example |
|
|
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:
|
|
416
559
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
// constructor silently accepting OPENAI_API_KEY instead.
|
|
421
|
-
const modelCall = createDeepSeekModelCall({ model: 'deepseek-chat' })
|
|
560
|
+
```ts
|
|
561
|
+
import { createAnthropicModelCall } from './model-calls/anthropic-model-call.js'
|
|
562
|
+
// createOpenAIModelCall / createDeepSeekModelCall work the same way
|
|
422
563
|
|
|
423
|
-
|
|
564
|
+
export function createModelCall(): ModelCall {
|
|
565
|
+
return createAnthropicModelCall({ model: 'claude-sonnet-5' })
|
|
566
|
+
}
|
|
424
567
|
```
|
|
425
568
|
|
|
426
|
-
|
|
427
|
-
change — `ModelCall` is the only seam a real API
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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.
|
|
569
|
+
Either way, nothing else in `run-agent.ts`, the adapters, or any
|
|
570
|
+
`AgentConfig` needs to change — `ModelCall` is the only seam a real API
|
|
571
|
+
call needs.
|
|
572
|
+
|
|
573
|
+
All three factories translate loopengine's `Message`/`ModelResponse` to
|
|
574
|
+
and from their provider's real request/response shape (verified against
|
|
575
|
+
the real SDKs with a stubbed `fetch`, tool calls included).
|
|
576
|
+
`model-calls/deepseek-model-call.ts` reuses `openai-model-call.ts`'s
|
|
577
|
+
translation directly rather than reimplementing it, since DeepSeek's
|
|
578
|
+
Chat Completions API is wire-compatible with OpenAI's.
|
|
459
579
|
|
|
460
580
|
## Skills
|
|
461
581
|
|
|
462
|
-
Agents can discover and invoke `SKILL.md` files
|
|
463
|
-
|
|
582
|
+
Agents can discover and invoke `SKILL.md` files, the same convention
|
|
583
|
+
production coding agents use: a short frontmatter index (`name`,
|
|
464
584
|
`description`) stays in context at all times, and the full body is loaded
|
|
465
585
|
only when the model actually invokes that skill — through a real `Skill`
|
|
466
586
|
tool schema (`{skill: string, args?: string}`) declared to the model
|
|
467
|
-
whenever
|
|
468
|
-
`skillsDirs` on an `AgentConfig` to enable it; omit it for agents that
|
|
469
|
-
don't need skills (`agents/rag-agent.ts` doesn't).
|
|
587
|
+
whenever any skills are actually found, not just handled after the fact.
|
|
470
588
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
discovery recursively walks
|
|
474
|
-
|
|
475
|
-
|
|
589
|
+
`AgentConfig.skillsDirs` (an array of directory paths) controls where
|
|
590
|
+
from — see "Smart defaults" above for what it defaults to when omitted.
|
|
591
|
+
`SkillGarden` discovery recursively walks every directory it's given with
|
|
592
|
+
**no per-agent filtering** — whatever `SKILL.md` files live under a
|
|
593
|
+
`skillsDirs` path, the agent sees all of them, which is the whole reason
|
|
594
|
+
the two kinds below live in different places.
|
|
595
|
+
|
|
596
|
+
**Agent-specific** — a skill only one agent needs, under that agent's own
|
|
597
|
+
folder, pointed at by only that agent's `skillsDirs`:
|
|
598
|
+
|
|
599
|
+
```
|
|
600
|
+
agents/customer-service/skills/
|
|
601
|
+
firecrawl/SKILL.md Only customer-service's skillsDirs points here
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Global** — a skill genuinely meant to be shared, at the repo-root
|
|
605
|
+
`skills/` (see `skills/README.md`), pointed at by every agent's
|
|
606
|
+
`skillsDirs` that wants it:
|
|
607
|
+
|
|
608
|
+
```
|
|
609
|
+
skills/
|
|
610
|
+
README.md Explains the convention; no shared skill exists yet
|
|
611
|
+
(some-shared-skill)/SKILL.md Would be seen by every agent pointed here
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Nothing lives in the global root today — every skill this repo ships is
|
|
615
|
+
agent-specific. Don't point an agent at the global root *and* its own
|
|
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.
|
|
476
619
|
|
|
477
620
|
To install one of SkillGarden's bundled skills (e.g. `firecrawl`) into an
|
|
478
|
-
agent
|
|
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:
|
|
479
624
|
|
|
480
625
|
```bash
|
|
481
626
|
npx skillgarden add firecrawl --agent file-agent
|
|
482
|
-
# -> Added file-agent:firecrawl ->
|
|
627
|
+
# -> Added file-agent:firecrawl -> agents/file-agent/skills/firecrawl/SKILL.md
|
|
483
628
|
```
|
|
484
629
|
|
|
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.
|
|
488
|
-
|
|
489
630
|
## Project layout
|
|
490
631
|
|
|
491
632
|
```
|
|
@@ -496,15 +637,27 @@ vector-index.ts Dependency-free embeddings + cosine-similarity searc
|
|
|
496
637
|
discover-agents.ts discoverAgents — scans a directory, builds {name -> {config, createModelCall}}
|
|
497
638
|
agent-registry.ts This repo's ~15-line wrapper: discoverAgents('agents/') + listAgents()/getEntry()
|
|
498
639
|
session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
|
|
499
|
-
anthropic-model-call.ts
|
|
500
|
-
openai-model-call.ts
|
|
501
|
-
deepseek-model-call.ts
|
|
502
|
-
agents/file-agent.ts Example agent: summarizes text files
|
|
503
|
-
agents/
|
|
504
|
-
agents/
|
|
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
|
|
505
652
|
adapters/cli.ts Channel adapter: command line
|
|
506
653
|
adapters/http.ts Channel adapter: HTTP API
|
|
507
|
-
|
|
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
|
|
508
656
|
examples/file-agent/ Sample input files + generated output for the file-agent demo
|
|
509
657
|
Dockerfile, docker-compose.yml Container build + local Redis for testing
|
|
510
658
|
```
|
|
659
|
+
|
|
660
|
+
Every agent's own tools/skills live under `agents/<name>/` — the root
|
|
661
|
+
`tools/`/`skills/` directories are reserved for something genuinely meant
|
|
662
|
+
to be shared across every agent that opts in, which doesn't exist in this
|
|
663
|
+
repo yet (see each directory's own `README.md`).
|