loopengine 0.0.4 → 0.0.7

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
@@ -32,6 +32,28 @@ one layer further out still: it's not part of the loop either, just a
32
32
  its output merges straight in with no adapter code. See "External tool
33
33
  gateways" below.
34
34
 
35
+ ## Creating a new project
36
+
37
+ The fastest way to get a running agent server, in your own repo, is
38
+ [`create-loopengine`](https://www.npmjs.com/package/create-loopengine):
39
+
40
+ ```bash
41
+ npx create-loopengine@latest my-agents
42
+ cd my-agents
43
+ npm install
44
+ cp .env.example .env # fill in ANTHROPIC_API_KEY
45
+ npm run dev # HTTP server on :8787
46
+ ```
47
+
48
+ That scaffolds a real, standalone project — its own `package.json`
49
+ depending on `loopengine` (not a clone of this repo), an HTTP + CLI
50
+ adapter, and one starter agent — rather than a bare library import. `npm
51
+ install loopengine` alone (below) only gets you the library; the server,
52
+ adapters, and a runnable example are deliberately not part of that
53
+ package, which is the gap `create-loopengine` fills. See
54
+ [`create-loopengine`'s own README](https://github.com/evanyan22/create-loopengine)
55
+ for what the generated project looks like.
56
+
35
57
  ## Using loopengine as a library
36
58
 
37
59
  This repo is two things in one: a small library (the loop itself) and a
@@ -74,17 +96,20 @@ console.log(result.text)
74
96
 
75
97
  The full exported surface: `runAgent`, `AgentConfig`/`ToolSchema`/`ToolDefinition`,
76
98
  `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.
99
+ `embed`/`cosineSimilarity` (for RAG — see below), `discoverAgents` (scans a
100
+ directory for `config`/`createModelCall` modules and builds a name → entry
101
+ map, keyed by `AgentConfig.name` — see `agent-registry.ts` for the ~15-line
102
+ wrapper this repo's own adapters use), and `createAnthropicModelCall`/
103
+ `createOpenAIModelCall`/`createDeepSeekModelCall` (real, ready-to-use
104
+ `ModelCall`s). See `index.ts` for the exact list.
80
105
 
81
106
  **Not** part of the package: `agents/*.ts` (this repo's own demo agents),
82
- `agent-registry.ts` (this repo's own name → config lookup table — build your
83
- own, same one-line-per-agent shape), `adapters/cli.ts`/`adapters/http.ts`
84
- (one specific CLI/HTTP wiring choice, not imposed on every consumer), and
85
- the `Dockerfile`. Everything below this point describes that reference app —
86
- read it as a worked example of what to build with the library, not as
87
- library API itself.
107
+ `agent-registry.ts` (this repo's own name → config lookup table, built on
108
+ `discoverAgents` — build your own the same way), `adapters/cli.ts`/
109
+ `adapters/http.ts` (one specific CLI/HTTP wiring choice, not imposed on
110
+ every consumer), and the `Dockerfile`. Everything below this point
111
+ describes that reference app — read it as a worked example of what to
112
+ build with the library, not as library API itself.
88
113
 
89
114
  ## Quick start
90
115
 
@@ -130,9 +155,10 @@ export const config: AgentConfig = {
130
155
  `agents/file-agent.ts` and `agents/customer-service-agent.ts` are two complete, working
131
156
  examples — same `runAgent` loop, entirely different personas and tools.
132
157
 
133
- To make a new agent runnable through the adapters, add one line to
134
- `agent-registry.ts` mapping its name to its `config` and a `createModelCall`
135
- factory.
158
+ A file exporting `config` and `createModelCall` and saved into `agents/` is
159
+ runnable through both adapters immediately — `agent-registry.ts` finds it
160
+ automatically (by `AgentConfig.name`, not the filename) at startup.
161
+ Nothing to edit, no import to add, no adapter change.
136
162
 
137
163
  ## Retrieval (RAG)
138
164
 
@@ -235,7 +261,7 @@ conversation on a later request):
235
261
  ```bash
236
262
  curl -X POST localhost:8787/agents/file-agent/messages \
237
263
  -H 'content-type: application/json' \
238
- -d '{"sessionId":"s1","message":"Summarize a.txt and b.txt into summary.txt."}'
264
+ -d '{"message":"Summarize a.txt and b.txt into summary.txt."}'
239
265
  ```
240
266
 
241
267
  `customer-service` is the exception here too — a request with no
@@ -385,6 +411,18 @@ const modelCall = createOpenAIModelCall({ model: 'gpt-4.1' })
385
411
  const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
386
412
  ```
387
413
 
414
+ ```ts
415
+ import { createDeepSeekModelCall } from './deepseek-model-call.js'
416
+
417
+ // model is required, same reasoning as OpenAI's; reads DEEPSEEK_API_KEY
418
+ // from the env — throws immediately, with a DeepSeek-specific message, if
419
+ // neither that nor apiKey is set, rather than the OpenAI SDK's own
420
+ // constructor silently accepting OPENAI_API_KEY instead.
421
+ const modelCall = createDeepSeekModelCall({ model: 'deepseek-chat' })
422
+
423
+ const result = await runAgent(config, modelCall, 'order A-1001 arrived broken', [])
424
+ ```
425
+
388
426
  Nothing else in `run-agent.ts`, the adapters, or any `AgentConfig` needs to
389
427
  change — `ModelCall` is the only seam a real API call needs.
390
428
 
@@ -410,6 +448,15 @@ user-role message (mirroring Anthropic's shape); OpenAI has no equivalent —
410
448
  each becomes its own top-level `role: 'tool'` message, so one loopengine
411
449
  `Message` can expand into several OpenAI messages, never the reverse.
412
450
 
451
+ `deepseek-model-call.ts` reuses `openai-model-call.ts`'s translation
452
+ verbatim (exported, not duplicated) rather than reimplementing it —
453
+ DeepSeek's Chat Completions API is wire-compatible with OpenAI's (verified
454
+ against DeepSeek's own docs), down to the same `tool_calls` response
455
+ shape. The one real difference: DeepSeek documents `max_tokens`, not the
456
+ newer `max_completion_tokens` OpenAI's endpoint expects, so only the
457
+ request-building call site differs, not the shared message/response
458
+ translation functions.
459
+
413
460
  ## Skills
414
461
 
415
462
  Agents can discover and invoke `SKILL.md` files (see `skills/`), the same
@@ -446,10 +493,12 @@ index.ts Public API surface — what actually ships to npm
446
493
  agent-config.ts AgentConfig type — the thing you fill in to define an agent
447
494
  run-agent.ts The generic ReAct loop every agent and adapter runs through
448
495
  vector-index.ts Dependency-free embeddings + cosine-similarity search, for RAG
449
- agent-registry.ts Maps agent name -> {config, createModelCall}
496
+ discover-agents.ts discoverAgents — scans a directory, builds {name -> {config, createModelCall}}
497
+ agent-registry.ts This repo's ~15-line wrapper: discoverAgents('agents/') + listAgents()/getEntry()
450
498
  session-store.ts SessionStore: FileSessionStore, RedisSessionStore, createSessionStore()
451
499
  anthropic-model-call.ts createAnthropicModelCall — a real ModelCall this repo ships
452
500
  openai-model-call.ts createOpenAIModelCall — same seam, OpenAI's Chat Completions API
501
+ deepseek-model-call.ts createDeepSeekModelCall — same seam, reuses openai-model-call.ts's translation
453
502
  agents/file-agent.ts Example agent: summarizes text files
454
503
  agents/customer-service-agent.ts Example agent: order/shipment lookup, refund, email
455
504
  agents/rag-agent.ts Example agent: retrieves from an in-memory vector index
@@ -0,0 +1,15 @@
1
+ import OpenAI from 'openai';
2
+ import type { ModelCall } from './run-agent.js';
3
+ export interface DeepSeekModelCallOptions {
4
+ /** Defaults to the DEEPSEEK_API_KEY env var. */
5
+ apiKey?: string;
6
+ /** No hardcoded default — same reasoning as OpenAIModelCallOptions.model:
7
+ * pass the one you actually want. */
8
+ model: string;
9
+ maxTokens?: number;
10
+ /** Defaults to DeepSeek's own API — override to point at a proxy/mirror. */
11
+ baseURL?: string;
12
+ /** Inject a pre-configured client instead of building one from apiKey/baseURL — e.g. to pass a custom `fetch` in tests. */
13
+ client?: OpenAI;
14
+ }
15
+ export declare function createDeepSeekModelCall(options: DeepSeekModelCallOptions): ModelCall;
@@ -0,0 +1,53 @@
1
+ // A third real ModelCall implementation. DeepSeek's Chat Completions API
2
+ // is wire-compatible with OpenAI's — confirmed against DeepSeek's own docs
3
+ // (api-docs.deepseek.com/api/create-chat-completion/): same messages/
4
+ // tools/tool_choice request shape, same tool_calls response shape — so
5
+ // this reuses openai-model-call.ts's message/response translation
6
+ // verbatim via the real `openai` SDK client, just pointed at DeepSeek's
7
+ // base URL, rather than duplicating that logic.
8
+ //
9
+ // The one real difference: DeepSeek's docs list `max_tokens` as the
10
+ // token-limit field, not the newer `max_completion_tokens`
11
+ // openai-model-call.ts uses (OpenAI deprecated max_tokens in favor of
12
+ // max_completion_tokens; DeepSeek's endpoint doesn't document the newer
13
+ // field at all) — decided at the request-building call site below, since
14
+ // toMessageParams/toModelResponse don't touch that field either way.
15
+ import OpenAI from 'openai';
16
+ import { toMessageParams, toModelResponse } from './openai-model-call.js';
17
+ export function createDeepSeekModelCall(options) {
18
+ if (!options.client && !options.apiKey && !process.env.DEEPSEEK_API_KEY) {
19
+ // Fails here, with a DeepSeek-specific message, rather than letting
20
+ // the OpenAI SDK's own constructor validation reject it — that error
21
+ // says "set OPENAI_API_KEY", which would send someone who never
22
+ // touched that variable chasing the wrong fix.
23
+ throw new Error('createDeepSeekModelCall: no apiKey given and DEEPSEEK_API_KEY is not set.');
24
+ }
25
+ const client = options.client ??
26
+ new OpenAI({
27
+ // Explicitly null, not undefined, if neither is set — the OpenAI
28
+ // SDK's own constructor falls back to reading OPENAI_API_KEY itself
29
+ // when apiKey is undefined (its normal, correct behavior for
30
+ // createOpenAIModelCall), which here would silently authenticate
31
+ // against DeepSeek with an OpenAI key if one happens to be set. The
32
+ // guard above already rules out reaching here with no key at all,
33
+ // but null (not undefined) is kept regardless so this line is
34
+ // correct on its own, not just because of the guard before it.
35
+ apiKey: options.apiKey ?? process.env.DEEPSEEK_API_KEY ?? null,
36
+ baseURL: options.baseURL ?? 'https://api.deepseek.com',
37
+ });
38
+ const maxTokens = options.maxTokens ?? 4096;
39
+ return async (messages, system, tools) => {
40
+ const response = await client.chat.completions.create({
41
+ model: options.model,
42
+ max_tokens: maxTokens,
43
+ messages: [{ role: 'system', content: system }, ...messages.flatMap(toMessageParams)],
44
+ tools: tools.length
45
+ ? tools.map((t) => ({
46
+ type: 'function',
47
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
48
+ }))
49
+ : undefined,
50
+ });
51
+ return toModelResponse(response.choices[0]);
52
+ };
53
+ }
@@ -0,0 +1,16 @@
1
+ import type { AgentConfig } from './agent-config.js';
2
+ import type { ModelCall } from './run-agent.js';
3
+ export interface AgentModule {
4
+ config: AgentConfig;
5
+ /** Factory, not a shared instance — a fresh ModelCall per request/session. */
6
+ createModelCall: () => ModelCall;
7
+ }
8
+ /** Scans `dir`'s direct files only (not subdirectories — put shared,
9
+ * non-agent code in a subdirectory to keep it out of discovery, no
10
+ * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
11
+ * expected to export `config` and `createModelCall` — the same shape
12
+ * every agents/*.ts file in this repo's own reference app already
13
+ * exports. Throws, rather than skipping, on a file that doesn't match
14
+ * that shape or a duplicate AgentConfig.name — both are almost always a
15
+ * bug in the agent file, not something to silently ignore. */
16
+ export declare function discoverAgents(dir: string): Promise<Map<string, AgentModule>>;
@@ -0,0 +1,48 @@
1
+ // Scans a directory for agent modules and builds a name -> module map from
2
+ // it — the thing that makes "drop a new file in agents/" enough to
3
+ // register an agent, no hand-written import-and-add-one-line edit needed
4
+ // anywhere else. See agent-registry.ts for the one place this repo's own
5
+ // reference app actually calls it.
6
+ //
7
+ // Keyed by AgentConfig.name, not the filename — that's the same identity
8
+ // ActAuth's scope.agent segment already uses (see AgentConfig.name's own
9
+ // doc comment), so this doesn't introduce a second naming scheme. Two
10
+ // files that happened to declare the same config.name would already
11
+ // silently collide in ActAuth's rules today regardless of this helper;
12
+ // surfacing that collision loudly here, at discovery time, is a real
13
+ // safety property this adds, not just convenience.
14
+ import { readdirSync } from 'node:fs';
15
+ import { resolve } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ function isAgentModule(mod) {
18
+ return (typeof mod === 'object' &&
19
+ mod !== null &&
20
+ 'config' in mod &&
21
+ 'createModelCall' in mod &&
22
+ typeof mod.createModelCall === 'function');
23
+ }
24
+ /** Scans `dir`'s direct files only (not subdirectories — put shared,
25
+ * non-agent code in a subdirectory to keep it out of discovery, no
26
+ * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
27
+ * expected to export `config` and `createModelCall` — the same shape
28
+ * every agents/*.ts file in this repo's own reference app already
29
+ * exports. Throws, rather than skipping, on a file that doesn't match
30
+ * that shape or a duplicate AgentConfig.name — both are almost always a
31
+ * bug in the agent file, not something to silently ignore. */
32
+ export async function discoverAgents(dir) {
33
+ const entries = new Map();
34
+ const files = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts'));
35
+ for (const file of files) {
36
+ const modulePath = resolve(dir, file.name);
37
+ const mod = await import(pathToFileURL(modulePath).href);
38
+ if (!isAgentModule(mod)) {
39
+ throw new Error(`${file.name} in ${dir} does not export both 'config' and 'createModelCall' — every agent module must.`);
40
+ }
41
+ const name = mod.config.name;
42
+ if (entries.has(name)) {
43
+ throw new Error(`Duplicate agent name '${name}' — ${file.name} in ${dir} isn't the first file to declare AgentConfig.name '${name}'.`);
44
+ }
45
+ entries.set(name, mod);
46
+ }
47
+ return entries;
48
+ }
package/dist/index.d.ts CHANGED
@@ -9,3 +9,7 @@ export { createAnthropicModelCall } from './anthropic-model-call.js';
9
9
  export type { AnthropicModelCallOptions } from './anthropic-model-call.js';
10
10
  export { createOpenAIModelCall } from './openai-model-call.js';
11
11
  export type { OpenAIModelCallOptions } from './openai-model-call.js';
12
+ export { createDeepSeekModelCall } from './deepseek-model-call.js';
13
+ export type { DeepSeekModelCallOptions } from './deepseek-model-call.js';
14
+ export { discoverAgents } from './discover-agents.js';
15
+ export type { AgentModule } from './discover-agents.js';
package/dist/index.js CHANGED
@@ -8,3 +8,5 @@ export { FileSessionStore, RedisSessionStore, createSessionStore } from './sessi
8
8
  export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
9
9
  export { createAnthropicModelCall } from './anthropic-model-call.js';
10
10
  export { createOpenAIModelCall } from './openai-model-call.js';
11
+ export { createDeepSeekModelCall } from './deepseek-model-call.js';
12
+ export { discoverAgents } from './discover-agents.js';
@@ -1,5 +1,6 @@
1
1
  import OpenAI from 'openai';
2
- import type { ModelCall } from './run-agent.js';
2
+ import type { Message, ModelCall, ModelResponse } from './run-agent.js';
3
+ type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
3
4
  export interface OpenAIModelCallOptions {
4
5
  /** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
5
6
  apiKey?: string;
@@ -11,4 +12,8 @@ export interface OpenAIModelCallOptions {
11
12
  /** Inject a pre-configured client instead of building one from apiKey — e.g. to pass a custom `fetch` in tests. */
12
13
  client?: OpenAI;
13
14
  }
15
+ export declare function toMessageParams(message: Message): ChatMessage[];
16
+ export declare function toStopReason(finishReason: string): string;
17
+ export declare function toModelResponse(choice: OpenAI.Chat.Completions.ChatCompletion.Choice): ModelResponse;
14
18
  export declare function createOpenAIModelCall(options: OpenAIModelCallOptions): ModelCall;
19
+ export {};
@@ -17,8 +17,15 @@
17
17
  // defaultIsTruncated checks a translated ModelResponse for stop_reason ===
18
18
  // 'max_tokens', so a provider-agnostic caller wiring up onTruncated later
19
19
  // gets the same behavior regardless of which ModelCall is plugged in.
20
+ //
21
+ // toMessageParams/toStopReason/toModelResponse are exported for
22
+ // deepseek-model-call.ts to reuse verbatim — DeepSeek's Chat Completions
23
+ // API is wire-compatible with this same request/response shape (confirmed
24
+ // against DeepSeek's own docs), the one real difference being the token-
25
+ // limit field name, which is only decided at the request-building call
26
+ // site below, not in these shared translation functions.
20
27
  import OpenAI from 'openai';
21
- function toMessageParams(message) {
28
+ export function toMessageParams(message) {
22
29
  if (typeof message.content === 'string') {
23
30
  return [{ role: message.role === 'assistant' ? 'assistant' : 'user', content: message.content }];
24
31
  }
@@ -54,7 +61,7 @@ function toMessageParams(message) {
54
61
  content: block.content ?? '',
55
62
  }));
56
63
  }
