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 CHANGED
@@ -1,4 +1,6 @@
1
- # loopengine
1
+ # LoopEngine
2
+
3
+ [![CI](https://github.com/evanyan22/loopengine/actions/workflows/ci.yml/badge.svg)](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 actauth contextclip reflowkit toollane skillgarden
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
- `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.
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 filesystem read or an MCP round-trip:
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
- `contextclip:check`, `actauth:decision`, `toollane:result`, ..., a final
201
- `done` with the answer:
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 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.
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 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
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/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:
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
- 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.
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. 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).
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}, via load-agent.ts
449
+ agent-registry.ts Maps agent name -> {config, createModelCall}
325
450
  session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
326
- anthropic-model-call.ts createAnthropicModelCall — the one real ModelCall this repo ships
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 / refund / email
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
  ```
@@ -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 [] — an agent can run on mcpServers alone. */
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, 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.
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 (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.
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((m) => ({
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
+ }