loopengine 0.0.1
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/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/agent-config.d.ts +40 -0
- package/dist/agent-config.js +1 -0
- package/dist/anthropic-model-call.d.ts +11 -0
- package/dist/anthropic-model-call.js +49 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +11 -0
- package/dist/load-agent.d.ts +8 -0
- package/dist/load-agent.js +27 -0
- package/dist/mcp-tools.d.ts +11 -0
- package/dist/mcp-tools.js +31 -0
- package/dist/run-agent.d.ts +28 -0
- package/dist/run-agent.js +100 -0
- package/dist/session-store.d.ts +52 -0
- package/dist/session-store.js +170 -0
- package/dist/vector-index.d.ts +25 -0
- package/dist/vector-index.js +72 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evan Yan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# loopengine
|
|
2
|
+
|
|
3
|
+
A minimal framework for defining and running tool-using AI agents. Write an
|
|
4
|
+
agent as a plain config object — persona, tools, permission rules — and run
|
|
5
|
+
it through one generic ReAct loop over the CLI, an HTTP API, or both. No
|
|
6
|
+
chain DSL, no hidden control flow: `run-agent.ts` is a single readable
|
|
7
|
+
function you can step through top to bottom.
|
|
8
|
+
|
|
9
|
+
It's built on six small, independently-published packages that each solve
|
|
10
|
+
one piece of the agent-loop problem:
|
|
11
|
+
|
|
12
|
+
| Package | Responsibility |
|
|
13
|
+
|---|---|
|
|
14
|
+
| [`actauth`](https://www.npmjs.com/package/actauth) | Rule-based permission gating (allow/ask/deny) with human-approval hooks |
|
|
15
|
+
| [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking with staged recovery |
|
|
16
|
+
| [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
|
|
17
|
+
| [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable, DAG-shaped session log with crash-interruption detection |
|
|
18
|
+
| [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md`-based skill discovery and lazy loading |
|
|
19
|
+
| [`toollane`](https://www.npmjs.com/package/toollane) | Schedules approved tool calls into parallel/solo execution lanes |
|
|
20
|
+
|
|
21
|
+
`run-agent.ts` wires the first five together — `sessionknit` sits one layer
|
|
22
|
+
out, in `session-store.ts`, since it persists what `run-agent.ts` produces
|
|
23
|
+
rather than participating in the loop itself. Everything else in this repo
|
|
24
|
+
— agent definitions, channel adapters — is built on top of those.
|
|
25
|
+
|
|
26
|
+
## Using loopengine as a library
|
|
27
|
+
|
|
28
|
+
This repo is two things in one: a small library (the loop itself) and a
|
|
29
|
+
reference app built on top of it (demo agents, a CLI, an HTTP server). Only
|
|
30
|
+
the library half ships to npm:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install loopengine actauth contextclip reflowkit toollane skillgarden
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { runAgent, type AgentConfig, type ToolDefinition } from 'loopengine'
|
|
38
|
+
|
|
39
|
+
const getWeather: ToolDefinition = {
|
|
40
|
+
name: 'get_weather',
|
|
41
|
+
description: 'Look up the weather for a city',
|
|
42
|
+
input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
|
|
43
|
+
execute: async (input) => `Sunny in ${input.city}`,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const config: AgentConfig = {
|
|
47
|
+
name: 'weather-agent',
|
|
48
|
+
systemPrompt: 'You answer questions about the weather.',
|
|
49
|
+
tools: [getWeather],
|
|
50
|
+
rules: [{ scopePattern: 'default/production/weather-agent', tool: 'get_weather', decision: 'allow' }],
|
|
51
|
+
defaultDecision: 'ask',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// bring your own ModelCall, or use the one below
|
|
55
|
+
const result = await runAgent(config, myModelCall, "What's the weather in Boston?", [])
|
|
56
|
+
console.log(result.text)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The full exported surface: `runAgent`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
|
|
60
|
+
`loadAgent` (for MCP-backed tools), `connectMcpTools`, `FileSessionStore`/
|
|
61
|
+
`RedisSessionStore`/`createSessionStore`, `VectorIndex`/`embed`/
|
|
62
|
+
`cosineSimilarity` (for RAG — see below), and `createAnthropicModelCall` (a
|
|
63
|
+
real, ready-to-use `ModelCall`). See `index.ts` for the exact list.
|
|
64
|
+
|
|
65
|
+
**Not** part of the package: `agents/*.ts` (this repo's own demo agents),
|
|
66
|
+
`agent-registry.ts` (this repo's own name → config lookup table — build your
|
|
67
|
+
own, same one-line-per-agent shape), `adapters/cli.ts`/`adapters/http.ts`
|
|
68
|
+
(one specific CLI/HTTP wiring choice, not imposed on every consumer), and
|
|
69
|
+
the `Dockerfile`. Everything below this point describes that reference app —
|
|
70
|
+
read it as a worked example of what to build with the library, not as
|
|
71
|
+
library API itself.
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm install
|
|
77
|
+
npx tsx agents/file-agent.ts # run the file-summarizer demo agent
|
|
78
|
+
npx tsx agents/customer-service-agent.ts # run the customer-service demo agent
|
|
79
|
+
npx tsx agents/rag-agent.ts # run the retrieval-augmented demo agent
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
All demo agents use a **simulated** model call (no `ANTHROPIC_API_KEY` is
|
|
83
|
+
wired up) — see "Wiring a real model" below.
|
|
84
|
+
|
|
85
|
+
## Defining your own agent
|
|
86
|
+
|
|
87
|
+
An agent is an `AgentConfig` (`agent-config.ts`): a name, a system prompt, a
|
|
88
|
+
list of tools, and ActAuth permission rules.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import type { AgentConfig } from './agent-config.js'
|
|
92
|
+
|
|
93
|
+
export const config: AgentConfig = {
|
|
94
|
+
name: 'my-agent',
|
|
95
|
+
systemPrompt: 'You are ...',
|
|
96
|
+
tools: [
|
|
97
|
+
{
|
|
98
|
+
name: 'do_thing',
|
|
99
|
+
description: 'What the model sees when deciding to call this',
|
|
100
|
+
input_schema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
101
|
+
execute: async (input) => {
|
|
102
|
+
// real implementation — DB call, HTTP request, whatever
|
|
103
|
+
return { ok: true }
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
rules: [
|
|
108
|
+
{ scopePattern: 'default/production/my-agent', tool: 'do_thing', decision: 'allow' },
|
|
109
|
+
],
|
|
110
|
+
defaultDecision: 'ask', // anything not covered by a rule requires approval
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`agents/file-agent.ts` and `agents/customer-service-agent.ts` are two complete, working
|
|
115
|
+
examples — same `runAgent` loop, entirely different personas and tools.
|
|
116
|
+
|
|
117
|
+
To make a new agent runnable through the adapters, add one line to
|
|
118
|
+
`agent-registry.ts` mapping its name to its `config` and a `createModelCall`
|
|
119
|
+
factory.
|
|
120
|
+
|
|
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
|
+
## Retrieval (RAG)
|
|
153
|
+
|
|
154
|
+
RAG is just another tool — the model decides when to call it, `run-agent.ts`
|
|
155
|
+
doesn't know or care that "executing" this particular tool means a vector
|
|
156
|
+
search instead of a filesystem read or an MCP round-trip:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const searchDocs: ToolDefinition = {
|
|
160
|
+
name: 'search_docs',
|
|
161
|
+
description: 'Search the knowledge base for relevant passages',
|
|
162
|
+
input_schema: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
|
|
163
|
+
execute: async (input) => {
|
|
164
|
+
const hits = index.search(input.query as string, 3) // top-3 chunks
|
|
165
|
+
return hits.map((h) => h.text).join('\n\n')
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`agents/rag-agent.ts` is a complete example, backed by `vector-index.ts` — a
|
|
171
|
+
small, dependency-free in-memory vector index (feature-hashing embeddings,
|
|
172
|
+
cosine-similarity search) good enough to prove the retrieval mechanics
|
|
173
|
+
end-to-end without wiring up a real embedding API. Swap `embed()` for a real
|
|
174
|
+
embedding model, or swap `VectorIndex` for a real vector DB client, and the
|
|
175
|
+
`search_docs` tool works unchanged — same "host provides the real
|
|
176
|
+
implementation, the package proves the pipeline" pattern as ContextClip's
|
|
177
|
+
`Summarizer` or ActAuth's `Approver`. The retrieved text comes back as an
|
|
178
|
+
ordinary tool result, subject to the same ActAuth gating, ToolLane
|
|
179
|
+
scheduling, and ContextClip budget tracking as any other tool call.
|
|
180
|
+
|
|
181
|
+
## Running an agent
|
|
182
|
+
|
|
183
|
+
**CLI:**
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arrived broken"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**HTTP:**
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
npx tsx adapters/http.ts
|
|
193
|
+
curl -X POST localhost:8787/agents/customer-service/messages \
|
|
194
|
+
-H 'content-type: application/json' \
|
|
195
|
+
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
|
|
199
|
+
loop step instead of a single response after the whole thing finishes —
|
|
200
|
+
`contextclip:check`, `actauth:decision`, `toollane:result`, ..., a final
|
|
201
|
+
`done` with the answer:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
|
|
205
|
+
-H 'content-type: application/json' \
|
|
206
|
+
-d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Both adapters are thin: they own request parsing and session persistence,
|
|
210
|
+
and call the same `runAgent(config, modelCall, message, history)` underneath.
|
|
211
|
+
`runAgent` itself does no I/O — callers own conversation history, which is
|
|
212
|
+
what lets it serve a one-shot CLI call and a long-lived chat session with no
|
|
213
|
+
code difference. The streaming route isn't a separate code path through
|
|
214
|
+
`runAgent` either — it forwards the same `onEvent` hook the non-streaming
|
|
215
|
+
route just discards, over SSE instead of into a log. What this streams is
|
|
216
|
+
loop *steps* (tool calls, permission decisions, budget checks), not model
|
|
217
|
+
output tokens — no `ModelCall` implementation here (fake or
|
|
218
|
+
`createAnthropicModelCall`) streams partial text from the model itself, so
|
|
219
|
+
there's nothing at the token level to forward yet.
|
|
220
|
+
|
|
221
|
+
## Sessions and multi-tenancy
|
|
222
|
+
|
|
223
|
+
`session-store.ts` exposes one method, `withSession(sessionId, fn)`, that
|
|
224
|
+
resumes history, runs `fn` **exclusively** for that session, and appends
|
|
225
|
+
whatever new messages `fn` produced — this is what stops two concurrent
|
|
226
|
+
requests for the same conversation from racing on read-modify-write.
|
|
227
|
+
|
|
228
|
+
History itself is a [`sessionknit`](https://www.npmjs.com/package/sessionknit)
|
|
229
|
+
log, not a flat file rewritten whole on every turn: each message is one
|
|
230
|
+
entry in an append-only, parent-linked chain, so a turn only ever appends,
|
|
231
|
+
never rewrites what's already durable. That buys two things a flat
|
|
232
|
+
rewrite can't:
|
|
233
|
+
|
|
234
|
+
- **Non-blocking, debounced writes** instead of a full read-modify-write of
|
|
235
|
+
the entire session every turn.
|
|
236
|
+
- **Interruption detection.** `run-agent.ts` pushes one
|
|
237
|
+
`[requested: tool_a, tool_b]` message before any tool runs, then one
|
|
238
|
+
`[... result]` message per completed tool as they finish. If the process
|
|
239
|
+
dies in that window, the next `resume()` sees a request with no result
|
|
240
|
+
behind it, flags the session as resumed-after-interruption, and injects a
|
|
241
|
+
note into context saying so — instead of silently resending an
|
|
242
|
+
incomplete turn as if it were clean.
|
|
243
|
+
|
|
244
|
+
Two implementations, same `SessionStore` interface:
|
|
245
|
+
|
|
246
|
+
- `FileSessionStore` — one JSONL entry log per session under `.sessions/`,
|
|
247
|
+
locked in-process. Fine for local dev and the CLI.
|
|
248
|
+
- `RedisSessionStore` — same log shape (a Redis list of entries, one
|
|
249
|
+
`RPUSH` per append), with a real distributed lock, safe across multiple
|
|
250
|
+
server instances.
|
|
251
|
+
|
|
252
|
+
`createSessionStore()` picks between them based on `REDIS_URL` — set it and
|
|
253
|
+
you get Redis, otherwise it falls back to the file store. Either way,
|
|
254
|
+
turn-level exclusivity (no two concurrent turns for the same session
|
|
255
|
+
interleaving) is still this module's job, not SessionKnit's — SessionKnit's
|
|
256
|
+
own topology repair handles branching *within* one resumed chain (parallel
|
|
257
|
+
tool calls, crash recovery), not races between two full concurrent turns.
|
|
258
|
+
|
|
259
|
+
The HTTP adapter derives the session key from `customerEmail` (hashed, so
|
|
260
|
+
raw emails never end up in storage keys), so different customers can never
|
|
261
|
+
read or write each other's history, and concurrent messages from the same
|
|
262
|
+
customer are serialized rather than dropped.
|
|
263
|
+
|
|
264
|
+
## Deployment
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
docker compose up --build
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
builds the HTTP adapter into a container (`Dockerfile`, multi-stage:
|
|
271
|
+
`npm ci` + `tsc`, then a slim runtime image) and starts it alongside a Redis
|
|
272
|
+
container (`docker-compose.yml`). For a real deployment, push the same
|
|
273
|
+
image to any container platform (Cloud Run, Fargate, Fly.io) with `REDIS_URL`
|
|
274
|
+
and any tool-specific secrets set as environment variables.
|
|
275
|
+
|
|
276
|
+
## Wiring a real model
|
|
277
|
+
|
|
278
|
+
Every example agent (`agents/file-agent.ts`, `agents/customer-service-agent.ts`,
|
|
279
|
+
`agents/mcp-filesystem-agent.ts`, `agents/rag-agent.ts`) still uses a canned,
|
|
280
|
+
turn-counting `ModelCall` so the whole loop is runnable and testable with no
|
|
281
|
+
API key. To go live, swap it
|
|
282
|
+
for `createAnthropicModelCall` (`anthropic-model-call.ts`), the one real
|
|
283
|
+
`ModelCall` implementation this repo ships:
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { createAnthropicModelCall } from './anthropic-model-call.js'
|
|
287
|
+
|
|
288
|
+
const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // reads ANTHROPIC_API_KEY from the env
|
|
289
|
+
|
|
290
|
+
const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
|
|
294
|
+
change — `ModelCall` is the only seam a real API call needs.
|
|
295
|
+
|
|
296
|
+
One tradeoff worth knowing: loopengine's `Message` type (from `contextclip`)
|
|
297
|
+
is deliberately generic — `{role, content: string}` — so conversation
|
|
298
|
+
history round-trips to the API as plain user/assistant text turns, not
|
|
299
|
+
Anthropic's native `tool_use`/`tool_result` content blocks. Claude reads
|
|
300
|
+
`"[lookup_order result] {...}"` as plain text just fine and the conversation
|
|
301
|
+
still works correctly, but it's not the structured, block-native history the
|
|
302
|
+
API is built around. This was verified against the real SDK (with a stubbed
|
|
303
|
+
`fetch`, not a live call) — request shape, tool schemas, and response
|
|
304
|
+
mapping all round-trip correctly end to end.
|
|
305
|
+
|
|
306
|
+
## Skills
|
|
307
|
+
|
|
308
|
+
Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
|
|
309
|
+
convention production coding agents use: a short frontmatter index (`name`,
|
|
310
|
+
`description`) stays in context at all times, and the full body is loaded
|
|
311
|
+
only when the model actually invokes that skill. Set `skillsDirs` on an
|
|
312
|
+
`AgentConfig` to enable it; omit it for agents that don't need skills
|
|
313
|
+
(`agents/customer-service-agent.ts` doesn't).
|
|
314
|
+
|
|
315
|
+
## Project layout
|
|
316
|
+
|
|
317
|
+
```
|
|
318
|
+
index.ts Public API surface — what actually ships to npm
|
|
319
|
+
agent-config.ts AgentConfig type — the thing you fill in to define an agent
|
|
320
|
+
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
|
+
vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
|
|
324
|
+
agent-registry.ts Maps agent name -> {config, createModelCall}, via load-agent.ts
|
|
325
|
+
session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
|
|
326
|
+
anthropic-model-call.ts createAnthropicModelCall — the one real ModelCall this repo ships
|
|
327
|
+
agents/file-agent.ts Example agent: summarizes text files
|
|
328
|
+
agents/customer-service-agent.ts Example agent: order lookup / refund / email
|
|
329
|
+
agents/mcp-filesystem-agent.ts Example agent: every tool comes from a real MCP server, zero hand-written
|
|
330
|
+
agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
|
|
331
|
+
adapters/cli.ts Channel adapter: command line
|
|
332
|
+
adapters/http.ts Channel adapter: HTTP API
|
|
333
|
+
skills/ SKILL.md files discoverable by agents
|
|
334
|
+
examples/file-agent/ Sample input files + generated output for the file-agent demo
|
|
335
|
+
Dockerfile, docker-compose.yml Container build + local Redis for testing
|
|
336
|
+
```
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Rule, Decision, Scope, Approver } from 'actauth';
|
|
2
|
+
import type { SafetyClassifier } from 'toollane';
|
|
3
|
+
import type { StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
export interface ToolSchema {
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
/** JSON schema sent to the model so it knows how to call the tool. */
|
|
8
|
+
input_schema: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolDefinition extends ToolSchema {
|
|
11
|
+
execute: (input: Record<string, unknown>) => Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface AgentConfig {
|
|
14
|
+
/** Also doubles as the ActAuth scope.agent segment. */
|
|
15
|
+
name: string;
|
|
16
|
+
systemPrompt: string;
|
|
17
|
+
/** Hand-written tools. Default [] — an agent can run on mcpServers alone. */
|
|
18
|
+
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
|
+
/** ActAuth rules, e.g. { scopePattern: 'default/production/customer-service', tool: 'issue_refund', decision: 'ask' } */
|
|
28
|
+
rules: Rule[];
|
|
29
|
+
/** Decision when no rule matches. Default 'ask' — new tools are opt-in, not silently allowed. */
|
|
30
|
+
defaultDecision?: Decision;
|
|
31
|
+
/** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
|
|
32
|
+
approver?: Approver;
|
|
33
|
+
scope?: Partial<Omit<Scope, 'agent'>>;
|
|
34
|
+
/** SKILL.md directories this agent can discover and invoke. Omit if the agent has no skills. */
|
|
35
|
+
skillsDirs?: string[];
|
|
36
|
+
skillIndexBudgetTokens?: number;
|
|
37
|
+
contextBudgetTokens?: number;
|
|
38
|
+
/** Which tools ToolLane may run in a parallel lane. Default: none (every tool runs solo). */
|
|
39
|
+
isSafeTool?: SafetyClassifier;
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import type { ModelCall } from './run-agent.js';
|
|
3
|
+
export interface AnthropicModelCallOptions {
|
|
4
|
+
/** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
model?: string;
|
|
7
|
+
maxTokens?: number;
|
|
8
|
+
/** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
|
|
9
|
+
client?: Anthropic;
|
|
10
|
+
}
|
|
11
|
+
export declare function createAnthropicModelCall(options?: AnthropicModelCallOptions): ModelCall;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The one real ModelCall implementation loopengine ships. Every example
|
|
2
|
+
// agent (file-agent.ts, customer-service-agent.ts, mcp-filesystem-agent.ts)
|
|
3
|
+
// still uses a canned, turn-counting fake so the whole loop is runnable
|
|
4
|
+
// with no API key — swap that fake for createAnthropicModelCall(...) and
|
|
5
|
+
// nothing in run-agent.ts, the adapters, or any AgentConfig changes.
|
|
6
|
+
// ModelCall is the only seam this needed.
|
|
7
|
+
//
|
|
8
|
+
// loopengine's Message type (contextclip) is deliberately generic —
|
|
9
|
+
// {role, content: string} — so conversation history round-trips to the
|
|
10
|
+
// API as plain user/assistant text turns, not Anthropic's native
|
|
11
|
+
// tool_use/tool_result content blocks. Claude reads
|
|
12
|
+
// "[lookup_order result] {...}" as plain text just fine and the
|
|
13
|
+
// conversation still works correctly, but it's not the structured,
|
|
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.
|
|
17
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
18
|
+
export function createAnthropicModelCall(options = {}) {
|
|
19
|
+
const client = options.client ?? new Anthropic({ apiKey: options.apiKey });
|
|
20
|
+
const model = options.model ?? 'claude-sonnet-5';
|
|
21
|
+
const maxTokens = options.maxTokens ?? 4096;
|
|
22
|
+
return async (messages, system, tools) => {
|
|
23
|
+
const response = await client.messages.create({
|
|
24
|
+
model,
|
|
25
|
+
max_tokens: maxTokens,
|
|
26
|
+
system,
|
|
27
|
+
messages: messages.map((m) => ({
|
|
28
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
29
|
+
content: m.content,
|
|
30
|
+
})),
|
|
31
|
+
tools: tools.length
|
|
32
|
+
? tools.map((t) => ({
|
|
33
|
+
name: t.name,
|
|
34
|
+
description: t.description,
|
|
35
|
+
input_schema: t.input_schema,
|
|
36
|
+
}))
|
|
37
|
+
: undefined,
|
|
38
|
+
});
|
|
39
|
+
const content = response.content.map((block) => {
|
|
40
|
+
if (block.type === 'text')
|
|
41
|
+
return { type: 'text', text: block.text };
|
|
42
|
+
if (block.type === 'tool_use') {
|
|
43
|
+
return { type: 'tool_use', id: block.id, name: block.name, input: block.input };
|
|
44
|
+
}
|
|
45
|
+
return { type: block.type };
|
|
46
|
+
});
|
|
47
|
+
return { stop_reason: response.stop_reason ?? 'end_turn', content };
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { runAgent } from './run-agent.js';
|
|
2
|
+
export type { ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
|
|
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
|
+
export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
|
|
9
|
+
export type { SessionStore, SessionResult } from './session-store.js';
|
|
10
|
+
export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
|
|
11
|
+
export type { Document, ScoredDocument } from './vector-index.js';
|
|
12
|
+
export { createAnthropicModelCall } from './anthropic-model-call.js';
|
|
13
|
+
export type { AnthropicModelCallOptions } from './anthropic-model-call.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The public API — everything a consumer building on loopengine needs to
|
|
2
|
+
// define and run their own agent. `agents/*.ts`, `agent-registry.ts`,
|
|
3
|
+
// `adapters/*.ts`, and the Dockerfile are this repo's own reference app
|
|
4
|
+
// built on top of this surface, not part of it — see the README section
|
|
5
|
+
// "Using loopengine as a library" for the boundary and why.
|
|
6
|
+
export { runAgent } from './run-agent.js';
|
|
7
|
+
export { loadAgent } from './load-agent.js';
|
|
8
|
+
export { connectMcpTools } from './mcp-tools.js';
|
|
9
|
+
export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
|
|
10
|
+
export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
|
|
11
|
+
export { createAnthropicModelCall } from './anthropic-model-call.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AgentConfig } from './agent-config.js';
|
|
2
|
+
export interface LoadedAgent {
|
|
3
|
+
/** Same config, with tools = hand-written tools + everything discovered from mcpServers. */
|
|
4
|
+
config: AgentConfig;
|
|
5
|
+
/** Closes every MCP connection this load opened. No-op if the config had no mcpServers. */
|
|
6
|
+
close: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function loadAgent(config: AgentConfig): Promise<LoadedAgent>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Resolves an AgentConfig's mcpServers (if any) into real tools. This is
|
|
2
|
+
// what makes MCP support config-driven instead of one bespoke async
|
|
3
|
+
// builder function per server: an agent that declares mcpServers and one
|
|
4
|
+
// that doesn't both go through loadAgent() the same way — it's a no-op
|
|
5
|
+
// passthrough for the latter. agent-registry.ts calls this for every
|
|
6
|
+
// agent, so adding a new MCP-backed agent means writing an AgentConfig,
|
|
7
|
+
// not a new .ts file with its own connection-handling logic.
|
|
8
|
+
import { connectMcpTools } from './mcp-tools.js';
|
|
9
|
+
export async function loadAgent(config) {
|
|
10
|
+
if (!config.mcpServers?.length) {
|
|
11
|
+
return { config: { ...config, tools: config.tools ?? [] }, close: async () => { } };
|
|
12
|
+
}
|
|
13
|
+
const connections = await Promise.all(config.mcpServers.map(connectMcpTools));
|
|
14
|
+
const mcpTools = connections.flatMap((c) => c.tools);
|
|
15
|
+
// Last-one-wins on name collisions (hand-written tool vs. MCP tool, or
|
|
16
|
+
// two MCP servers exposing the same name) — same resolution run-agent.ts
|
|
17
|
+
// itself uses when it builds its own tool map. Sending the model a
|
|
18
|
+
// duplicate tool name in its schema list would be a live API error, so
|
|
19
|
+
// this dedupes before it gets that far.
|
|
20
|
+
const byName = new Map([...(config.tools ?? []), ...mcpTools].map((t) => [t.name, t]));
|
|
21
|
+
return {
|
|
22
|
+
config: { ...config, tools: [...byName.values()] },
|
|
23
|
+
close: async () => {
|
|
24
|
+
await Promise.all(connections.map((c) => c.close()));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type StdioServerParameters } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
2
|
+
import type { ToolDefinition } from './agent-config.js';
|
|
3
|
+
export interface McpConnection {
|
|
4
|
+
tools: ToolDefinition[];
|
|
5
|
+
close: () => Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/** Spawns an MCP server over stdio, discovers its tools via listTools(),
|
|
8
|
+
* and wraps each one so execute() forwards to callTool() on the real
|
|
9
|
+
* server. Call close() when the agent is done with it (or on process
|
|
10
|
+
* shutdown) to kill the child process. */
|
|
11
|
+
export declare function connectMcpTools(serverParams: StdioServerParameters): Promise<McpConnection>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Wraps a real MCP server's tools as ToolDefinition[] — the same shape as
|
|
2
|
+
// any hand-written tool (agent-config.ts), so they drop straight into
|
|
3
|
+
// AgentConfig.tools. Neither run-agent.ts nor ActAuth nor ToolLane know or
|
|
4
|
+
// care that these particular tools proxy to a subprocess speaking MCP
|
|
5
|
+
// instead of running in-process; permission gating and execution still go
|
|
6
|
+
// through the exact same path as lookup_order or read_file.
|
|
7
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
8
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
9
|
+
/** Spawns an MCP server over stdio, discovers its tools via listTools(),
|
|
10
|
+
* and wraps each one so execute() forwards to callTool() on the real
|
|
11
|
+
* server. Call close() when the agent is done with it (or on process
|
|
12
|
+
* shutdown) to kill the child process. */
|
|
13
|
+
export async function connectMcpTools(serverParams) {
|
|
14
|
+
const client = new Client({ name: 'loopengine', version: '0.0.1' });
|
|
15
|
+
await client.connect(new StdioClientTransport(serverParams));
|
|
16
|
+
const { tools } = await client.listTools();
|
|
17
|
+
const toolDefs = tools.map((tool) => ({
|
|
18
|
+
name: tool.name,
|
|
19
|
+
description: tool.description ?? tool.name,
|
|
20
|
+
input_schema: tool.inputSchema,
|
|
21
|
+
execute: async (input) => {
|
|
22
|
+
const result = await client.callTool({ name: tool.name, arguments: input });
|
|
23
|
+
const text = result.content
|
|
24
|
+
.filter((block) => block.type === 'text')
|
|
25
|
+
.map((block) => block.text)
|
|
26
|
+
.join('\n');
|
|
27
|
+
return result.isError ? { error: text } : text;
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
return { tools: toolDefs, close: () => client.close() };
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Message } from 'contextclip';
|
|
2
|
+
import type { AgentConfig, ToolSchema } from './agent-config.js';
|
|
3
|
+
export interface ModelContentBlock {
|
|
4
|
+
type: string;
|
|
5
|
+
text?: string;
|
|
6
|
+
id?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
input?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export interface ModelResponse {
|
|
11
|
+
stop_reason: string;
|
|
12
|
+
content: ModelContentBlock[];
|
|
13
|
+
}
|
|
14
|
+
/** Swap the Anthropic SDK in here — this is the only seam a real API call needs. */
|
|
15
|
+
export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[]) => Promise<ModelResponse>;
|
|
16
|
+
export interface RunAgentOptions {
|
|
17
|
+
/** Emitted at each loop step for callers that want visibility (logging, UI, tests). Default: no-op. */
|
|
18
|
+
onEvent?: (event: string, detail: unknown) => void;
|
|
19
|
+
}
|
|
20
|
+
export interface RunAgentResult {
|
|
21
|
+
/** Final assistant text for this turn. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Full conversation so far, including this turn — pass back in as
|
|
24
|
+
* `history` on the next call to continue the conversation. Opaque to
|
|
25
|
+
* the caller: store/transmit it, don't inspect or mutate it. */
|
|
26
|
+
history: Message[];
|
|
27
|
+
}
|
|
28
|
+
export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The one ReAct loop every agent runs through, and every channel adapter
|
|
2
|
+
// (CLI, HTTP, ...) calls unchanged — only their AgentConfig and modelCall
|
|
3
|
+
// differ. runAgent itself does no I/O and holds no state between calls:
|
|
4
|
+
// callers own conversation history, which is what lets the same function
|
|
5
|
+
// serve a one-shot CLI invocation and a long-lived chat session.
|
|
6
|
+
import { Gate, RuleSet, ConsoleApprover } from 'actauth';
|
|
7
|
+
import { SkillGarden } from 'skillgarden';
|
|
8
|
+
import { ContextClipper } from 'contextclip';
|
|
9
|
+
import { ToolLane } from 'toollane';
|
|
10
|
+
import { Reflow } from 'reflowkit';
|
|
11
|
+
export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
|
|
12
|
+
const log = options.onEvent ?? (() => { });
|
|
13
|
+
const scope = { tenant: 'default', environment: 'production', ...config.scope, agent: config.name };
|
|
14
|
+
const skillGarden = config.skillsDirs?.length
|
|
15
|
+
? new SkillGarden({ dirs: config.skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
|
|
16
|
+
: null;
|
|
17
|
+
const skillIndex = skillGarden?.buildIndex().included ?? [];
|
|
18
|
+
const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages: 4 });
|
|
19
|
+
const rules = new RuleSet(config.rules, config.defaultDecision ?? 'ask');
|
|
20
|
+
const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
|
|
21
|
+
const toolLane = new ToolLane({ isSafe: config.isSafeTool ?? (() => false) });
|
|
22
|
+
const reflow = new Reflow({
|
|
23
|
+
onPromptTooLong: async (messages) => {
|
|
24
|
+
const result = await contextClip.recover(messages);
|
|
25
|
+
log('reflow:recover', { from: messages.length, to: result.messages.length });
|
|
26
|
+
return result.messages;
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
// Optional on AgentConfig — an MCP-only agent's tools are populated by
|
|
30
|
+
// load-agent.ts before this ever runs, but default defensively here too
|
|
31
|
+
// in case a config reaches runAgent without going through it.
|
|
32
|
+
const tools = config.tools ?? [];
|
|
33
|
+
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
34
|
+
const toolSchemas = tools.map(({ name, description, input_schema }) => ({
|
|
35
|
+
name,
|
|
36
|
+
description,
|
|
37
|
+
input_schema,
|
|
38
|
+
}));
|
|
39
|
+
const systemPrompt = skillIndex.length
|
|
40
|
+
? `${config.systemPrompt}\n\nAvailable skills:\n${skillIndex.map((s) => `- ${s.name}: ${s.description}`).join('\n')}`
|
|
41
|
+
: config.systemPrompt;
|
|
42
|
+
let messages = [...history, { role: 'user', content: userMessage }];
|
|
43
|
+
for (;;) {
|
|
44
|
+
const budget = contextClip.check(messages);
|
|
45
|
+
log('contextclip:check', budget);
|
|
46
|
+
if (budget.action === 'nudge' && budget.nudge)
|
|
47
|
+
messages.push(budget.nudge);
|
|
48
|
+
const { value: response, recoveries } = await reflow.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
|
|
49
|
+
if (recoveries.length > 0)
|
|
50
|
+
log('reflow:recoveries', recoveries);
|
|
51
|
+
const toolUseBlocks = response.content.filter((b) => b.type === 'tool_use');
|
|
52
|
+
if (toolUseBlocks.length === 0) {
|
|
53
|
+
const text = response.content.find((b) => b.type === 'text')?.text ?? '';
|
|
54
|
+
log('loop:done', text);
|
|
55
|
+
messages.push({ role: 'assistant', content: text });
|
|
56
|
+
return { text, history: messages };
|
|
57
|
+
}
|
|
58
|
+
// Skill invocation injects instructions into context; it isn't a tool
|
|
59
|
+
// that needs ActAuth gating or ToolLane scheduling.
|
|
60
|
+
const skillCall = toolUseBlocks.find((b) => b.name === 'Skill');
|
|
61
|
+
if (skillCall && skillGarden) {
|
|
62
|
+
const body = skillGarden.invoke(skillCall.input.skill);
|
|
63
|
+
log('skillgarden:invoke', skillCall.input.skill);
|
|
64
|
+
messages.push({ role: 'assistant', content: `[called Skill: ${skillCall.input.skill}]` });
|
|
65
|
+
messages.push({ role: 'user', content: body });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const approved = [];
|
|
69
|
+
const denied = [];
|
|
70
|
+
const reasons = new Map();
|
|
71
|
+
for (const block of toolUseBlocks) {
|
|
72
|
+
const decision = await gate.evaluate(block.name, block.input ?? {}, scope);
|
|
73
|
+
log('actauth:decision', { tool: block.name, decision: decision.decision, reason: decision.reason });
|
|
74
|
+
if (decision.decision === 'allow') {
|
|
75
|
+
approved.push(block);
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
denied.push(block);
|
|
79
|
+
reasons.set(block.id, decision.reason);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
messages.push({ role: 'assistant', content: `[requested: ${toolUseBlocks.map((b) => b.name).join(', ')}]` });
|
|
83
|
+
// Every requested tool gets exactly one result message back — a denied
|
|
84
|
+
// call is not simply dropped, or the model has no way to tell "denied"
|
|
85
|
+
// apart from "hasn't run yet" and may just re-request it forever.
|
|
86
|
+
for (const block of denied) {
|
|
87
|
+
messages.push({ role: 'user', content: `[${block.name} result] denied: ${reasons.get(block.id)}` });
|
|
88
|
+
}
|
|
89
|
+
const calls = approved.map((b) => ({
|
|
90
|
+
id: b.id,
|
|
91
|
+
name: b.name,
|
|
92
|
+
execute: () => toolsByName.get(b.name).execute(b.input ?? {}),
|
|
93
|
+
}));
|
|
94
|
+
for await (const result of toolLane.run(calls)) {
|
|
95
|
+
const summary = result.status === 'fulfilled' ? JSON.stringify(result.value) : `ERROR: ${result.error}`;
|
|
96
|
+
log('toollane:result', { name: result.name, summary });
|
|
97
|
+
messages.push({ role: 'user', content: `[${result.name} result] ${summary}` });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type Storage } from 'sessionknit';
|
|
2
|
+
import type { Message } from 'contextclip';
|
|
3
|
+
export interface SessionResult<T> {
|
|
4
|
+
history: Message[];
|
|
5
|
+
result: T;
|
|
6
|
+
}
|
|
7
|
+
export interface SessionStore {
|
|
8
|
+
/** Loads history for sessionId, runs fn exclusively — no other
|
|
9
|
+
* withSession call for the same sessionId runs concurrently, in this
|
|
10
|
+
* process or (for RedisSessionStore) any other — appends the new
|
|
11
|
+
* messages fn's result adds, and resolves with fn's result. */
|
|
12
|
+
withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
/** Wraps a SessionKnit log behind the flat load/run/append contract
|
|
16
|
+
* runAgent's callers use. Shared by both the file- and Redis-backed
|
|
17
|
+
* stores below — only the Storage and the lock differ. */
|
|
18
|
+
declare class SessionKnitStore implements SessionStore {
|
|
19
|
+
private readonly lock;
|
|
20
|
+
private readonly onClose;
|
|
21
|
+
private readonly knit;
|
|
22
|
+
constructor(storage: Storage<Message>, lock: <T>(sessionId: string, fn: () => Promise<T>) => Promise<T>, onClose?: () => Promise<void>);
|
|
23
|
+
withSession<T>(sessionId: string, fn: (history: Message[]) => Promise<SessionResult<T>>): Promise<T>;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/** One JSONL entry log per session under `dir`. Fine for a single local
|
|
27
|
+
* process (CLI, dev server); not safe across multiple processes/instances
|
|
28
|
+
* — those need RedisSessionStore. */
|
|
29
|
+
export declare class FileSessionStore extends SessionKnitStore {
|
|
30
|
+
constructor(dir: string);
|
|
31
|
+
}
|
|
32
|
+
/** Redis-backed store for multi-instance deployments. Locking is a
|
|
33
|
+
* single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
|
|
34
|
+
* script) — good enough as long as session state lives in one Redis; true
|
|
35
|
+
* multi-node Redlock is overkill until it doesn't. This lock is what keeps
|
|
36
|
+
* two concurrent turns for the same session from interleaving appends
|
|
37
|
+
* across instances — SessionKnit's own topology repair handles branching
|
|
38
|
+
* *within* one resumed chain, not that. */
|
|
39
|
+
export declare class RedisSessionStore extends SessionKnitStore {
|
|
40
|
+
private readonly lockTtlMs;
|
|
41
|
+
private readonly lockRetryDelayMs;
|
|
42
|
+
private readonly lockMaxWaitMs;
|
|
43
|
+
private readonly redis;
|
|
44
|
+
constructor(redisUrl: string, lockTtlMs?: number, lockRetryDelayMs?: number, lockMaxWaitMs?: number);
|
|
45
|
+
private lockKeyFor;
|
|
46
|
+
private withLock;
|
|
47
|
+
}
|
|
48
|
+
/** Picks the store from environment so adapters don't hardcode a backend:
|
|
49
|
+
* REDIS_URL set -> RedisSessionStore (required once you run >1 instance),
|
|
50
|
+
* otherwise FileSessionStore under .sessions (fine for local dev). */
|
|
51
|
+
export declare function createSessionStore(): SessionStore;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Where conversation history lives between calls to runAgent, and how
|
|
2
|
+
// concurrent requests for the *same* session are kept from racing on
|
|
3
|
+
// read-modify-write. Adapters never call load/save directly — they go
|
|
4
|
+
// through withSession, so locking isn't something each adapter has to
|
|
5
|
+
// remember to do correctly.
|
|
6
|
+
//
|
|
7
|
+
// Storage itself is SessionKnit: a durable, append-only, parent-linked log
|
|
8
|
+
// per session, rather than a flat blob rewritten whole on every turn. That
|
|
9
|
+
// buys two things runAgent's history array can't give itself:
|
|
10
|
+
// - non-blocking, debounced writes instead of a full read-modify-write
|
|
11
|
+
// of the entire session on every turn
|
|
12
|
+
// - interruption detection — if the process dies between requesting
|
|
13
|
+
// tools and recording their results (run-agent.ts pushes an
|
|
14
|
+
// "[requested: ...]" message before any tool runs, then one
|
|
15
|
+
// "[... result]" message per completed tool), the next resume() sees
|
|
16
|
+
// an unresolved request and injects a note into context saying so,
|
|
17
|
+
// instead of silently resending an incomplete turn as if it were
|
|
18
|
+
// clean.
|
|
19
|
+
// SessionKnit's own topology repair is for branches *within* one resumed
|
|
20
|
+
// chain (parallel tool calls, crash recovery) — it isn't a substitute for
|
|
21
|
+
// turn-level exclusivity, so this module still serializes concurrent
|
|
22
|
+
// withSession calls for the same sessionId itself (KeyedMutex / a Redis
|
|
23
|
+
// lock), same as before.
|
|
24
|
+
import { randomUUID } from 'node:crypto';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { Redis } from 'ioredis';
|
|
27
|
+
import { SessionKnit, FileStorage } from 'sessionknit';
|
|
28
|
+
/** Serializes async work per key within this process. Good enough for
|
|
29
|
+
* FileSessionStore (single process); RedisSessionStore needs a real
|
|
30
|
+
* distributed lock instead since multiple server instances share it. */
|
|
31
|
+
class KeyedMutex {
|
|
32
|
+
tails = new Map();
|
|
33
|
+
run(key, fn) {
|
|
34
|
+
const prevTail = this.tails.get(key) ?? Promise.resolve();
|
|
35
|
+
const result = prevTail.then(fn, fn);
|
|
36
|
+
this.tails.set(key, result.catch(() => { }));
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// run-agent.ts pushes exactly one "[requested: tool_a, tool_b]" assistant
|
|
41
|
+
// message before any tool executes, then one "[tool result]" user message
|
|
42
|
+
// per completed tool as they finish (adapters/http.ts, adapters/cli.ts
|
|
43
|
+
// never see this — it lives entirely inside the messages array runAgent
|
|
44
|
+
// returns). If the process dies in that window, the request message is the
|
|
45
|
+
// last thing on disk with no result behind it.
|
|
46
|
+
function hasUnresolvedToolCall(message) {
|
|
47
|
+
return message.role === 'assistant' && message.content.startsWith('[requested:');
|
|
48
|
+
}
|
|
49
|
+
function buildContinuation() {
|
|
50
|
+
return {
|
|
51
|
+
role: 'user',
|
|
52
|
+
content: '[session resumed after an interruption — the assistant had requested tool calls but the process stopped before any results were recorded; treat those tools as not having run]',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Wraps a SessionKnit log behind the flat load/run/append contract
|
|
56
|
+
* runAgent's callers use. Shared by both the file- and Redis-backed
|
|
57
|
+
* stores below — only the Storage and the lock differ. */
|
|
58
|
+
class SessionKnitStore {
|
|
59
|
+
lock;
|
|
60
|
+
onClose;
|
|
61
|
+
knit;
|
|
62
|
+
constructor(storage, lock, onClose = async () => { }) {
|
|
63
|
+
this.lock = lock;
|
|
64
|
+
this.onClose = onClose;
|
|
65
|
+
this.knit = new SessionKnit(storage, { hasUnresolvedToolCall, buildContinuation });
|
|
66
|
+
}
|
|
67
|
+
async withSession(sessionId, fn) {
|
|
68
|
+
return this.lock(sessionId, async () => {
|
|
69
|
+
const { messages, leafId } = await this.knit.resume(sessionId);
|
|
70
|
+
const { history: nextHistory, result } = await fn(messages);
|
|
71
|
+
// Everything fn's history grew by beyond what resume() handed it —
|
|
72
|
+
// this naturally excludes the synthetic continuation message too
|
|
73
|
+
// (it's already counted in messages.length), which is exactly right:
|
|
74
|
+
// it's a resend-only hint, never meant to be durably stored.
|
|
75
|
+
let parentId = leafId;
|
|
76
|
+
for (const message of nextHistory.slice(messages.length)) {
|
|
77
|
+
const id = randomUUID();
|
|
78
|
+
await this.knit.append(sessionId, { id, parentId, message });
|
|
79
|
+
parentId = id;
|
|
80
|
+
}
|
|
81
|
+
await this.knit.flush(sessionId);
|
|
82
|
+
return result;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async close() {
|
|
86
|
+
await this.onClose();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const fileMutex = new KeyedMutex();
|
|
90
|
+
/** One JSONL entry log per session under `dir`. Fine for a single local
|
|
91
|
+
* process (CLI, dev server); not safe across multiple processes/instances
|
|
92
|
+
* — those need RedisSessionStore. */
|
|
93
|
+
export class FileSessionStore extends SessionKnitStore {
|
|
94
|
+
constructor(dir) {
|
|
95
|
+
super(new FileStorage(dir), (sessionId, fn) => fileMutex.run(path.resolve(dir, sessionId), fn));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** SessionKnit Storage backed by a Redis list — one RPUSH per appended
|
|
99
|
+
* entry. Redis is already durable per write, so unlike FileStorage there's
|
|
100
|
+
* nothing to debounce: flush() is a no-op. */
|
|
101
|
+
class RedisEntryStorage {
|
|
102
|
+
redis;
|
|
103
|
+
constructor(redis) {
|
|
104
|
+
this.redis = redis;
|
|
105
|
+
}
|
|
106
|
+
keyFor(sessionId) {
|
|
107
|
+
return `session-log:${sessionId}`;
|
|
108
|
+
}
|
|
109
|
+
async append(sessionId, entry) {
|
|
110
|
+
await this.redis.rpush(this.keyFor(sessionId), JSON.stringify(entry));
|
|
111
|
+
}
|
|
112
|
+
async flush() { }
|
|
113
|
+
async readAll(sessionId) {
|
|
114
|
+
const raw = await this.redis.lrange(this.keyFor(sessionId), 0, -1);
|
|
115
|
+
return raw.map((line) => JSON.parse(line));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Redis-backed store for multi-instance deployments. Locking is a
|
|
119
|
+
* single-instance-Redis lock (SET NX PX + a compare-and-delete unlock
|
|
120
|
+
* script) — good enough as long as session state lives in one Redis; true
|
|
121
|
+
* multi-node Redlock is overkill until it doesn't. This lock is what keeps
|
|
122
|
+
* two concurrent turns for the same session from interleaving appends
|
|
123
|
+
* across instances — SessionKnit's own topology repair handles branching
|
|
124
|
+
* *within* one resumed chain, not that. */
|
|
125
|
+
export class RedisSessionStore extends SessionKnitStore {
|
|
126
|
+
lockTtlMs;
|
|
127
|
+
lockRetryDelayMs;
|
|
128
|
+
lockMaxWaitMs;
|
|
129
|
+
redis;
|
|
130
|
+
constructor(redisUrl, lockTtlMs = 30_000, lockRetryDelayMs = 50, lockMaxWaitMs = 15_000) {
|
|
131
|
+
const redis = new Redis(redisUrl);
|
|
132
|
+
super(new RedisEntryStorage(redis), (sessionId, fn) => this.withLock(sessionId, fn), () => redis.quit().then(() => { }));
|
|
133
|
+
this.lockTtlMs = lockTtlMs;
|
|
134
|
+
this.lockRetryDelayMs = lockRetryDelayMs;
|
|
135
|
+
this.lockMaxWaitMs = lockMaxWaitMs;
|
|
136
|
+
this.redis = redis;
|
|
137
|
+
}
|
|
138
|
+
lockKeyFor(sessionId) {
|
|
139
|
+
return `session-lock:${sessionId}`;
|
|
140
|
+
}
|
|
141
|
+
async withLock(sessionId, fn) {
|
|
142
|
+
const lockKey = this.lockKeyFor(sessionId);
|
|
143
|
+
const token = randomUUID();
|
|
144
|
+
const deadline = Date.now() + this.lockMaxWaitMs;
|
|
145
|
+
for (;;) {
|
|
146
|
+
const ok = await this.redis.set(lockKey, token, 'PX', this.lockTtlMs, 'NX');
|
|
147
|
+
if (ok)
|
|
148
|
+
break;
|
|
149
|
+
if (Date.now() > deadline)
|
|
150
|
+
throw new Error(`timed out waiting for lock on session '${sessionId}'`);
|
|
151
|
+
await new Promise((resolve) => setTimeout(resolve, this.lockRetryDelayMs));
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return await fn();
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
// Only delete if we still hold it — otherwise we'd release a lock our
|
|
158
|
+
// TTL already expired and a different request has since acquired.
|
|
159
|
+
const script = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
|
|
160
|
+
await this.redis.eval(script, 1, lockKey, token);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Picks the store from environment so adapters don't hardcode a backend:
|
|
165
|
+
* REDIS_URL set -> RedisSessionStore (required once you run >1 instance),
|
|
166
|
+
* otherwise FileSessionStore under .sessions (fine for local dev). */
|
|
167
|
+
export function createSessionStore() {
|
|
168
|
+
const redisUrl = process.env.REDIS_URL;
|
|
169
|
+
return redisUrl ? new RedisSessionStore(redisUrl) : new FileSessionStore('.sessions');
|
|
170
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Hashes each token into one of EMBEDDING_DIMENSIONS buckets (FNV-1a),
|
|
2
|
+
* with the hash's sign deciding +1/-1 to reduce collision bias, then
|
|
3
|
+
* L2-normalizes. Works on tokens it's never seen and needs no training
|
|
4
|
+
* step, at the cost of real embedding quality — a stand-in, not a
|
|
5
|
+
* production embedding model. */
|
|
6
|
+
export declare function embed(text: string): Float64Array;
|
|
7
|
+
/** Both inputs are unit vectors (embed() normalizes), so the dot product
|
|
8
|
+
* already is the cosine similarity. */
|
|
9
|
+
export declare function cosineSimilarity(a: Float64Array, b: Float64Array): number;
|
|
10
|
+
export interface Document {
|
|
11
|
+
id: string;
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ScoredDocument extends Document {
|
|
15
|
+
score: number;
|
|
16
|
+
}
|
|
17
|
+
/** In-memory vector store: add() embeds and keeps the document, search()
|
|
18
|
+
* returns the top-k most similar by cosine similarity. Swap for a real
|
|
19
|
+
* vector DB (pgvector, Pinecone, ...) once the corpus outgrows memory —
|
|
20
|
+
* same shape, so callers (like a search_docs tool) don't need to change. */
|
|
21
|
+
export declare class VectorIndex {
|
|
22
|
+
private readonly documents;
|
|
23
|
+
add(id: string, text: string): void;
|
|
24
|
+
search(query: string, topK?: number): ScoredDocument[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// A small, dependency-free vector index for RAG demos — feature-hashing
|
|
2
|
+
// embeddings (no external embedding API call, no vocabulary to train)
|
|
3
|
+
// plus cosine-similarity search. Real technique (the "hashing trick"),
|
|
4
|
+
// just not a learned one — good enough to prove the retrieval mechanics
|
|
5
|
+
// end-to-end without wiring up a live embedding model. Swap embed() for a
|
|
6
|
+
// real one and VectorIndex works unchanged: same seam as ContextClip's
|
|
7
|
+
// Summarizer or ActAuth's Approver — a working default the rest of the
|
|
8
|
+
// pipeline can be built and tested against.
|
|
9
|
+
// Higher dimensions mean fewer hash collisions between unrelated tokens,
|
|
10
|
+
// which matters more here than with a learned embedding since there's no
|
|
11
|
+
// training to smooth collisions out — 1024 keeps ranking stable for the
|
|
12
|
+
// short, few-dozen-document corpora this is meant for.
|
|
13
|
+
const EMBEDDING_DIMENSIONS = 1024;
|
|
14
|
+
function tokenize(text) {
|
|
15
|
+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
|
|
16
|
+
}
|
|
17
|
+
function normalize(vector) {
|
|
18
|
+
let magnitude = 0;
|
|
19
|
+
for (const value of vector)
|
|
20
|
+
magnitude += value * value;
|
|
21
|
+
magnitude = Math.sqrt(magnitude);
|
|
22
|
+
if (magnitude === 0)
|
|
23
|
+
return vector;
|
|
24
|
+
const result = new Float64Array(vector.length);
|
|
25
|
+
for (let i = 0; i < vector.length; i++)
|
|
26
|
+
result[i] = vector[i] / magnitude;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/** Hashes each token into one of EMBEDDING_DIMENSIONS buckets (FNV-1a),
|
|
30
|
+
* with the hash's sign deciding +1/-1 to reduce collision bias, then
|
|
31
|
+
* L2-normalizes. Works on tokens it's never seen and needs no training
|
|
32
|
+
* step, at the cost of real embedding quality — a stand-in, not a
|
|
33
|
+
* production embedding model. */
|
|
34
|
+
export function embed(text) {
|
|
35
|
+
const vector = new Float64Array(EMBEDDING_DIMENSIONS);
|
|
36
|
+
for (const token of tokenize(text)) {
|
|
37
|
+
let hash = 2166136261; // FNV offset basis
|
|
38
|
+
for (let i = 0; i < token.length; i++) {
|
|
39
|
+
hash ^= token.charCodeAt(i);
|
|
40
|
+
hash = Math.imul(hash, 16777619); // FNV prime
|
|
41
|
+
}
|
|
42
|
+
const bucket = Math.abs(hash) % EMBEDDING_DIMENSIONS;
|
|
43
|
+
const sign = hash & 1 ? 1 : -1;
|
|
44
|
+
vector[bucket] += sign;
|
|
45
|
+
}
|
|
46
|
+
return normalize(vector);
|
|
47
|
+
}
|
|
48
|
+
/** Both inputs are unit vectors (embed() normalizes), so the dot product
|
|
49
|
+
* already is the cosine similarity. */
|
|
50
|
+
export function cosineSimilarity(a, b) {
|
|
51
|
+
let dot = 0;
|
|
52
|
+
for (let i = 0; i < a.length; i++)
|
|
53
|
+
dot += a[i] * b[i];
|
|
54
|
+
return dot;
|
|
55
|
+
}
|
|
56
|
+
/** In-memory vector store: add() embeds and keeps the document, search()
|
|
57
|
+
* returns the top-k most similar by cosine similarity. Swap for a real
|
|
58
|
+
* vector DB (pgvector, Pinecone, ...) once the corpus outgrows memory —
|
|
59
|
+
* same shape, so callers (like a search_docs tool) don't need to change. */
|
|
60
|
+
export class VectorIndex {
|
|
61
|
+
documents = [];
|
|
62
|
+
add(id, text) {
|
|
63
|
+
this.documents.push({ id, text, vector: embed(text) });
|
|
64
|
+
}
|
|
65
|
+
search(query, topK = 3) {
|
|
66
|
+
const queryVector = embed(query);
|
|
67
|
+
return this.documents
|
|
68
|
+
.map(({ id, text, vector }) => ({ id, text, score: cosineSimilarity(queryVector, vector) }))
|
|
69
|
+
.sort((a, b) => b.score - a.score)
|
|
70
|
+
.slice(0, topK);
|
|
71
|
+
}
|
|
72
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "loopengine",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "A minimal framework for defining and running tool-using AI agents over CLI, HTTP, or both.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/evanyan22/loopengine.git"
|
|
10
|
+
},
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/index.js",
|
|
15
|
+
"dist/index.d.ts",
|
|
16
|
+
"dist/run-agent.js",
|
|
17
|
+
"dist/run-agent.d.ts",
|
|
18
|
+
"dist/agent-config.js",
|
|
19
|
+
"dist/agent-config.d.ts",
|
|
20
|
+
"dist/load-agent.js",
|
|
21
|
+
"dist/load-agent.d.ts",
|
|
22
|
+
"dist/mcp-tools.js",
|
|
23
|
+
"dist/mcp-tools.d.ts",
|
|
24
|
+
"dist/session-store.js",
|
|
25
|
+
"dist/session-store.d.ts",
|
|
26
|
+
"dist/vector-index.js",
|
|
27
|
+
"dist/vector-index.d.ts",
|
|
28
|
+
"dist/anthropic-model-call.js",
|
|
29
|
+
"dist/anthropic-model-call.d.ts",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"prepublishOnly": "npm run build",
|
|
35
|
+
"start": "node dist/adapters/http.js",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@anthropic-ai/sdk": "^0.116.0",
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
41
|
+
"actauth": "^0.0.2",
|
|
42
|
+
"contextclip": "^0.0.1",
|
|
43
|
+
"ioredis": "^6.0.0",
|
|
44
|
+
"reflowkit": "^0.0.1",
|
|
45
|
+
"sessionknit": "^0.0.2",
|
|
46
|
+
"skillgarden": "^0.0.1",
|
|
47
|
+
"toollane": "^0.0.1"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@modelcontextprotocol/server-filesystem": "^2026.7.10",
|
|
51
|
+
"@types/node": "^26.2.0",
|
|
52
|
+
"tsx": "^4.19.0",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"vitest": "^2.1.9"
|
|
55
|
+
}
|
|
56
|
+
}
|