57
- function toStopReason(finishReason) {
64
+ export function toStopReason(finishReason) {
58
65
  if (finishReason === 'tool_calls' || finishReason === 'function_call')
59
66
  return 'tool_use';
60
67
  if (finishReason === 'length')
@@ -63,6 +70,26 @@ function toStopReason(finishReason) {
63
70
  return 'end_turn';
64
71
  return finishReason;
65
72
  }
73
+ export function toModelResponse(choice) {
74
+ const content = [];
75
+ if (choice.message.content)
76
+ content.push({ type: 'text', text: choice.message.content });
77
+ for (const call of choice.message.tool_calls ?? []) {
78
+ // A 'custom' tool call is the freeform-text tool type OpenAI added
79
+ // alongside 'function' — this framework's ToolDefinition only ever
80
+ // declares 'function'-shaped tools, so a custom call can't have come
81
+ // from anything this loop offered the model.
82
+ if (call.type !== 'function')
83
+ continue;
84
+ content.push({
85
+ type: 'tool_use',
86
+ id: call.id,
87
+ name: call.function.name,
88
+ input: JSON.parse(call.function.arguments),
89
+ });
90
+ }
91
+ return { stop_reason: toStopReason(choice.finish_reason), content };
92
+ }
66
93
  export function createOpenAIModelCall(options) {
67
94
  const client = options.client ?? new OpenAI({ apiKey: options.apiKey });
68
95
  const maxTokens = options.maxTokens ?? 4096;
@@ -78,24 +105,6 @@ export function createOpenAIModelCall(options) {
78
105
  }))
79
106
  : undefined,
80
107
  });
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 };
108
+ return toModelResponse(response.choices[0]);
100
109
  };
101
110
  }
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.0.4",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "A minimal framework for defining and running tool-using AI agents over CLI, HTTP, or both.",
6
6
  "license": "MIT",
7
+ "homepage": "https://loopengine.co",
7
8
  "repository": {
8
9
  "type": "git",
9
10
  "url": "git+https://github.com/evanyan22/loopengine.git"
@@ -25,6 +26,10 @@
25
26
  "dist/anthropic-model-call.d.ts",
26
27
  "dist/openai-model-call.js",
27
28
  "dist/openai-model-call.d.ts",
29
+ "dist/deepseek-model-call.js",
30
+ "dist/deepseek-model-call.d.ts",
31
+ "dist/discover-agents.js",
32
+ "dist/discover-agents.d.ts",
28
33
  "LICENSE"
29
34
  ],
30
35
  "scripts": {