loopengine 0.0.18 → 0.0.21

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.
Files changed (40) hide show
  1. package/PROTOCOL.md +245 -0
  2. package/README.md +44 -5
  3. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  4. package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
  5. package/dist/{cli.js → bin/cli.js} +131 -16
  6. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  7. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  8. package/dist/core/client.d.ts +113 -0
  9. package/dist/core/client.js +123 -0
  10. package/dist/core/discover-agents.d.ts +45 -0
  11. package/dist/{discover-agents.js → core/discover-agents.js} +11 -3
  12. package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
  13. package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
  14. package/dist/core/loop-events.d.ts +175 -0
  15. package/dist/core/loop-events.js +1 -0
  16. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  18. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  19. package/dist/{model-calls → core/model-calls}/openai-model-call.js +1 -1
  20. package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
  21. package/dist/{run-agent.js → core/run-agent.js} +242 -60
  22. package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
  23. package/dist/{session-store.js → core/session-store.js} +19 -0
  24. package/dist/core/system-tools/ask_user.d.ts +72 -0
  25. package/dist/core/system-tools/ask_user.js +136 -0
  26. package/dist/core/system-tools/index.d.ts +3 -0
  27. package/dist/core/system-tools/index.js +12 -0
  28. package/dist/core/system-tools/read_file.d.ts +28 -0
  29. package/dist/core/system-tools/read_file.js +64 -0
  30. package/dist/{vector-index.js → core/vector-index.js} +1 -1
  31. package/dist/index.d.ts +19 -16
  32. package/dist/index.js +16 -9
  33. package/package.json +34 -28
  34. package/protocol/loop-event.schema.json +257 -0
  35. package/dist/discover-agents.d.ts +0 -24
  36. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  37. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  38. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  39. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  40. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
