loopengine 0.0.1 → 0.0.2

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,4 @@
1
- # loopengine
1
+ # LoopEngine
2
2
 
3
3
  A minimal framework for defining and running tool-using AI agents. Write an
4
4
  agent as a plain config object — persona, tools, permission rules — and run
@@ -23,6 +23,13 @@ out, in `session-store.ts`, since it persists what `run-agent.ts` produces
23
23
  rather than participating in the loop itself. Everything else in this repo
24
24
  — agent definitions, channel adapters — is built on top of those.
25
25
 
26
+ A seventh package, [`mcpplug`](https://www.npmjs.com/package/mcpplug), sits
27
+ one layer further out still: it's not part of the loop either, just a
28
+ `ToolSource` an agent's own config can draw from — `loadTools()` returns
29
+ `ToolDefinition[]`, the exact shape `AgentConfig.tools` already expects, so
30
+ its output merges straight in with no adapter code. See "External tool
31
+ gateways" below.
32
+
26
33
  ## Using loopengine as a library
27
34
 
28
35
  This repo is two things in one: a small library (the loop itself) and a
@@ -57,10 +64,10 @@ console.log(result.text)
57
64
  ```
58
65
 
59
66
  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.
67
+ `FileSessionStore`/`RedisSessionStore`/`createSessionStore`, `VectorIndex`/
68
+ `embed`/`cosineSimilarity` (for RAG — see below), and `createAnthropicModelCall`/
69
+ `createOpenAIModelCall` (real, ready-to-use `ModelCall`s). See `index.ts` for
70
+ the exact list.
64
71
 
65
72
  **Not** part of the package: `agents/*.ts` (this repo's own demo agents),
66
73
  `agent-registry.ts` (this repo's own name → config lookup table — build your
@@ -118,42 +125,11 @@ To make a new agent runnable through the adapters, add one line to
118
125
  `agent-registry.ts` mapping its name to its `config` and a `createModelCall`
119
126
  factory.
120
127
 
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
128
  ## Retrieval (RAG)
153
129
 
154
130
  RAG is just another tool — the model decides when to call it, `run-agent.ts`
155
131
  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:
132
+ search instead of a plain HTTP call:
157
133
 
158
134
  ```ts
159
135
  const searchDocs: ToolDefinition = {
@@ -178,6 +154,51 @@ implementation, the package proves the pipeline" pattern as ContextClip's
178
154
  ordinary tool result, subject to the same ActAuth gating, ToolLane
179
155
  scheduling, and ContextClip budget tracking as any other tool call.
180
156
 
157
+ ## External tool gateways
158
+
159
+ A hand-written tool needs an `execute` function you write yourself; an
160
+ external SaaS action (list GitHub repos, send a Slack message) needs
161
+ someone to own that vendor's auth. Connecting straight to a vendor's own
162
+ MCP server means owning its OAuth flow (authorization code, PKCE, token
163
+ storage/refresh) yourself, repeated per vendor. Routing through a gateway
164
+ like [Composio](https://composio.dev) avoids that — it already holds the
165
+ OAuth relationship with 1000+ apps — and [`mcpplug`](https://www.npmjs.com/package/mcpplug)
166
+ wraps that gateway behind the same `ToolDefinition` shape as everything
167
+ else:
168
+
169
+ ```ts
170
+ import { connectComposioSource } from 'mcpplug'
171
+
172
+ // Resolved once, at module-eval time — not per runAgent() call. A gateway
173
+ // connection is exactly the kind of setup cost runAgent() shouldn't pay on
174
+ // every turn; see agents/file-agent.ts for the working example this is
175
+ // drawn from.
176
+ const composioTools = await connectComposioSource('composio', {
177
+ slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
178
+ }).then((source) => source.loadTools())
179
+
180
+ const config: AgentConfig = {
181
+ name: 'my-agent',
182
+ systemPrompt: 'You are ...',
183
+ tools: [...handWrittenTools, ...composioTools],
184
+ rules: [
185
+ // mcpplug namespaces each tool `${sourceName}_${slug}` — falls
186
+ // through to defaultDecision unless a rule names it explicitly.
187
+ { scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' },
188
+ ],
189
+ defaultDecision: 'ask',
190
+ }
191
+ ```
192
+
193
+ Same ActAuth gating, ToolLane scheduling, and ContextClip budget tracking
194
+ as any other tool call — `runAgent` has no idea one `execute` shells out
195
+ through Composio instead of touching the filesystem or a vector index.
196
+ `agents/file-agent.ts` is a complete, working example, verified end-to-end
197
+ against a real linked GitHub account. `slugs` is explicit, not
198
+ auto-discovered — an agent should only see the Composio actions its own
199
+ config actually lists; find them with the `composio` CLI's
200
+ `composio tools list <toolkit>` or `composio search "<use case>"`.
201
+
181
202
  ## Running an agent
182
203
 
183
204
  **CLI:**
@@ -195,10 +216,29 @@ curl -X POST localhost:8787/agents/customer-service/messages \
195
216
  -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
196
217
  ```
197
218
 
219
+ `customerEmail` is specific to `customer-service` — it defines its own
220
+ `AgentConfig.sessionIdFor` (see "Sessions and multi-tenancy" below). Other
221
+ agents take a plain `sessionId` field instead — and it's optional: omit it
222
+ for a fresh, one-off session (a generated id, echoed back in the response
223
+ as `sessionId` so you can pass it in explicitly to continue that same
224
+ conversation on a later request):
225
+
226
+ ```bash
227
+ curl -X POST localhost:8787/agents/file-agent/messages \
228
+ -H 'content-type: application/json' \
229
+ -d '{"sessionId":"s1","message":"Summarize a.txt and b.txt into summary.txt."}'
230
+ ```
231
+
232
+ `customer-service` is the exception here too — a request with no
233
+ `customerEmail` is a genuine validation error (`400`), not "start me an
234
+ anonymous session," since `customerEmail` is a real, meaningful identity
235
+ for that agent, not an arbitrary key.
236
+
198
237
  **HTTP, streamed:** same request, `/stream` suffix, one Server-Sent Event per
199
238
  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:
239
+ a `session` event first (announcing whatever `sessionId` got used, generated
240
+ or not), then `contextclip:check`, `actauth:decision`, `toollane:result`,
241
+ ..., a final `done` with the answer:
202
242
 
203
243
  ```bash
204
244
  curl -N -X POST localhost:8787/agents/customer-service/messages/stream \
@@ -233,13 +273,13 @@ rewrite can't:
233
273
 
234
274
  - **Non-blocking, debounced writes** instead of a full read-modify-write of
235
275
  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.
276
+ - **Interruption detection.** `run-agent.ts` pushes the assistant's full
277
+ response — tool_use blocks included — as one message before any tool
278
+ runs, then one message bundling every tool_result once they've all
279
+ settled. If the process dies in that window, the next `resume()` sees an
280
+ assistant message with an unanswered tool_use block, flags the session
281
+ as resumed-after-interruption, and injects a note into context saying so
282
+ — instead of silently resending an incomplete turn as if it were clean.
243
283
 
244
284
  Two implementations, same `SessionStore` interface:
245
285
 
@@ -247,19 +287,57 @@ Two implementations, same `SessionStore` interface:
247
287
  locked in-process. Fine for local dev and the CLI.
248
288
  - `RedisSessionStore` — same log shape (a Redis list of entries, one
249
289
  `RPUSH` per append), with a real distributed lock, safe across multiple
250
- server instances.
290
+ server instances. The lock renews itself (`lockTtlMs / 3` by default)
291
+ for as long as a turn runs, instead of a flat TTL that a long turn (a
292
+ real model call plus several tool round-trips) could outlast — if
293
+ renewal ever confirms the lock was lost anyway (not just a failed
294
+ renewal attempt — a network blip there isn't proof of loss), the turn's
295
+ result is rejected rather than silently returned as if mutual exclusion
296
+ held the whole time.
251
297
 
252
298
  `createSessionStore()` picks between them based on `REDIS_URL` — set it and
253
299
  you get Redis, otherwise it falls back to the file store. Either way,
254
300
  turn-level exclusivity (no two concurrent turns for the same session
255
301
  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
302
+ own topology repair (reattaching sibling branches under a shared
303
+ `parentId`) is a defensive read-side repair for crash/corruption recovery,
304
+ not something normal operation exercises. `run-agent.ts` bundles a whole
305
+ turn's `tool_use`/`tool_result` blocks into one message each, and
306
+ `withSession` appends new messages sequentially, so even a turn with
307
+ several parallel tool calls only ever produces a linear chain in the
308
+ durable log — not races between two full concurrent turns, which is what
309
+ the lock above actually prevents.
310
+
311
+ Session-key derivation is `AgentConfig.sessionIdFor(body)` — deliberately
312
+ not the HTTP adapter's call, since what counts as "one conversation" is
313
+ business logic specific to what the agent is for, not a transport concern.
314
+ `customer-service-agent.ts` defines its own, hashing `customerEmail` so raw
315
+ addresses never end up in storage keys — different customers can never
261
316
  read or write each other's history, and concurrent messages from the same
262
- customer are serialized rather than dropped.
317
+ customer are serialized rather than dropped. A missing `customerEmail` is a
318
+ real validation failure for that agent (`400`), not something to paper over.
319
+
320
+ An agent that doesn't define `sessionIdFor` falls back to a plain
321
+ client-supplied `sessionId` field (`adapters/http.ts`'s
322
+ `defaultSessionIdFor`) — the same shape `adapters/cli.ts`'s `--session`
323
+ flag already uses — and if that's missing too, one gets generated rather
324
+ than the request being rejected, mirroring what omitting `--session` now
325
+ does for the CLI: a fresh, isolated, one-off session rather than an error
326
+ or (worse) a shared bucket every untagged caller collides into. The
327
+ generated id comes back in the response (`sessionId` in the JSON body, a
328
+ `session` SSE event first for the streamed route) so a caller that wants to
329
+ continue that exact conversation can pass it in explicitly next time.
330
+
331
+ `SessionStore` itself is agent-agnostic — nothing in `session-store.ts` or
332
+ `sessionknit` knows which agent is calling `withSession`, so both adapters
333
+ namespace the *actual* key by agent name before it ever reaches
334
+ `SessionStore`: `` `${agentName}:${sessionIdFor(...)}` `` in
335
+ `adapters/http.ts`, `` `${agent}:${session}` `` in `adapters/cli.ts`.
336
+ Without that, two different agents given the same session identifier (a
337
+ client reusing one `sessionId`/`--session` value across agents, most
338
+ likely) would read and write the exact same underlying log — one agent's
339
+ tool calls and results spliced straight into another's conversation as if
340
+ it had always been there.
263
341
 
264
342
  ## Deployment
265
343
 
@@ -276,11 +354,9 @@ and any tool-specific secrets set as environment variables.
276
354
  ## Wiring a real model
277
355
 
278
356
  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:
357
+ `agents/rag-agent.ts`) still uses a canned, turn-counting `ModelCall` so the
358
+ whole loop is runnable and testable with no API key. To go live, swap it for
359
+ one of the two real `ModelCall` implementations this repo ships:
284
360
 
285
361
  ```ts
286
362
  import { createAnthropicModelCall } from './anthropic-model-call.js'
@@ -290,27 +366,69 @@ const modelCall = createAnthropicModelCall({ model: 'claude-sonnet-5' }) // read
290
366
  const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
291
367
  ```
292
368
 
369
+ ```ts
370
+ import { createOpenAIModelCall } from './openai-model-call.js'
371
+
372
+ // model is required (no built-in default) — OpenAI's current flagship
373
+ // name changes too often to hardcode safely; reads OPENAI_API_KEY from the env
374
+ const modelCall = createOpenAIModelCall({ model: 'gpt-4.1' })
375
+
376
+ const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
377
+ ```
378
+
293
379
  Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
294
380
  change — `ModelCall` is the only seam a real API call needs.
295
381
 
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.
382
+ loopengine's own `Message` type carries real block-native history: `content`
383
+ is either a plain string or a `ModelContentBlock[]` — the same shape
384
+ `ModelResponse.content` already used — so a model's `tool_use` requests and
385
+ this loop's `tool_result` replies round-trip with real per-call identity
386
+ (`tool_use_id`) rather than being flattened into prose. That's what makes
387
+ parallel tool calls unambiguous (a result links back to the exact call that
388
+ requested it, not just "a call to this tool name") and lets a denied or
389
+ failed call carry `is_error: true`, which Claude is specifically trained to
390
+ react to. `anthropic-model-call.ts` translates directly to and from
391
+ Anthropic's native `TextBlockParam`/`ToolUseBlockParam`/`ToolResultBlockParam`
392
+ types — no flattening step in between. This was verified against the real
393
+ SDK (with a stubbed `fetch`, not a live call) — request shape, tool schemas,
394
+ and response mapping all round-trip correctly end to end.
395
+
396
+ `openai-model-call.ts` translates to and from OpenAI's Chat Completions
397
+ shape instead, verified the same way (stubbed `fetch`, real SDK, full
398
+ `runAgent` round trip including a tool call). The one structural mismatch:
399
+ loopengine bundles a whole turn's `tool_result` blocks into a single
400
+ user-role message (mirroring Anthropic's shape); OpenAI has no equivalent —
401
+ each becomes its own top-level `role: 'tool'` message, so one loopengine
402
+ `Message` can expand into several OpenAI messages, never the reverse.
305
403
 
306
404
  ## Skills
307
405
 
308
406
  Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
309
407
  convention production coding agents use: a short frontmatter index (`name`,
310
408
  `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).
409
+ only when the model actually invokes that skill — through a real `Skill`
410
+ tool schema (`{skill: string, args?: string}`) declared to the model
411
+ whenever `skillsDirs` is set, not just handled after the fact. Set
412
+ `skillsDirs` on an `AgentConfig` to enable it; omit it for agents that
413
+ don't need skills (`agents/rag-agent.ts` doesn't).
414
+
415
+ Point `skillsDirs` at a subdirectory scoped to that agent
416
+ (`skills/file-agent/`, not the shared `skills/` root) — `SkillGarden`'s
417
+ discovery recursively walks whatever root it's given with no per-agent
418
+ filtering, so two agents both pointed at `skills/` would each see every
419
+ skill under it, not just their own.
420
+
421
+ To install one of SkillGarden's bundled skills (e.g. `firecrawl`) into an
422
+ agent's own subdirectory, use its `add` CLI:
423
+
424
+ ```bash
425
+ npx skillgarden add firecrawl --agent file-agent
426
+ # -> Added file-agent:firecrawl -> skills/file-agent/firecrawl/SKILL.md
427
+ ```
428
+
429
+ That writes `skills/file-agent/firecrawl/SKILL.md`, which discovery then
430
+ surfaces as `file-agent:firecrawl` — no code change needed beyond having
431
+ already set `skillsDirs: 'skills/file-agent'` on that agent's config.
314
432
 
315
433
  ## Project layout
316
434
 
@@ -318,19 +436,17 @@ only when the model actually invokes that skill. Set `skillsDirs` on an
318
436
  index.ts Public API surface — what actually ships to npm
319
437
  agent-config.ts AgentConfig type — the thing you fill in to define an agent
320
438
  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
439
  vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
324
- agent-registry.ts Maps agent name -> {config, createModelCall}, via load-agent.ts
440
+ agent-registry.ts Maps agent name -> {config, createModelCall}
325
441
  session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
326
- anthropic-model-call.ts createAnthropicModelCall — the one real ModelCall this repo ships
442
+ anthropic-model-call.ts createAnthropicModelCall — a real ModelCall this repo ships
443
+ openai-model-call.ts createOpenAIModelCall — same seam, OpenAI's Chat Completions API
327
444
  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
445
+ agents/customer-service-agent.ts Example agent: order/shipment lookup, refund, email
330
446
  agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
331
447
  adapters/cli.ts Channel adapter: command line
332
448
  adapters/http.ts Channel adapter: HTTP API
333
- skills/ SKILL.md files discoverable by agents
449
+ skills/ SKILL.md files discoverable by agents, scoped per agent subdirectory
334
450
  examples/file-agent/ Sample input files + generated output for the file-agent demo
335
451
  Dockerfile, docker-compose.yml Container build + local Redis for testing
336
452
  ```
@@ -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
+ }