loopengine 0.0.1 → 0.0.3
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 +203 -78
- package/dist/agent-config.d.ts +10 -10
- package/dist/anthropic-model-call.js +30 -18
- package/dist/index.d.ts +4 -6
- package/dist/index.js +1 -2
- package/dist/openai-model-call.d.ts +14 -0
- package/dist/openai-model-call.js +101 -0
- package/dist/run-agent.d.ts +30 -1
- package/dist/run-agent.js +147 -43
- package/dist/session-store.d.ts +41 -8
- package/dist/session-store.js +113 -36
- package/package.json +6 -8
- package/dist/load-agent.d.ts +0 -8
- package/dist/load-agent.js +0 -27
- package/dist/mcp-tools.d.ts +0 -11
- package/dist/mcp-tools.js +0 -31
package/README.md
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# LoopEngine
|
|
2
|
+
|
|
3
|
+
[](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml)
|
|
2
4
|
|
|
3
5
|
A minimal framework for defining and running tool-using AI agents. Write an
|
|
4
6
|
agent as a plain config object — persona, tools, permission rules — and run
|
|
@@ -23,6 +25,13 @@ out, in `session-store.ts`, since it persists what `run-agent.ts` produces
|
|
|
23
25
|
rather than participating in the loop itself. Everything else in this repo
|
|
24
26
|
— agent definitions, channel adapters — is built on top of those.
|
|
25
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
|
+
|
|
26
35
|
## Using loopengine as a library
|
|
27
36
|
|
|
28
37
|
This repo is two things in one: a small library (the loop itself) and a
|
|
@@ -30,9 +39,16 @@ reference app built on top of it (demo agents, a CLI, an HTTP server). Only
|
|
|
30
39
|
the library half ships to npm:
|
|
31
40
|
|
|
32
41
|
```bash
|
|
33
|
-
npm install loopengine
|
|
42
|
+
npm install loopengine
|
|
34
43
|
```
|
|
35
44
|
|
|
45
|
+
`actauth`, `contextclip`, `reflowkit`, `toollane`, `skillgarden`, and the
|
|
46
|
+
rest are regular dependencies of `loopengine` itself — installing it alone
|
|
47
|
+
already pulls them into `node_modules`, fully resolvable for both imports
|
|
48
|
+
and TypeScript types. Install one of them directly only if you want to use
|
|
49
|
+
it standalone, outside `runAgent` (e.g. just `toollane`'s parallel/solo
|
|
50
|
+
scheduling in your own loop).
|
|
51
|
+
|
|
36
52
|
```ts
|
|
37
53
|
import { runAgent, type AgentConfig, type ToolDefinition } from 'loopengine'
|
|
38
54
|
|
|
@@ -57,10 +73,10 @@ console.log(result.text)
|
|
|
57
73
|
```
|
|
58
74
|
|
|
59
75
|
The full exported surface: `runAgent`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
|
|
60
|
-
`
|
|
61
|
-
`
|
|
62
|
-
`
|
|
63
|
-
|
|
76
|
+
`FileSessionStore`/`RedisSessionStore`/`createSessionStore`, `VectorIndex`/
|
|
77
|
+
`embed`/`cosineSimilarity` (for RAG — see below), and `createAnthropicModelCall`/
|
|
78
|
+
`createOpenAIModelCall` (real, ready-to-use `ModelCall`s). See `index.ts` for
|
|
79
|
+
the exact list.
|
|
64
80
|
|
|
65
81
|
**Not** part of the package: `agents/*.ts` (this repo's own demo agents),
|
|
66
82
|
`agent-registry.ts` (this repo's own name → config lookup table — build your
|
|
@@ -118,42 +134,11 @@ To make a new agent runnable through the adapters, add one line to
|
|
|
118
134
|
`agent-registry.ts` mapping its name to its `config` and a `createModelCall`
|
|
119
135
|
factory.
|
|
120
136
|
|
|
121
|
-
## Connecting MCP tools
|
|
122
|
-
|
|
123
|
-
A tool doesn't have to be hand-written — `AgentConfig.mcpServers` connects to
|
|
124
|
-
a real MCP server and turns everything it exposes into ordinary tools, with
|
|
125
|
-
the same ActAuth gating and ToolLane scheduling as `do_thing` above:
|
|
126
|
-
|
|
127
|
-
```ts
|
|
128
|
-
export const config: AgentConfig = {
|
|
129
|
-
name: 'my-mcp-agent',
|
|
130
|
-
systemPrompt: 'You are ...',
|
|
131
|
-
mcpServers: [
|
|
132
|
-
{ command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/some/dir'] },
|
|
133
|
-
],
|
|
134
|
-
rules: [
|
|
135
|
-
{ scopePattern: 'default/production/my-mcp-agent', tool: 'read_text_file', decision: 'allow' },
|
|
136
|
-
],
|
|
137
|
-
defaultDecision: 'ask', // any tool the server exposes that you didn't write a rule for
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
`agents/mcp-filesystem-agent.ts` is a complete example — it has zero hand-written
|
|
142
|
-
tools; every one comes from the official
|
|
143
|
-
`@modelcontextprotocol/server-filesystem`, spawned as a real subprocess.
|
|
144
|
-
`load-agent.ts` is what resolves `mcpServers` into real tools: it runs for
|
|
145
|
-
every agent (a no-op if `mcpServers` is absent), which is what makes this a
|
|
146
|
-
config change rather than a bespoke loader function per server — write rules
|
|
147
|
-
for the tool names you expect the server to expose (check its docs, or
|
|
148
|
-
connect once and call `listTools()` yourself), the same way you'd write
|
|
149
|
-
rules for a hand-written tool. Anything the server exposes that you didn't
|
|
150
|
-
write a rule for still safely falls through to `defaultDecision`.
|
|
151
|
-
|
|
152
137
|
## Retrieval (RAG)
|
|
153
138
|
|
|
154
139
|
RAG is just another tool — the model decides when to call it, `run-agent.ts`
|
|
155
140
|
doesn't know or care that "executing" this particular tool means a vector
|
|
156
|
-
search instead of a
|
|
141
|
+
search instead of a plain HTTP call:
|
|
157
142
|
|
|
158
143
|
```ts
|
|
159
144
|
const searchDocs: ToolDefinition = {
|
|
@@ -178,6 +163,51 @@ implementation, the package proves the pipeline" pattern as ContextClip's
|
|
|
178
163
|
ordinary tool result, subject to the same ActAuth gating, ToolLane
|
|
179
164
|
scheduling, and ContextClip budget tracking as any other tool call.
|
|
180
165
|
|
|
166
|
+
## External tool gateways
|
|
167
|
+
|
|
168
|
+
A hand-written tool needs an `execute` function you write yourself; an
|
|
169
|
+
external SaaS action (list GitHub repos, send a Slack message) needs
|
|
170
|
+
someone to own that vendor's auth. Connecting straight to a vendor's own
|
|
171
|
+
MCP server means owning its OAuth flow (authorization code, PKCE, token
|
|
172
|
+
storage/refresh) yourself, repeated per vendor. Routing through a gateway
|
|
173
|
+
like [Composio](https://composio.dev) avoids that — it already holds the
|
|
174
|
+
OAuth relationship with 1000+ apps — and [`mcpplug`](https://www.npmjs.com/package/mcpplug)
|
|
175
|
+
wraps that gateway behind the same `ToolDefinition` shape as everything
|
|
176
|
+
else:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { connectComposioSource } from 'mcpplug'
|
|
180
|
+
|
|
181
|
+
// Resolved once, at module-eval time — not per runAgent() call. A gateway
|
|
182
|
+
// connection is exactly the kind of setup cost runAgent() shouldn't pay on
|
|
183
|
+
// every turn; see agents/file-agent.ts for the working example this is
|
|
184
|
+
// drawn from.
|
|
185
|
+
const composioTools = await connectComposioSource('composio', {
|
|
186
|
+
slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
|
|
187
|
+
}).then((source) => source.loadTools())
|
|
188
|
+
|
|
189
|
+
const config: AgentConfig = {
|
|
190
|
+
name: 'my-agent',
|
|
191
|
+
systemPrompt: 'You are ...',
|
|
192
|
+
tools: [...handWrittenTools, ...composioTools],
|
|
193
|
+
rules: [
|
|
194
|
+
// mcpplug namespaces each tool `${sourceName}_${slug}` — falls
|
|
195
|
+
// through to defaultDecision unless a rule names it explicitly.
|
|
196
|
+
{ scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' },
|
|
197
|
+
],
|
|
198
|
+
defaultDecision: 'ask',
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Same ActAuth gating, ToolLane scheduling, and ContextClip budget tracking
|
|
203
|
+
as any other tool call — `runAgent` has no idea one `execute` shells out
|
|
204
|
+
through Composio instead of touching the filesystem or a vector index.
|
|
205
|
+
`agents/file-agent.ts` is a complete, working example, verified end-to-end
|
|
206
|
+
against a real linked GitHub account. `slugs` is explicit, not
|
|
207
|
+
auto-discovered — an agent should only see the Composio actions its own
|
|
208
|
+
config actually lists; find them with the `composio` CLI's
|
|
209
|
+
`composio tools list <toolkit>` or `composio search "<use case>"`.
|
|
210
|
+
|
|
181
211
|
## Running an agent
|
|
182
212
|
|
|
183
213
|
**CLI:**
|
|
@@ -195,10 +225,29 @@ curl -X POST localhost:8787/agents/customer-service/messages \
|
|
|
195
225
|
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
196
226
|
```
|
|
197
227
|
|
|
228
|
+
`customerEmail` is specific to `customer-service` — it defines its own
|
|
229
|
+
`AgentConfig.sessionIdFor` (see "Sessions and multi-tenancy" below). Other
|
|
230
|
+
agents take a plain `sessionId` field instead — and it's optional: omit it
|
|
231
|
+
for a fresh, one-off session (a generated id, echoed back in the response
|
|
232
|
+
as `sessionId` so you can pass it in explicitly to continue that same
|
|
233
|
+
conversation on a later request):
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
curl -X POST localhost:8787/agents/file-agent/messages \
|
|
237
|
+
-H 'content-type: application/json' \
|
|
238
|
+
-d '{"sessionId":"s1","message":"Summarize a.txt and b.txt into summary.txt."}'
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`customer-service` is the exception here too — a request with no
|
|
242
|
+
`customerEmail` is a genuine validation error (`400`), not "start me an
|
|
243
|
+
anonymous session," since `customerEmail` is a real, meaningful identity
|
|
244
|
+
for that agent, not an arbitrary key.
|
|
245
|
+
|
|
198
246
|
**HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
|
|
199
247
|
loop step instead of a single response after the whole thing finishes —
|
|
200
|
-
|
|
201
|
-
|
|
248
|
+
a `session` event first (announcing whatever `sessionId` got used, generated
|
|
249
|
+
or not), then `contextclip:check`, `actauth:decision`, `toollane:result`,
|
|
250
|
+
..., a final `done` with the answer:
|
|
202
251
|
|
|
203
252
|
```bash
|
|
204
253
|
curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
|
|
@@ -233,13 +282,13 @@ rewrite can't:
|
|
|
233
282
|
|
|
234
283
|
- **Non-blocking, debounced writes** instead of a full read-modify-write of
|
|
235
284
|
the entire session every turn.
|
|
236
|
-
- **Interruption detection.** `run-agent.ts` pushes
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
dies in that window, the next `resume()` sees
|
|
240
|
-
|
|
241
|
-
note into context saying so
|
|
242
|
-
incomplete turn as if it were clean.
|
|
285
|
+
- **Interruption detection.** `run-agent.ts` pushes the assistant's full
|
|
286
|
+
response — tool_use blocks included — as one message before any tool
|
|
287
|
+
runs, then one message bundling every tool_result once they've all
|
|
288
|
+
settled. If the process dies in that window, the next `resume()` sees an
|
|
289
|
+
assistant message with an unanswered tool_use block, flags the session
|
|
290
|
+
as resumed-after-interruption, and injects a note into context saying so
|
|
291
|
+
— instead of silently resending an incomplete turn as if it were clean.
|
|
243
292
|
|
|
244
293
|
Two implementations, same `SessionStore` interface:
|
|
245
294
|
|
|
@@ -247,19 +296,57 @@ Two implementations, same `SessionStore` interface:
|
|
|
247
296
|
locked in-process. Fine for local dev and the CLI.
|
|
248
297
|
- `RedisSessionStore` — same log shape (a Redis list of entries, one
|
|
249
298
|
`RPUSH` per append), with a real distributed lock, safe across multiple
|
|
250
|
-
server instances.
|
|
299
|
+
server instances. The lock renews itself (`lockTtlMs / 3` by default)
|
|
300
|
+
for as long as a turn runs, instead of a flat TTL that a long turn (a
|
|
301
|
+
real model call plus several tool round-trips) could outlast — if
|
|
302
|
+
renewal ever confirms the lock was lost anyway (not just a failed
|
|
303
|
+
renewal attempt — a network blip there isn't proof of loss), the turn's
|
|
304
|
+
result is rejected rather than silently returned as if mutual exclusion
|
|
305
|
+
held the whole time.
|
|
251
306
|
|
|
252
307
|
`createSessionStore()` picks between them based on `REDIS_URL` — set it and
|
|
253
308
|
you get Redis, otherwise it falls back to the file store. Either way,
|
|
254
309
|
turn-level exclusivity (no two concurrent turns for the same session
|
|
255
310
|
interleaving) is still this module's job, not SessionKnit's — SessionKnit's
|
|
256
|
-
own topology repair
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
311
|
+
own topology repair (reattaching sibling branches under a shared
|
|
312
|
+
`parentId`) is a defensive read-side repair for crash/corruption recovery,
|
|
313
|
+
not something normal operation exercises. `run-agent.ts` bundles a whole
|
|
314
|
+
turn's `tool_use`/`tool_result` blocks into one message each, and
|
|
315
|
+
`withSession` appends new messages sequentially, so even a turn with
|
|
316
|
+
several parallel tool calls only ever produces a linear chain in the
|
|
317
|
+
durable log — not races between two full concurrent turns, which is what
|
|
318
|
+
the lock above actually prevents.
|
|
319
|
+
|
|
320
|
+
Session-key derivation is `AgentConfig.sessionIdFor(body)` — deliberately
|
|
321
|
+
not the HTTP adapter's call, since what counts as "one conversation" is
|
|
322
|
+
business logic specific to what the agent is for, not a transport concern.
|
|
323
|
+
`customer-service-agent.ts` defines its own, hashing `customerEmail` so raw
|
|
324
|
+
addresses never end up in storage keys — different customers can never
|
|
261
325
|
read or write each other's history, and concurrent messages from the same
|
|
262
|
-
customer are serialized rather than dropped.
|
|
326
|
+
customer are serialized rather than dropped. A missing `customerEmail` is a
|
|
327
|
+
real validation failure for that agent (`400`), not something to paper over.
|
|
328
|
+
|
|
329
|
+
An agent that doesn't define `sessionIdFor` falls back to a plain
|
|
330
|
+
client-supplied `sessionId` field (`adapters/http.ts`'s
|
|
331
|
+
`defaultSessionIdFor`) — the same shape `adapters/cli.ts`'s `--session`
|
|
332
|
+
flag already uses — and if that's missing too, one gets generated rather
|
|
333
|
+
than the request being rejected, mirroring what omitting `--session` now
|
|
334
|
+
does for the CLI: a fresh, isolated, one-off session rather than an error
|
|
335
|
+
or (worse) a shared bucket every untagged caller collides into. The
|
|
336
|
+
generated id comes back in the response (`sessionId` in the JSON body, a
|
|
337
|
+
`session` SSE event first for the streamed route) so a caller that wants to
|
|
338
|
+
continue that exact conversation can pass it in explicitly next time.
|
|
339
|
+
|
|
340
|
+
`SessionStore` itself is agent-agnostic — nothing in `session-store.ts` or
|
|
341
|
+
`sessionknit` knows which agent is calling `withSession`, so both adapters
|
|
342
|
+
namespace the *actual* key by agent name before it ever reaches
|
|
343
|
+
`SessionStore`: `` `${agentName}:${sessionIdFor(...)}` `` in
|
|
344
|
+
`adapters/http.ts`, `` `${agent}:${session}` `` in `adapters/cli.ts`.
|
|
345
|
+
Without that, two different agents given the same session identifier (a
|
|
346
|
+
client reusing one `sessionId`/`--session` value across agents, most
|
|
347
|
+
likely) would read and write the exact same underlying log — one agent's
|
|
348
|
+
tool calls and results spliced straight into another's conversation as if
|
|
349
|
+
it had always been there.
|
|
263
350
|
|
|
264
351
|
## Deployment
|
|
265
352
|
|
|
@@ -276,11 +363,9 @@ and any tool-specific secrets set as environment variables.
|
|
|
276
363
|
## Wiring a real model
|
|
277
364
|
|
|
278
365
|
Every example agent (`agents/file-agent.ts`, `agents/customer-service-agent.ts`,
|
|
279
|
-
`agents/
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
for `createAnthropicModelCall` (`anthropic-model-call.ts`), the one real
|
|
283
|
-
`ModelCall` implementation this repo ships:
|
|
366
|
+
`agents/rag-agent.ts`) still uses a canned, turn-counting `ModelCall` so the
|
|
367
|
+
whole loop is runnable and testable with no API key. To go live, swap it for
|
|
368
|
+
one of the two real `ModelCall` implementations this repo ships:
|
|
284
369
|
|
|
285
370
|
```ts
|
|
286
371
|
import { createAnthropicModelCall } from './anthropic-model-call.js'
|
|
@@ -290,27 +375,69 @@ const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // read
|
|
|
290
375
|
const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
|
|
291
376
|
```
|
|
292
377
|
|
|
378
|
+
```ts
|
|
379
|
+
import { createOpenAIModelCall } from './openai-model-call.js'
|
|
380
|
+
|
|
381
|
+
// model is required (no built-in default) — OpenAI's current flagship
|
|
382
|
+
// name changes too often to hardcode safely; reads OPENAI_API_KEY from the env
|
|
383
|
+
const modelCall = createOpenAIModelCall({ model: 'gpt-4.1' })
|
|
384
|
+
|
|
385
|
+
const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
|
|
386
|
+
```
|
|
387
|
+
|
|
293
388
|
Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
|
|
294
389
|
change — `ModelCall` is the only seam a real API call needs.
|
|
295
390
|
|
|
296
|
-
|
|
297
|
-
is
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
`
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
391
|
+
loopengine's own `Message` type carries real block-native history: `content`
|
|
392
|
+
is either a plain string or a `ModelContentBlock[]` — the same shape
|
|
393
|
+
`ModelResponse.content` already used — so a model's `tool_use` requests and
|
|
394
|
+
this loop's `tool_result` replies round-trip with real per-call identity
|
|
395
|
+
(`tool_use_id`) rather than being flattened into prose. That's what makes
|
|
396
|
+
parallel tool calls unambiguous (a result links back to the exact call that
|
|
397
|
+
requested it, not just "a call to this tool name") and lets a denied or
|
|
398
|
+
failed call carry `is_error: true`, which Claude is specifically trained to
|
|
399
|
+
react to. `anthropic-model-call.ts` translates directly to and from
|
|
400
|
+
Anthropic's native `TextBlockParam`/`ToolUseBlockParam`/`ToolResultBlockParam`
|
|
401
|
+
types — no flattening step in between. This was verified against the real
|
|
402
|
+
SDK (with a stubbed `fetch`, not a live call) — request shape, tool schemas,
|
|
403
|
+
and response mapping all round-trip correctly end to end.
|
|
404
|
+
|
|
405
|
+
`openai-model-call.ts` translates to and from OpenAI's Chat Completions
|
|
406
|
+
shape instead, verified the same way (stubbed `fetch`, real SDK, full
|
|
407
|
+
`runAgent` round trip including a tool call). The one structural mismatch:
|
|
408
|
+
loopengine bundles a whole turn's `tool_result` blocks into a single
|
|
409
|
+
user-role message (mirroring Anthropic's shape); OpenAI has no equivalent —
|
|
410
|
+
each becomes its own top-level `role: 'tool'` message, so one loopengine
|
|
411
|
+
`Message` can expand into several OpenAI messages, never the reverse.
|
|
305
412
|
|
|
306
413
|
## Skills
|
|
307
414
|
|
|
308
415
|
Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
|
|
309
416
|
convention production coding agents use: a short frontmatter index (`name`,
|
|
310
417
|
`description`) stays in context at all times, and the full body is loaded
|
|
311
|
-
only when the model actually invokes that skill
|
|
312
|
-
|
|
313
|
-
|
|
418
|
+
only when the model actually invokes that skill — through a real `Skill`
|
|
419
|
+
tool schema (`{skill: string, args?: string}`) declared to the model
|
|
420
|
+
whenever `skillsDirs` is set, not just handled after the fact. Set
|
|
421
|
+
`skillsDirs` on an `AgentConfig` to enable it; omit it for agents that
|
|
422
|
+
don't need skills (`agents/rag-agent.ts` doesn't).
|
|
423
|
+
|
|
424
|
+
Point `skillsDirs` at a subdirectory scoped to that agent
|
|
425
|
+
(`skills/file-agent/`, not the shared `skills/` root) — `SkillGarden`'s
|
|
426
|
+
discovery recursively walks whatever root it's given with no per-agent
|
|
427
|
+
filtering, so two agents both pointed at `skills/` would each see every
|
|
428
|
+
skill under it, not just their own.
|
|
429
|
+
|
|
430
|
+
To install one of SkillGarden's bundled skills (e.g. `firecrawl`) into an
|
|
431
|
+
agent's own subdirectory, use its `add` CLI:
|
|
432
|
+
|
|
433
|
+
```bash
|
|
434
|
+
npx skillgarden add firecrawl --agent file-agent
|
|
435
|
+
# -> Added file-agent:firecrawl -> skills/file-agent/firecrawl/SKILL.md
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
That writes `skills/file-agent/firecrawl/SKILL.md`, which discovery then
|
|
439
|
+
surfaces as `file-agent:firecrawl` — no code change needed beyond having
|
|
440
|
+
already set `skillsDirs: 'skills/file-agent'` on that agent's config.
|
|
314
441
|
|
|
315
442
|
## Project layout
|
|
316
443
|
|
|
@@ -318,19 +445,17 @@ only when the model actually invokes that skill. Set `skillsDirs` on an
|
|
|
318
445
|
index.ts Public API surface — what actually ships to npm
|
|
319
446
|
agent-config.ts AgentConfig type — the thing you fill in to define an agent
|
|
320
447
|
run-agent.ts The generic ReAct loop every agent and adapter runs through
|
|
321
|
-
load-agent.ts Resolves AgentConfig.mcpServers into real tools (no-op without it)
|
|
322
|
-
mcp-tools.ts Wraps one MCP server's tools as ToolDefinition[]
|
|
323
448
|
vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
|
|
324
|
-
agent-registry.ts Maps agent name -> {config, createModelCall}
|
|
449
|
+
agent-registry.ts Maps agent name -> {config, createModelCall}
|
|
325
450
|
session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
|
|
326
|
-
anthropic-model-call.ts createAnthropicModelCall —
|
|
451
|
+
anthropic-model-call.ts createAnthropicModelCall — a real ModelCall this repo ships
|
|
452
|
+
openai-model-call.ts createOpenAIModelCall — same seam, OpenAI's Chat Completions API
|
|
327
453
|
agents/file-agent.ts Example agent: summarizes text files
|
|
328
|
-
agents/customer-service-agent.ts Example agent: order lookup
|
|
329
|
-
agents/mcp-filesystem-agent.ts Example agent: every tool comes from a real MCP server, zero hand-written
|
|
454
|
+
agents/customer-service-agent.ts Example agent: order/shipment lookup, refund, email
|
|
330
455
|
agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
|
|
331
456
|
adapters/cli.ts Channel adapter: command line
|
|
332
457
|
adapters/http.ts Channel adapter: HTTP API
|
|
333
|
-
skills/ SKILL.md files discoverable by agents
|
|
458
|
+
skills/ SKILL.md files discoverable by agents, scoped per agent subdirectory
|
|
334
459
|
examples/file-agent/ Sample input files + generated output for the file-agent demo
|
|
335
460
|
Dockerfile, docker-compose.yml Container build + local Redis for testing
|
|
336
461
|
```
|
package/dist/agent-config.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Rule, Decision, Scope, Approver } from 'actauth';
|
|
2
2
|
import type { SafetyClassifier } from 'toollane';
|
|
3
|
-
import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
3
|
export interface ToolSchema {
|
|
5
4
|
name: string;
|
|
6
5
|
description: string;
|
|
@@ -14,16 +13,8 @@ export interface AgentConfig {
|
|
|
14
13
|
/** Also doubles as the ActAuth scope.agent segment. */
|
|
15
14
|
name: string;
|
|
16
15
|
systemPrompt: string;
|
|
17
|
-
/** Hand-written tools. Default []
|
|
16
|
+
/** Hand-written tools. Default []. */
|
|
18
17
|
tools?: ToolDefinition[];
|
|
19
|
-
/** MCP servers to connect at load time (see load-agent.ts); their
|
|
20
|
-
* tools are discovered via listTools() and appended to `tools`. This
|
|
21
|
-
* is what makes adding an MCP-backed agent a config change — write
|
|
22
|
-
* rules for the tool names you expect it to expose (check its docs, or
|
|
23
|
-
* probe it once), same as for a hand-written tool. Anything it exposes
|
|
24
|
-
* that you didn't write a rule for still safely falls through to
|
|
25
|
-
* defaultDecision. */
|
|
26
|
-
mcpServers?: StdioServerParameters[];
|
|
27
18
|
/** ActAuth rules, e.g. { scopePattern: 'default/production/customer-service', tool: 'issue_refund', decision: 'ask' } */
|
|
28
19
|
rules: Rule[];
|
|
29
20
|
/** Decision when no rule matches. Default 'ask' — new tools are opt-in, not silently allowed. */
|
|
@@ -37,4 +28,13 @@ export interface AgentConfig {
|
|
|
37
28
|
contextBudgetTokens?: number;
|
|
38
29
|
/** Which tools ToolLane may run in a parallel lane. Default: none (every tool runs solo). */
|
|
39
30
|
isSafeTool?: SafetyClassifier;
|
|
31
|
+
/** How adapters/http.ts derives a session key from a request body — this
|
|
32
|
+
* is business logic ("what counts as one conversation" is agent-
|
|
33
|
+
* specific: a customer, a Slack channel, a ticket, ...), not a channel-
|
|
34
|
+
* adapter concern, so it lives here rather than being hardcoded in the
|
|
35
|
+
* adapter. Return undefined to signal "this body doesn't identify a
|
|
36
|
+
* session" (the adapter responds 400). Omit entirely to use the
|
|
37
|
+
* adapter's default — a client-supplied `sessionId` field, the same
|
|
38
|
+
* shape adapters/cli.ts's --session flag already uses. */
|
|
39
|
+
sessionIdFor?: (body: Record<string, unknown>) => string | undefined;
|
|
40
40
|
}
|
|
@@ -1,20 +1,35 @@
|
|
|
1
1
|
// The one real ModelCall implementation loopengine ships. Every example
|
|
2
|
-
// agent (file-agent.ts, customer-service-agent.ts,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// agent (file-agent.ts, customer-service-agent.ts, rag-agent.ts) still
|
|
3
|
+
// uses a canned, turn-counting fake so the whole loop is runnable with no
|
|
4
|
+
// API key — swap that fake for createAnthropicModelCall(...) and nothing
|
|
5
|
+
// in run-agent.ts, the adapters, or any AgentConfig changes. ModelCall is
|
|
6
|
+
// the only seam this needed.
|
|
7
7
|
//
|
|
8
|
-
// loopengine's Message type
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// tool_use
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// block-native history the API is built around. If you need that
|
|
15
|
-
// fidelity later, it means changing what run-agent.ts stores in
|
|
16
|
-
// `messages`, not this file.
|
|
8
|
+
// loopengine's own Message type mirrors Anthropic's native shape closely
|
|
9
|
+
// on purpose: `content` is either a plain string or a ModelContentBlock[]
|
|
10
|
+
// carrying real tool_use/tool_result blocks with ids, the same identity
|
|
11
|
+
// Anthropic's own API expects a tool_use to be answered by a matching
|
|
12
|
+
// tool_result. This file's only job is translating between that shape and
|
|
13
|
+
// the SDK's own param/response types — see message-to-param below.
|
|
17
14
|
import Anthropic from '@anthropic-ai/sdk';
|
|
15
|
+
function toContentBlockParam(block) {
|
|
16
|
+
if (block.type === 'tool_use') {
|
|
17
|
+
return { type: 'tool_use', id: block.id, name: block.name, input: block.input ?? {} };
|
|
18
|
+
}
|
|
19
|
+
if (block.type === 'tool_result') {
|
|
20
|
+
return { type: 'tool_result', tool_use_id: block.tool_use_id, content: block.content, is_error: block.is_error };
|
|
21
|
+
}
|
|
22
|
+
// text (and anything else this file doesn't specifically construct —
|
|
23
|
+
// in practice only text/tool_use/tool_result ever end up in a Message
|
|
24
|
+
// this framework builds).
|
|
25
|
+
return { type: 'text', text: block.text ?? '' };
|
|
26
|
+
}
|
|
27
|
+
function toMessageParam(message) {
|
|
28
|
+
return {
|
|
29
|
+
role: message.role === 'assistant' ? 'assistant' : 'user',
|
|
30
|
+
content: typeof message.content === 'string' ? message.content : message.content.map(toContentBlockParam),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
18
33
|
export function createAnthropicModelCall(options = {}) {
|
|
19
34
|
const client = options.client ?? new Anthropic({ apiKey: options.apiKey });
|
|
20
35
|
const model = options.model ?? 'claude-sonnet-5';
|
|
@@ -24,10 +39,7 @@ export function createAnthropicModelCall(options = {}) {
|
|
|
24
39
|
model,
|
|
25
40
|
max_tokens: maxTokens,
|
|
26
41
|
system,
|
|
27
|
-
messages: messages.map(
|
|
28
|
-
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
29
|
-
content: m.content,
|
|
30
|
-
})),
|
|
42
|
+
messages: messages.map(toMessageParam),
|
|
31
43
|
tools: tools.length
|
|
32
44
|
? tools.map((t) => ({
|
|
33
45
|
name: t.name,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
export { runAgent } from './run-agent.js';
|
|
2
|
-
export type { ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
|
|
2
|
+
export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
|
|
3
3
|
export type { AgentConfig, ToolSchema, ToolDefinition } from './agent-config.js';
|
|
4
|
-
export { loadAgent } from './load-agent.js';
|
|
5
|
-
export type { LoadedAgent } from './load-agent.js';
|
|
6
|
-
export { connectMcpTools } from './mcp-tools.js';
|
|
7
|
-
export type { McpConnection } from './mcp-tools.js';
|
|
8
4
|
export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
|
|
9
|
-
export type { SessionStore, SessionResult } from './session-store.js';
|
|
5
|
+
export type { SessionStore, SessionResult, RedisSessionStoreOptions } from './session-store.js';
|
|
10
6
|
export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
|
|
11
7
|
export type { Document, ScoredDocument } from './vector-index.js';
|
|
12
8
|
export { createAnthropicModelCall } from './anthropic-model-call.js';
|
|
13
9
|
export type { AnthropicModelCallOptions } from './anthropic-model-call.js';
|
|
10
|
+
export { createOpenAIModelCall } from './openai-model-call.js';
|
|
11
|
+
export type { OpenAIModelCallOptions } from './openai-model-call.js';
|
package/dist/index.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// built on top of this surface, not part of it — see the README section
|
|
5
5
|
// "Using loopengine as a library" for the boundary and why.
|
|
6
6
|
export { runAgent } from './run-agent.js';
|
|
7
|
-
export { loadAgent } from './load-agent.js';
|
|
8
|
-
export { connectMcpTools } from './mcp-tools.js';
|
|
9
7
|
export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
|
|
10
8
|
export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
|
|
11
9
|
export { createAnthropicModelCall } from './anthropic-model-call.js';
|
|
10
|
+
export { createOpenAIModelCall } from './openai-model-call.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
import type { ModelCall } from './run-agent.js';
|
|
3
|
+
export interface OpenAIModelCallOptions {
|
|
4
|
+
/** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
/** No hardcoded default, unlike Anthropic's — OpenAI's current flagship
|
|
7
|
+
* model name changes too often to bake into this file safely. Pass the
|
|
8
|
+
* one you actually want. */
|
|
9
|
+
model: string;
|
|
10
|
+
maxTokens?: number;
|
|
11
|
+
/** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
|
|
12
|
+
client?: OpenAI;
|
|
13
|
+
}
|
|
14
|
+
export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A second real ModelCall implementation, alongside anthropic-model-call.ts
|
|
2
|
+
// — same seam, different provider. Swap createOpenAIModelCall(...) in for
|
|
3
|
+
// the canned fake in any agent and nothing in run-agent.ts, the adapters,
|
|
4
|
+
// or any AgentConfig changes.
|
|
5
|
+
//
|
|
6
|
+
// Structural mismatch worth knowing: loopengine's Message bundles every
|
|
7
|
+
// tool_result from one turn into a single user-role message's content
|
|
8
|
+
// array (mirroring Anthropic's own shape, which this framework's Message
|
|
9
|
+
// type was modeled on). OpenAI's Chat Completions API has no equivalent —
|
|
10
|
+
// each tool result is its own top-level message with role: 'tool'. So
|
|
11
|
+
// unlike Anthropic's 1:1 toMessageParam, toMessageParams here is plural: one
|
|
12
|
+
// loopengine Message can expand into several OpenAI messages, never fewer.
|
|
13
|
+
//
|
|
14
|
+
// stop_reason is normalized to the vocabulary anthropic-model-call.ts
|
|
15
|
+
// already established ('end_turn' / 'tool_use' / 'max_tokens') rather than
|
|
16
|
+
// passed through as OpenAI's own finish_reason strings — reflowkit's
|
|
17
|
+
// defaultIsTruncated checks a translated ModelResponse for stop_reason ===
|
|
18
|
+
// 'max_tokens', so a provider-agnostic caller wiring up onTruncated later
|
|
19
|
+
// gets the same behavior regardless of which ModelCall is plugged in.
|
|
20
|
+
import OpenAI from 'openai';
|
|
21
|
+
function toMessageParams(message) {
|
|
22
|
+
if (typeof message.content === 'string') {
|
|
23
|
+
return [{ role: message.role === 'assistant' ? 'assistant' : 'user', content: message.content }];
|
|
24
|
+
}
|
|
25
|
+
if (message.role === 'assistant') {
|
|
26
|
+
const text = message.content
|
|
27
|
+
.filter((block) => block.type === 'text')
|
|
28
|
+
.map((block) => block.text ?? '')
|
|
29
|
+
.join('');
|
|
30
|
+
const toolUseBlocks = message.content.filter((block) => block.type === 'tool_use');
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
role: 'assistant',
|
|
34
|
+
// Required unless tool_calls is set — an empty string here (no
|
|
35
|
+
// text, only tool calls) is rejected by the API, so this must be
|
|
36
|
+
// null, not ''.
|
|
37
|
+
content: text || null,
|
|
38
|
+
tool_calls: toolUseBlocks.length
|
|
39
|
+
? toolUseBlocks.map((block) => ({
|
|
40
|
+
id: block.id,
|
|
41
|
+
type: 'function',
|
|
42
|
+
function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
|
|
43
|
+
}))
|
|
44
|
+
: undefined,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
// Every non-assistant, block-structured Message this loop ever builds is
|
|
49
|
+
// one turn's tool_result blocks bundled together (see run-agent.ts's
|
|
50
|
+
// pushMessage calls) — expand each into its own `role: 'tool'` message.
|
|
51
|
+
return message.content.map((block) => ({
|
|
52
|
+
role: 'tool',
|
|
53
|
+
tool_call_id: block.tool_use_id,
|
|
54
|
+
content: block.content ?? '',
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function toStopReason(finishReason) {
|
|
58
|
+
if (finishReason === 'tool_calls' || finishReason === 'function_call')
|
|
59
|
+
return 'tool_use';
|
|
60
|
+
if (finishReason === 'length')
|
|
61
|
+
return 'max_tokens';
|
|
62
|
+
if (finishReason === 'stop')
|
|
63
|
+
return 'end_turn';
|
|
64
|
+
return finishReason;
|
|
65
|
+
}
|
|
66
|
+
export function createOpenAIModelCall(options) {
|
|
67
|
+
const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
|
|
68
|
+
const maxTokens = options.maxTokens ?? 4096;
|
|
69
|
+
return async (messages, system, tools) => {
|
|
70
|
+
const response = await client.chat.completions.create({
|
|
71
|
+
model: options.model,
|
|
72
|
+
max_completion_tokens: maxTokens,
|
|
73
|
+
messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
|
|
74
|
+
tools: tools.length
|
|
75
|
+
? tools.map((t) => ({
|
|
76
|
+
type: 'function',
|
|
77
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
78
|
+
}))
|
|
79
|
+
: undefined,
|
|
80
|
+
});
|
|
81
|
+
const choice = response.choices[0];
|
|
82
|
+
const content = [];
|
|
83
|
+
if (choice.message.content)
|
|
84
|
+
content.push({ type: 'text', text: choice.message.content });
|
|
85
|
+
for (const call of choice.message.tool_calls ?? []) {
|
|
86
|
+
// A 'custom' tool call is the freeform-text tool type OpenAI added
|
|
87
|
+
// alongside 'function' — this framework's ToolDefinition only ever
|
|
88
|
+
// declares 'function'-shaped tools, so a custom call can't have come
|
|
89
|
+
// from anything this loop offered the model.
|
|
90
|
+
if (call.type !== 'function')
|
|
91
|
+
continue;
|
|
92
|
+
content.push({
|
|
93
|
+
type: 'tool_use',
|
|
94
|
+
id: call.id,
|
|
95
|
+
name: call.function.name,
|
|
96
|
+
input: JSON.parse(call.function.arguments),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return { stop_reason: toStopReason(choice.finish_reason), content };
|
|
100
|
+
};
|
|
101
|
+
}
|