loopengine 0.0.10 → 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 CHANGED
@@ -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`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
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`/`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`/
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). See `index.ts` for the exact list.
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 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
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
- All demo agents use a **simulated** model call (no `ANTHROPIC_API_KEY` is
124
- wired up) — see "Wiring a real model" below.
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-agent.ts` are two complete, working
156
- examples — same `runAgent` loop, entirely different personas and tools.
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
- 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.
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 and multi-tenancy" below). Other
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 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/`,
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 (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.
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
- 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:
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
- ```ts
397
- import { createAnthropicModelCall } from './anthropic-model-call.js'
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
- import { createOpenAIModelCall } from './openai-model-call.js'
406
-
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' })
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
- ```ts
415
- import { createDeepSeekModelCall } from './deepseek-model-call.js'
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
- // 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' })
560
+ ```ts
561
+ import { createAnthropicModelCall } from './model-calls/anthropic-model-call.js'
562
+ // createOpenAIModelCall / createDeepSeekModelCall work the same way
422
563
 
423
- const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
564
+ export function createModelCall(): ModelCall {
565
+ return createAnthropicModelCall({ model: 'claude-sonnet-5' })
566
+ }
424
567
  ```
425
568
 
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.
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 (see `skills/`), the same
463
- convention production coding agents use: a short frontmatter index (`name`,
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 `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).
587
+ whenever any skills are actually found, not just handled after the fact.
470
588
 
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.
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's own subdirectory, use its `add` CLI:
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 -> skills/file-agent/firecrawl/SKILL.md
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 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
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
- skills/ SKILL.md files discoverable by agents, scoped per agent subdirectory
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`).