package/PROTOCOL.md ADDED
@@ -0,0 +1,245 @@
1
+ # The loopengine event protocol
2
+
3
+ **Status:** v1 · **Schema:** [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
4
+
5
+ One `runAgent()` turn emits an ordered sequence of typed `LoopEvent`
6
+ objects. That sequence — not any particular transport — is the actual
7
+ contract. The SSE stream, the plain HTTP response, and the CLI's `--json`
8
+ mode are three different ways of delivering the exact same objects; a
9
+ client written against one of them can switch to another without
10
+ changing how it interprets a single event.
11
+
12
+ This document specifies that sequence: every event's shape, when it
13
+ fires, what's guaranteed about ordering, and how each transport carries
14
+ it. If you're building a UI or integration against loopengine in any
15
+ language — not just TypeScript — this is what to implement against
16
+ instead of reverse-engineering `core/loop-events.ts`.
17
+
18
+ ## Stability
19
+
20
+ This is v1. Within v1:
21
+
22
+ - **Additive is safe to rely on being additive.** A new event `type` may
23
+ be introduced; a new optional field may be added to an existing event.
24
+ A consumer that ignores unrecognized `type` values and unrecognized
25
+ fields will never break.
26
+ - **These are breaking changes**, and won't happen inside v1: renaming or
27
+ removing an event `type`; renaming, removing, or changing the meaning
28
+ of an existing field; turning an optional field required; changing an
29
+ established transport's framing (SSE's `event: <type>\ndata: <json>`,
30
+ the plain response's `events` array, or `--json`'s one-object-per-line
31
+ NDJSON).
32
+ - A breaking change ships as v2, documented as a new section here, not a
33
+ silent edit to this one.
34
+
35
+ ## Lifecycle
36
+
37
+ Every event has a `type` field, which doubles as the discriminant and,
38
+ on the SSE transport, the literal frame name. Two families:
39
+
40
+ - **Engine events** — emitted by `runAgent()` itself, identically
41
+ regardless of which transport is carrying them.
42
+ - **Adapter events** — synthesized by the HTTP/CLI adapter around a turn
43
+ (`session`, `approval:pending`, `question:pending`, `done`, `error`).
44
+ `runAgent()` itself has no notion of sessions or HTTP-level failures;
45
+ these exist because a real caller needs them.
46
+
47
+ A turn always starts with `session` (streamed) and always ends with
48
+ exactly one `done`. The only real branch is the ActAuth Gate's decision
49
+ on each requested tool call:
50
+
51
+ ```
52
+ session → budget:check → assistant:text? → actauth:decision
53
+ │
54
+ ┌────────────────────────┴───────────────────────┐
55
+ │ allow │ deny
56
+ ▼ ▼
57
+ [approval:pending first, tool:result (statusText: "Denied.")
58
+ if the rule said "ask"] — no execution, no tool:started.
59
+ │ Any sibling call already approved
60
+ ▼ in the same batch gets loop:skipped
61
+ tool:started + its own tool:result ("Skipped.")
62
+ ▼ │
63
+ toollane:result │
64
+ ▼ │
65
+ tool:result (Approved./Error.) │
66
+ │ │
67
+ └─────────────────┬──────────────────────────────┘
68
+ ▼
69
+ loops back to budget:check for the
70
+ next tool call, or the model's next turn,
71
+ until exactly one of:
72
+ loop:done / loop:max_turns / loop:denied
73
+ ▼
74
+ done
75
+ ```
76
+
77
+ `approval:pending`/`question:pending` are the only events without a
78
+ fixed position in that diagram — they can arrive at any point a tool call
79
+ is being evaluated. At most one is ever pending at a time (the Gate
80
+ evaluates one call before starting the next), so a consumer never needs
81
+ to track more than one open decision per turn.
82
+
83
+ ## Event catalog
84
+
85
+ ### Engine — emitted by `runAgent()`
86
+
87
+ | `type` | Fires | Fields |
88
+ |---|---|---|
89
+ | `budget:check` | Once per model call, before it | `action: 'ok'\|'nudge'\|'over_hard_limit'`, `usedTokens`, `budgetTokens`, `ratio`, `nudge?` |
90
+ | `prompt:compaction` | A prompt was rejected as too large | `from`, `to` (message-array lengths, not tokens) |
91
+ | `assistant:text` | The model's "I'll do X" preamble alongside a tool call | `text` |
92
+ | `actauth:decision` | Once per requested tool call | `tool`, `decision: 'allow'\|'ask'\|'deny'`, `reason` |
93
+ | `tool:started` | The instant a call is decided, before it runs | `id`, `tool`, `args`, `detailText` |
94
+ | `tool:result` | A call resolves — allowed, denied, or skipped | `id`, `tool`, `args?`, `detailText?`, `statusText` |
95
+ | `toollane:result` | ToolLane's own raw outcome, alongside `tool:result` | `name`, `summary` |
96
+ | `skill:loaded` | A `Skill` block resolved to a real skill body | `skill` |
97
+ | `loop:skipped` | A sibling call never ran because another was denied | `name`, `deniedTools` |
98
+ | `loop:max_turns` | Hit `config.maxTurns` with no final answer | `maxTurns` |
99
+ | `loop:denied` | A human denied a call — the turn stops | `deniedTools` |
100
+ | `loop:done` | A genuine finish — no more tool calls | `text` |
101
+
102
+ ### Adapter — synthesized around a turn
103
+
104
+ | `type` | Fires | Fields |
105
+ |---|---|---|
106
+ | `session` | First event of every streamed turn | `sessionId` |
107
+ | `approval:pending` | A tool call needs a live human decision | `id`, `tool`, `args`, `scope: {tenant,environment,agent}`, `reason`, `requestedAt` |
108
+ | `question:pending` | The `ask_user` tool raised a real question | `id`, `question`, `options?`, `agent`, `sessionId?`, `requestedAt` |
109
+ | `done` | The turn is over — wraps `loop:done`/`max_turns`/`denied` | `text`, `stopReason?: 'max_turns'\|'denied'` |
110
+ | `error` | Something failed after the response was already committed | `error` |
111
+
112
+ Full field types, `required` vs optional, and `additionalProperties: false`
113
+ enforcement live in [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
114
+ — the table above is a summary, the schema is the source of truth.
115
+
116
+ ## Transports
117
+
118
+ ### SSE stream — live, frame by frame
119
+
120
+ ```
121
+ POST /agents/:name/messages/stream
122
+ content-type: application/json
123
+
124
+ {"message": "order A-1001 arrived broken, can you refund it?"}
125
+ ```
126
+
127
+ Each frame is `event: <type>\ndata: <json>\n\n`, `<json>` being the whole
128
+ event object (`type` included, redundantly with the frame name — parse
129
+ whichever is more convenient):
130
+
131
+ ```
132
+ event: tool:started
133
+ data: {"type":"tool:started","id":"call_1","tool":"issue_refund","args":{"orderId":"A-1001"},"detailText":"matched rule 'refunds-need-approval'"}
134
+
135
+ event: approval:pending
136
+ data: {"type":"approval:pending","id":"3f9c...","tool":"issue_refund","args":{"orderId":"A-1001"},"scope":{"tenant":"default","environment":"production","agent":"customer-service"},"reason":"matched rule 'refunds-need-approval'","requestedAt":"2026-08-28T01:34:32.193Z"}
137
+ ```
138
+
139
+ ### Plain HTTP — one request, the full array
140
+
141
+ ```
142
+ POST /agents/:name/messages
143
+ content-type: application/json
144
+
145
+ {"message": "..."}
146
+ ```
147
+
148
+ Two response shapes, both carrying the identical `events` array a
149
+ streamed version of the same turn would have delivered live:
150
+
151
+ **200 — the turn finished:**
152
+
153
+ ```json
154
+ {
155
+ "text": "Refunded $42.00 to your original payment method.",
156
+ "sessionId": "5a83c6b4-...",
157
+ "events": [ /* every LoopEvent this turn produced, in order */ ],
158
+ "stopReason": "max_turns"
159
+ }
160
+ ```
161
+
162
+ `stopReason` is present only for a synthetic finish (`max_turns`/`denied`).
163
+
164
+ **202 — the turn needs a decision right now:**
165
+
166
+ ```json
167
+ {
168
+ "pending": true,
169
+ "type": "approval",
170
+ "id": "3f9c...",
171
+ "sessionId": "5a83c6b4-...",
172
+ "tool": "issue_refund",
173
+ "args": { "orderId": "A-1001" },
174
+ "scope": { "tenant": "default", "environment": "production", "agent": "customer-service" },
175
+ "reason": "matched rule 'refunds-need-approval'",
176
+ "approveUrl": "/approvals/3f9c.../approve",
177
+ "denyUrl": "/approvals/3f9c.../deny",
178
+ "events": [ /* everything so far */ ],
179
+ "statusUrl": "/agents/customer-service/sessions/5a83c6b4-..."
180
+ }
181
+ ```
182
+
183
+ (`type: "question"` carries `question`/`options`/`answerUrl` instead.)
184
+
185
+ ### CLI — one JSON object per line
186
+
187
+ ```
188
+ loopengine run customer-service --json "order A-1001 arrived broken, can you refund it?"
189
+ ```
190
+
191
+ ```
192
+ {"type":"session","sessionId":"5a83c6b4-..."}
193
+ {"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":8000,"ratio":0.00025}
194
+ {"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
195
+ {"type":"done","text":"Refunded $42.00..."}
196
+ ```
197
+
198
+ Every line is a complete `LoopEvent`, pipeable straight into `jq` or
199
+ anything else that reads NDJSON. Without `--json`, stdout stays
200
+ human-readable (just the final answer) and events go to stderr instead.
201
+
202
+ ### Resolving a pending item
203
+
204
+ ```
205
+ POST /approvals/:id/approve POST /approvals/:id/deny
206
+ POST /questions/:id/answer {"answer": "..."}
207
+ ```
208
+
209
+ All three return the exact same two-shape family as plain
210
+ `POST /messages` above (200 done, or 202 the *next* pending item, if the
211
+ turn needed another decision) — a caller can treat send → approve/deny/
212
+ answer → approve/deny/answer → ... as one uniform loop regardless of
213
+ which endpoint produced the response.
214
+
215
+ ## Validating against the schema
216
+
217
+ ```js
218
+ import Ajv2020 from 'ajv/dist/2020.js'
219
+ import addFormats from 'ajv-formats' // schema uses format: 'date-time'
220
+ import schema from './protocol/loop-event.schema.json' with { type: 'json' }
221
+
222
+ const ajv = new Ajv2020({ strict: true })
223
+ addFormats(ajv)
224
+ const validate = ajv.compile(schema)
225
+ validate(event) // true/false; validate.errors on failure
226
+ ```
227
+
228
+ The schema is hand-maintained alongside `core/loop-events.ts`, not
229
+ generated from it — a PR that changes one without the other is
230
+ incomplete. It's plain JSON Schema (draft 2020-12), so it works from any
231
+ language with a validator, not just JavaScript.
232
+
233
+ ## Reference implementations
234
+
235
+ - **`core/client.ts`** — the framework-agnostic TypeScript client this
236
+ spec was extracted from: SSE parsing, the plain-request helpers,
237
+ approve/deny/answer. Re-exported through the package's own entry point.
238
+ - **`ui-examples/react/`**, **`ui-examples/vue/`** — a hook/composable
239
+ and a chat component each, built on `core/client.ts`, rendering
240
+ `approval:pending`/`question:pending` as real UI.
241
+
242
+ Implementing this protocol in another language means reproducing what
243
+ `core/client.ts` does: parse whichever transport you chose into
244
+ `LoopEvent` objects per this schema, and drive your own UI or logic off
245
+ `type`.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  LoopEngine is a runtime for building AI agents: a persona, a set of tools,
6
6
  and permission rules, run through a transparent ReAct loop. No chain DSL,
7
- no hidden control flow — `run-agent.ts` is a single function you can read
7
+ no hidden control flow — `core/run-agent.ts` is a single function you can read
8
8
  top to bottom. Run agents over a CLI, an HTTP API, or both.
9
9
 
10
10
  Once you define an agent, the loop turns it into something you can put in
@@ -37,7 +37,7 @@ npx create-loopengine@latest my-agents
37
37
  cd my-agents
38
38
  npm install
39
39
  cp .env.example .env # fill in ANTHROPIC_API_KEY
40
- npm run dev # HTTP server on :8787
40
+ npm run dev # HTTP server on :8787 — same as `npx loopengine dev`
41
41
  ```
42
42
 
43
43
  This scaffolds a standalone project — its own repo, with `loopengine` as
@@ -96,7 +96,7 @@ working examples.
96
96
 
97
97
  ### 1. The Loop
98
98
 
99
- `run-agent.ts` is the whole engine — a single, readable ReAct loop with no
99
+ `core/run-agent.ts` is the whole engine — a single, readable ReAct loop with no
100
100
  hidden control flow: call the model, act on what it asks for, repeat.
101
101
 
102
102
  1. Call the model with the conversation so far.
@@ -338,6 +338,37 @@ starting an anonymous session). Without it, the caller just supplies a
338
338
 
339
339
  ## Running an agent
340
340
 
341
+ `adapters/cli.ts` and `adapters/http.ts` are files your own scaffolded
342
+ project owns (see "Quick start" above), not something hidden inside the
343
+ `loopengine` package — run them directly with `tsx`, or through shorter
344
+ `loopengine` commands that just call the same files for you:
345
+
346
+ ```bash
347
+ npx loopengine run customer-service --session s1 "order A-1001 arrived broken"
348
+ npx loopengine run customer-service --session s1 --input "order A-1001 arrived broken"
349
+ npx loopengine serve # HTTP server on :8787
350
+ npx loopengine dev # same server, restarts on file changes (tsx watch)
351
+ ```
352
+
353
+ `--session <id>` is just an arbitrary string you pick — `s1` above isn't
354
+ special, it's only a label. Omit it entirely for a fresh, one-off
355
+ conversation each call; reuse the same value on a later call to continue
356
+ that exact conversation (the id is printed to stderr if you omit it, so
357
+ you can capture it for next time). `--input "<message>"` is an
358
+ alternative to the trailing positional message, for scripts that build
359
+ the argument list programmatically and would rather not depend on the
360
+ message always being the last argument.
361
+
362
+ `run`/`serve`/`dev` are thin wrappers, not a separate implementation —
363
+ each shells out to your project's own `adapters/cli.ts` or
364
+ `adapters/http.ts` via `npx tsx` (so it's always *your* copy that runs,
365
+ edits included, resolved from your project's own `node_modules`), and
366
+ fails with a clear message pointing at `create-loopengine` if that file
367
+ doesn't exist yet. `.env` is loaded automatically for all three,
368
+ equivalent to passing `--env-file-if-exists=.env` to `tsx` yourself.
369
+
370
+ The same thing, spelled out without the wrapper:
371
+
341
372
  **CLI:**
342
373
 
343
374
  ```bash
@@ -372,6 +403,14 @@ into the playground and config page below.
372
403
  that same loop-step event stream render live instead of reading raw SSE
373
404
  frames. Same `/messages/stream` route underneath.
374
405
 
406
+ **Building your own client:** the playground is one UI on top of a typed
407
+ event protocol, not the only way to consume a turn — see
408
+ [`PROTOCOL.md`](PROTOCOL.md) for the full `LoopEvent` catalog, the SSE/
409
+ plain-HTTP/CLI transport bindings, and a JSON Schema
410
+ (`protocol/loop-event.schema.json`) any language can validate against.
411
+ `core/client.ts` and the `ui-examples/react`/`ui-examples/vue` hooks are
412
+ reference implementations of it.
413
+
375
414
  **Agent config page:** open `http://localhost:8787/agents/config`
376
415
  (optionally `?agent=<name>`) to browse every registered agent's resolved
377
416
  config — system prompt, model, tools (with their JSON schemas and
@@ -402,8 +441,8 @@ testing), export your own `createModelCall(): ModelCall` instead.
402
441
  | Package | Responsibility |
403
442
  |---|---|
404
443
  | [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
405
- | [`contextclip`](https://www.npmjs.com/package/contextclip) | Context-window budget tracking |
406
- | [`reflowkit`](https://www.npmjs.com/package/reflowkit) | Retries prompt-too-long / truncated-output failures |
444
+ | `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
445
+ | `core/recovery.ts` | Retries prompt-too-long / truncated-output failures (vendored in-repo, not an external dependency) |
407
446
  | [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
408
447
  | [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
409
448
  | [`toollane`](https://www.npmjs.com/package/toollane) | Parallel/solo tool-call scheduling |
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: composio-large-outputs
3
+ description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
4
+ ---
5
+
6
+ # Gateway tools with a large result
7
+
8
+ Some gateway tools (Composio-sourced ones in particular) return a small
9
+ pointer object instead of their real output when the actual result is
10
+ too large to return inline:
11
+
12
+ ```
13
+ {
14
+ "successful": true,
15
+ "storedInFile": true,
16
+ "tokenCount": 10234,
17
+ "outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
18
+ }
19
+ ```
20
+
21
+ When you see `storedInFile: true` in a tool result, the real data is
22
+ **not** in that object — it's in the file at `outputFilePath`. Call
23
+ `system_read_file` with that exact path to retrieve the actual content
24
+ before answering. Don't guess at an answer, summarize from the pointer
25
+ alone, or claim you don't have the data — read the file first.
@@ -5,19 +5,39 @@ export declare class AgentExistsError extends Error {
5
5
  }
6
6
  export declare class AgentNotFoundError extends Error {
7
7
  }
8
- export declare function agentIndexTemplate(name: string): string;
8
+ export declare class AgentModelError extends Error {
9
+ }
10
+ type Provider = 'anthropic' | 'openai' | 'deepseek';
11
+ export interface AgentTemplateOptions {
12
+ systemPrompt?: string;
13
+ model?: {
14
+ provider: Provider;
15
+ model?: string;
16
+ };
17
+ }
18
+ /** Generates agents/<name>/index.ts's real content. `options` is
19
+ * entirely optional and defaults to exactly what this template always
20
+ * wrote before it existed ('You are ...', anthropic/claude-sonnet-5) —
21
+ * `loopengine add-agent <name>` (which never passes it) produces
22
+ * byte-identical output to before this existed. openai/deepseek have no
23
+ * default model (only anthropic's is optional — see AgentModelConfig's
24
+ * own doc comment), so scaffoldAgent validates a model name was given
25
+ * for those rather than this function silently writing a wrong one. */
26
+ export declare function agentIndexTemplate(name: string, options?: AgentTemplateOptions, importSpecifier?: string): string;
9
27
  /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
10
28
  * field a subagent needs that a top-level agent doesn't. run-agent.ts's
11
29
  * loadSubagentAsTools wraps this config with agentAsTool, which throws
12
30
  * without a real toolDescription: it's what the parent agent's model
13
31
  * reads to decide when to delegate here, and systemPrompt (instructions
14
32
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
15
- export declare function subagentIndexTemplate(name: string): string;
33
+ export declare function subagentIndexTemplate(name: string, importSpecifier?: string): string;
16
34
  /** Writes agents/<name>/index.ts under baseDir and returns its path.
17
35
  * Only that one file — tools, rules, and skillsDirs are left to their
18
36
  * smart defaults (see AgentConfig's own doc comments), the same as the
19
- * README's own "simplest possible agent" example. */
20
- export declare function scaffoldAgent(baseDir: string, name: string): Promise<string>;
37
+ * README's own "simplest possible agent" example. `options` (systemPrompt,
38
+ * model) is entirely optional — see agentIndexTemplate's own doc comment
39
+ * for the defaults used when omitted. */
40
+ export declare function scaffoldAgent(baseDir: string, name: string, options?: AgentTemplateOptions): Promise<string>;
21
41
  /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
22
42
  * returns its path, where <parentDir> is `parent` resolved by
23
43
  * resolveParentDir — the folder convention run-agent.ts's
@@ -29,3 +49,4 @@ export declare function scaffoldAgent(baseDir: string, name: string): Promise<st
29
49
  * scaffoldAgent already covers. Nesting more than one level is just
30
50
  * `parent` itself being a path — see resolveParentDir. */
31
51
  export declare function scaffoldSubagent(baseDir: string, parent: string, name: string): Promise<string>;
52
+ export {};
@@ -9,7 +9,7 @@
9
9
  // these stay thin wrappers around those files rather than a built-in
10
10
  // runner/server this package hides inside itself.
11
11
  import { realpathSync } from 'node:fs';
12
- import { access, mkdir, writeFile } from 'node:fs/promises';
12
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { spawn } from 'node:child_process';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
@@ -20,13 +20,61 @@ export class AgentExistsError extends Error {
20
20
  }
21
21
  export class AgentNotFoundError extends Error {
22
22
  }
23
- export function agentIndexTemplate(name) {
24
- return `import type { AgentConfig } from 'loopengine'
23
+ export class AgentModelError extends Error {
24
+ }
25
+ const MODEL_ENV_VAR = {
26
+ anthropic: 'ANTHROPIC_API_KEY',
27
+ openai: 'OPENAI_API_KEY',
28
+ deepseek: 'DEEPSEEK_API_KEY',
29
+ };
30
+ // Escapes a value for safe interpolation into a single-quoted TS string
31
+ // literal in generated code — an operator-supplied systemPrompt (from
32
+ // the admin UI's Create new agent form, say) containing a literal quote
33
+ // or backslash would otherwise produce invalid, or silently different,
34
+ // generated TypeScript.
35
+ function tsStringLiteral(value) {
36
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
37
+ }
38
+ /** 'loopengine' for a real consumer project — the only thing that ever
39
+ * resolves once this package is genuinely installed as a dependency. A
40
+ * checkout of this repo itself has no such install (see this repo's own
41
+ * git history around the reverted self-link attempt: pointing
42
+ * node_modules/loopengine back at itself collides with tsc trying to
43
+ * write dist/ while also reading it as an input), so a file scaffolded
44
+ * *into this repo* — an agent created via `loopengine dev`'s admin UI
45
+ * while hacking on loopengine itself, say — needs the same internal
46
+ * alias every hand-maintained demo agent here already uses instead.
47
+ * Detected by baseDir's own package.json name rather than a flag the
48
+ * caller has to remember to pass, so this can never drift out of sync
49
+ * with which project is actually being scaffolded into. */
50
+ async function configImportSpecifier(baseDir) {
51
+ try {
52
+ const raw = await readFile(path.join(baseDir, 'package.json'), 'utf8');
53
+ const pkg = JSON.parse(raw);
54
+ return pkg.name === 'loopengine' ? '#core/agent-config.js' : 'loopengine';
55
+ }
56
+ catch {
57
+ return 'loopengine';
58
+ }
59
+ }
60
+ /** Generates agents/<name>/index.ts's real content. `options` is
61
+ * entirely optional and defaults to exactly what this template always
62
+ * wrote before it existed ('You are ...', anthropic/claude-sonnet-5) —
63
+ * `loopengine add-agent <name>` (which never passes it) produces
64
+ * byte-identical output to before this existed. openai/deepseek have no
65
+ * default model (only anthropic's is optional — see AgentModelConfig's
66
+ * own doc comment), so scaffoldAgent validates a model name was given
67
+ * for those rather than this function silently writing a wrong one. */
68
+ export function agentIndexTemplate(name, options = {}, importSpecifier = 'loopengine') {
69
+ const systemPrompt = options.systemPrompt?.trim() || 'You are ...';
70
+ const provider = options.model?.provider ?? 'anthropic';
71
+ const modelName = options.model?.model?.trim() || (provider === 'anthropic' ? 'claude-sonnet-5' : '');
72
+ return `import type { AgentConfig } from '${importSpecifier}'
25
73
 
26
74
  export const config: AgentConfig = {
27
75
  name: '${name}',
28
- systemPrompt: 'You are ...',
29
- model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
76
+ systemPrompt: ${tsStringLiteral(systemPrompt)},
77
+ model: { provider: '${provider}', model: ${tsStringLiteral(modelName)} }, // reads ${MODEL_ENV_VAR[provider]}
30
78
  }
31
79
  `;
32
80
  }
@@ -36,8 +84,8 @@ export const config: AgentConfig = {
36
84
  * without a real toolDescription: it's what the parent agent's model
37
85
  * reads to decide when to delegate here, and systemPrompt (instructions
38
86
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
39
- export function subagentIndexTemplate(name) {
40
- return `import type { AgentConfig } from 'loopengine'
87
+ export function subagentIndexTemplate(name, importSpecifier = 'loopengine') {
88
+ return `import type { AgentConfig } from '${importSpecifier}'
41
89
 
42
90
  export const config: AgentConfig = {
43
91
  name: '${name}',
@@ -50,18 +98,24 @@ export const config: AgentConfig = {
50
98
  /** Writes agents/<name>/index.ts under baseDir and returns its path.
51
99
  * Only that one file — tools, rules, and skillsDirs are left to their
52
100
  * smart defaults (see AgentConfig's own doc comments), the same as the
53
- * README's own "simplest possible agent" example. */
54
- export async function scaffoldAgent(baseDir, name) {
101
+ * README's own "simplest possible agent" example. `options` (systemPrompt,
102
+ * model) is entirely optional — see agentIndexTemplate's own doc comment
103
+ * for the defaults used when omitted. */
104
+ export async function scaffoldAgent(baseDir, name, options = {}) {
55
105
  if (!NAME_PATTERN.test(name)) {
56
106
  throw new AgentNameError(`Agent name must be lowercase, alphanumeric, hyphen-separated (e.g. "weather-agent") — got "${name}"`);
57
107
  }
108
+ const provider = options.model?.provider ?? 'anthropic';
109
+ if (provider !== 'anthropic' && !options.model?.model?.trim()) {
110
+ throw new AgentModelError(`A model name is required for provider '${provider}' — only anthropic has a default (claude-sonnet-5).`);
111
+ }
58
112
  const dir = path.join(baseDir, 'agents', name);
59
113
  const indexPath = path.join(dir, 'index.ts');
60
114
  if (await pathExists(indexPath)) {
61
115
  throw new AgentExistsError(`agents/${name}/index.ts already exists — pick a different name or edit it directly.`);
62
116
  }
63
117
  await mkdir(dir, { recursive: true });
64
- await writeFile(indexPath, agentIndexTemplate(name));
118
+ await writeFile(indexPath, agentIndexTemplate(name, options, await configImportSpecifier(baseDir)));
65
119
  return indexPath;
66
120
  }
67
121
  /** Resolves `parent` — a single agent name, or a `/`-joined path of
@@ -105,7 +159,7 @@ export async function scaffoldSubagent(baseDir, parent, name) {
105
159
  throw new AgentExistsError(`A subagent named '${name}' already exists under '${parent}' (${indexPath}) — pick a different name or edit it directly.`);
106
160
  }
107
161
  await mkdir(dir, { recursive: true });
108
- await writeFile(indexPath, subagentIndexTemplate(name));
162
+ await writeFile(indexPath, subagentIndexTemplate(name, await configImportSpecifier(baseDir)));
109
163
  return indexPath;
110
164
  }
111
165
  async function pathExists(p) {
@@ -130,8 +184,32 @@ async function pathExists(p) {
130
184
  function runTsx(args) {
131
185
  return new Promise((resolve) => {
132
186
  const child = spawn('npx', ['tsx', ...args], { cwd: process.cwd(), stdio: 'inherit' });
133
- child.on('exit', (code) => resolve(code ?? 1));
134
- child.on('error', () => resolve(1));
187
+ // `stdio: 'inherit'` with no `detached` puts this child in the same
188
+ // foreground process group as this process itself — so a terminal's
189
+ // Ctrl+C delivers SIGINT to *both* at once, not just to this process
190
+ // for it to relay. Node's default disposition for an unhandled
191
+ // SIGINT/SIGTERM is to terminate immediately — without a listener,
192
+ // this process would die right then, before ever observing the
193
+ // child's own 'exit' below. That's a real bug, not cosmetic: this
194
+ // process is the shell's *direct* child, so the shell returns control
195
+ // as soon as it exits — but the child (tsx watch, itself possibly
196
+ // still killing *its* own child, adapters/http.ts) can still be alive
197
+ // for a moment after, orphaned but still attached to the same TTY.
198
+ // A plain no-op listener is enough to opt out of that default
199
+ // immediate-termination behavior — the child already gets the same
200
+ // signal directly (same process group), nothing needs forwarding —
201
+ // so this just keeps this process alive long enough to actually see
202
+ // the child finish, and exit in step with it instead of ahead of it.
203
+ const noop = () => { };
204
+ process.on('SIGINT', noop);
205
+ process.on('SIGTERM', noop);
206
+ function settle(result) {
207
+ process.off('SIGINT', noop);
208
+ process.off('SIGTERM', noop);
209
+ resolve(result);
210
+ }
211
+ child.on('exit', (code) => settle(code ?? 1));
212
+ child.on('error', () => settle(1));
135
213
  });
136
214
  }
137
215
  /** run/serve/dev all delegate to a file the *project* owns (created by
@@ -203,17 +281,38 @@ async function main() {
203
281
  // adapters/cli.ts's own contract (see its own header comment), not a
204
282
  // REPL. `rest` after `<agent>` is forwarded through as-is (--session,
205
283
  // the message, and any future flag that file grows), so this wrapper
206
- // never needs to change in step with adapters/cli.ts's own arg parsing.
284
+ // never needs to change in step with adapters/cli.ts's own arg parsing
285
+ // — except --input, which is this wrapper's own convenience flag, not
286
+ // adapters/cli.ts's: that file only understands --agent/--session plus
287
+ // one trailing positional message (see its own parseArgs, which joins
288
+ // every non-flag arg back into one string), so --input's value is
289
+ // translated into that positional form before forwarding, rather than
290
+ // taught to adapters/cli.ts itself. Exists for callers building the
291
+ // arg list programmatically, where getting a trailing positional's
292
+ // exact position right (after --session, after --agent, ...) is easy
293
+ // to get wrong; an explicit flag has no position to get wrong.
207
294
  if (command === 'run') {
208
295
  const [agent, ...forward] = rest;
209
296
  if (!agent) {
210
297
  console.error('Usage: loopengine run <agent> [--session <id>] "<message>"');
298
+ console.error(' loopengine run <agent> [--session <id>] --input "<message>"');
211
299
  process.exitCode = 1;
212
300
  return;
213
301
  }
214
302
  if (!(await requireAdapterFile('adapters/cli.ts')))
215
303
  return;
216
- process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/cli.ts', '--agent', agent, ...forward]);
304
+ let finalArgs = forward;
305
+ const inputIndex = forward.indexOf('--input');
306
+ if (inputIndex !== -1) {
307
+ const message = forward[inputIndex + 1];
308
+ if (message === undefined) {
309
+ console.error('--input requires a value.');
310
+ process.exitCode = 1;
311
+ return;
312
+ }
313
+ finalArgs = [...forward.slice(0, inputIndex), ...forward.slice(inputIndex + 2), message];
314
+ }
315
+ process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/cli.ts', '--agent', agent, ...finalArgs]);
217
316
  return;
218
317
  }
219
318
  // Serves every registered agent at once (adapters/http.ts has no
@@ -234,10 +333,26 @@ async function main() {
234
333
  // read fresh off disk every call regardless (no restart needed for
235
334
  // those even under plain `serve`) — this is for the TS source itself:
236
335
  // a new tool file, an edited AgentConfig, ...
336
+ //
337
+ // --include 'agents/*/index.ts' is deliberately narrow, not a blanket
338
+ // 'agents/**' — tsx watch on its own only tracks the actual ES module
339
+ // graph, so a *new* agents/<name>/index.ts (e.g. from the admin UI's
340
+ // "Create new agent," or from `loopengine add-agent`) wouldn't
341
+ // otherwise trigger a restart at all: core/agent-registry.ts discovers
342
+ // agents once via a top-level `readdirSync` (see discover-agents.ts),
343
+ // not an import, so tsx's watcher has no static edge to that new file
344
+ // until this flag adds one. A wider 'agents/**' would also match
345
+ // gateway-tools.yml/actauth.yml/skills/*.md — restarting the whole
346
+ // server (dropping in-flight sessions) on every admin-UI edit to
347
+ // those, which is exactly the "no restart needed" behavior this repo
348
+ // went out of its way to build for them elsewhere. Confirmed live
349
+ // (both directions) before landing this: editing gateway-tools.yml
350
+ // under this exact glob triggers no restart; adding a new
351
+ // agents/<name>/index.ts does.
237
352
  if (command === 'dev') {
238
353
  if (!(await requireAdapterFile('adapters/http.ts')))
239
354
  return;
240
- process.exitCode = await runTsx(['watch', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
355
+ process.exitCode = await runTsx(['watch', '--include', 'agents/*/index.ts', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
241
356
  return;
242
357
  }
243
358
  console.error('Usage: loopengine add-agent <name>');
@@ -1,5 +1,5 @@
1
- import type { AgentConfig, ToolDefinition } from '#agent-config.js';
2
- import { type ModelCall } from '#run-agent.js';
1
+ import type { AgentConfig, ToolDefinition } from '#core/agent-config.js';
2
+ import { type ModelCall } from '#core/run-agent.js';
3
3
  /** `createModelCall` is a factory, not a shared instance — the same
4
4
  * `AgentModule.createModelCall` contract discover-agents.ts already
5
5
  * uses, so each call to the returned tool gets its own ModelCall rather
@@ -1,4 +1,4 @@
1
- import { runAgent } from '#run-agent.js';
1
+ import { runAgent } from '#core/run-agent.js';
2
2
  /** `createModelCall` is a factory, not a shared instance — the same
3
3
  * `AgentModule.createModelCall` contract discover-agents.ts already
4
4
  * uses, so each call to the returned tool gets its own ModelCall rather