loopengine 0.0.17 → 0.0.20

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/README.md +67 -6
  2. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  3. package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
  4. package/dist/bin/cli.js +384 -0
  5. package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
  6. package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
  7. package/dist/core/client.d.ts +113 -0
  8. package/dist/core/client.js +123 -0
  9. package/dist/core/discover-agents.d.ts +45 -0
  10. package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
  11. package/dist/core/gateway-tools.d.ts +226 -0
  12. package/dist/core/gateway-tools.js +660 -0
  13. package/dist/core/loop-events.d.ts +168 -0
  14. package/dist/core/loop-events.js +1 -0
  15. package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
  16. package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
  17. package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
  18. package/dist/core/run-agent.d.ts +182 -0
  19. package/dist/{run-agent.js → core/run-agent.js} +233 -51
  20. package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
  21. package/dist/{session-store.js → core/session-store.js} +19 -0
  22. package/dist/core/system-tools/ask_user.d.ts +72 -0
  23. package/dist/core/system-tools/ask_user.js +136 -0
  24. package/dist/core/system-tools/index.d.ts +3 -0
  25. package/dist/core/system-tools/index.js +12 -0
  26. package/dist/core/system-tools/read_file.d.ts +28 -0
  27. package/dist/core/system-tools/read_file.js +64 -0
  28. package/dist/index.d.ts +19 -16
  29. package/dist/index.js +16 -9
  30. package/package.json +33 -25
  31. package/dist/cli.js +0 -191
  32. package/dist/discover-agents.d.ts +0 -24
  33. package/dist/run-agent.d.ts +0 -92
  34. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  35. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  36. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  37. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  38. /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
  39. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
  40. /package/dist/{vector-index.js → core/vector-index.js} +0 -0
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.
@@ -258,7 +258,37 @@ A few things worth knowing before reaching for this:
258
258
  spends turns at every level (default 25 each) — a "simple" answer can
259
259
  cost far more than any one agent's own turn budget suggests.
260
260
 
261
- ### 5. Tool permission and multi-tenancy
261
+ ### 5. Gateway tool sources — connecting external tools from a web page
262
+
263
+ Beyond hand-written tools and subagents, an agent can pull tools from an
264
+ external gateway — [Composio](https://composio.dev) today, with
265
+ [`mcpplug`](https://www.npmjs.com/package/mcpplug)'s `ToolSource`
266
+ interface designed so more providers (Nango, Arcade, Scalekit, ...) slot
267
+ in later as thin adapters, same shape. Unlike hand-written tools, these
268
+ are meant to be managed by an operator at runtime, not committed to code:
269
+ run the HTTP adapter, open `/agents/config`, pick an agent, and switch to
270
+ its "Gateway tools" tab — alongside "Overview" and "Actauth" — to see its
271
+ connected sources and add or remove one.
272
+
273
+ ```bash
274
+ npx tsx --env-file-if-exists=.env adapters/http.ts
275
+ # open http://localhost:8787/agents/config
276
+ ```
277
+
278
+ Adding a source (via the page, or `POST /agents/:name/gateway-tools` with
279
+ `{ provider: 'composio', name: 'gh', slugs: [...] }`) writes
280
+ `agents/<name>/gateway-tools.yml` — read fresh off disk on every request,
281
+ same "no restart to see an edit" behavior `actauth.yml` already has.
282
+ Composio's own OAuth is out of band: run `composio link <toolkit>` once
283
+ on the machine running the server, then use the page purely to pick which
284
+ already-authorized slugs to expose to which agent.
285
+
286
+ A newly-added tool is **denied by default**, same "opt-in, not silently
287
+ allowed" rule every tool in this repo follows — pass a `decision` when
288
+ adding a source (or use the page's permission dropdown) to seed an
289
+ `actauth.yml` rule for it instead, or add the rule by hand afterward.
290
+
291
+ ### 6. Tool permission and multi-tenancy
262
292
 
263
293
  Every tool call is gated by [`actauth`](https://www.npmjs.com/package/actauth):
264
294
  each rule in `AgentConfig.rules` maps a `scope` (tenant/environment) + tool
@@ -292,7 +322,7 @@ undefined` — from headers only, never the request body, since it feeds
292
322
  permission decisions directly. No `tenantFor` means every request is the
293
323
  `'default'` tenant.
294
324
 
295
- ### 6. Sessions
325
+ ### 7. Sessions
296
326
 
297
327
  A session is one ongoing conversation. Message history persists between
298
328
  requests automatically — send a message, get a reply, come back later with
@@ -308,6 +338,37 @@ starting an anonymous session). Without it, the caller just supplies a
308
338
 
309
339
  ## Running an agent
310
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
+
311
372
  **CLI:**
312
373
 
313
374
  ```bash
@@ -317,7 +378,7 @@ npx tsx adapters/cli.ts --agent customer-service --session s1 "order A-1001 arri
317
378
  **HTTP:**
318
379
 
319
380
  ```bash
320
- npx tsx adapters/http.ts
381
+ npx tsx --env-file-if-exists=.env adapters/http.ts
321
382
  curl -X POST localhost:8787/agents/customer-service/messages \
322
383
  -H 'content-type: application/json' \
323
384
  -d '{"customerEmail":"a@example.com","message":"order A-1001 arrived broken"}'
@@ -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,23 +5,43 @@ 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
- * loadSubagentTools wraps this config with agentAsTool, which throws
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
24
- * loadSubagentTools auto-discovers, so this subagent becomes one of
44
+ * loadSubagentAsTools auto-discovers, so this subagent becomes one of
25
45
  * `parent`'s tools with no further edits: no import, no
26
46
  * AgentConfig.tools entry, nothing to register by hand. Requires
27
47
  * `parent` to already exist (its own index.ts) — a subagent without a
@@ -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 {};
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env node
2
+ // The `loopengine` bin. add-agent/add-subagent generate the
3
+ // agents/<name>/index.ts boilerplate the folder convention expects (see
4
+ // agent-config.ts's own doc comments, or the README's "Define your first
5
+ // agent"), so using that convention doesn't mean memorizing its shape
6
+ // and hand-writing it every time. run/serve/dev are shorter muscle-
7
+ // memory commands for what a scaffolded project's own adapters/cli.ts
8
+ // and adapters/http.ts already do — see runTsx's own doc comment for why
9
+ // these stay thin wrappers around those files rather than a built-in
10
+ // runner/server this package hides inside itself.
11
+ import { realpathSync } from 'node:fs';
12
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
13
+ import { spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
17
+ export class AgentNameError extends Error {
18
+ }
19
+ export class AgentExistsError extends Error {
20
+ }
21
+ export class AgentNotFoundError extends Error {
22
+ }
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}'
73
+
74
+ export const config: AgentConfig = {
75
+ name: '${name}',
76
+ systemPrompt: ${tsStringLiteral(systemPrompt)},
77
+ model: { provider: '${provider}', model: ${tsStringLiteral(modelName)} }, // reads ${MODEL_ENV_VAR[provider]}
78
+ }
79
+ `;
80
+ }
81
+ /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
82
+ * field a subagent needs that a top-level agent doesn't. run-agent.ts's
83
+ * loadSubagentAsTools wraps this config with agentAsTool, which throws
84
+ * without a real toolDescription: it's what the parent agent's model
85
+ * reads to decide when to delegate here, and systemPrompt (instructions
86
+ * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
87
+ export function subagentIndexTemplate(name, importSpecifier = 'loopengine') {
88
+ return `import type { AgentConfig } from '${importSpecifier}'
89
+
90
+ export const config: AgentConfig = {
91
+ name: '${name}',
92
+ systemPrompt: 'You are ...',
93
+ toolDescription: 'Call this when ...',
94
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
95
+ }
96
+ `;
97
+ }
98
+ /** Writes agents/<name>/index.ts under baseDir and returns its path.
99
+ * Only that one file — tools, rules, and skillsDirs are left to their
100
+ * smart defaults (see AgentConfig's own doc comments), the same as the
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 = {}) {
105
+ if (!NAME_PATTERN.test(name)) {
106
+ throw new AgentNameError(`Agent name must be lowercase, alphanumeric, hyphen-separated (e.g. "weather-agent") — got "${name}"`);
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
+ }
112
+ const dir = path.join(baseDir, 'agents', name);
113
+ const indexPath = path.join(dir, 'index.ts');
114
+ if (await pathExists(indexPath)) {
115
+ throw new AgentExistsError(`agents/${name}/index.ts already exists — pick a different name or edit it directly.`);
116
+ }
117
+ await mkdir(dir, { recursive: true });
118
+ await writeFile(indexPath, agentIndexTemplate(name, options, await configImportSpecifier(baseDir)));
119
+ return indexPath;
120
+ }
121
+ /** Resolves `parent` — a single agent name, or a `/`-joined path of
122
+ * names for nesting more than one level deep (e.g.
123
+ * "support-orchestrator/billing-agent" to scaffold a subagent under an
124
+ * *existing subagent*) — to the real directory that agent's index.ts
125
+ * lives in: agents/<seg0>/subagents/<seg1>/subagents/<seg2>/... Every
126
+ * segment gets the same lowercase-hyphen validation a plain agent name
127
+ * does; there's no separate "path" naming rule. */
128
+ function resolveParentDir(baseDir, parent) {
129
+ const segments = parent.split('/');
130
+ for (const segment of segments) {
131
+ if (!NAME_PATTERN.test(segment)) {
132
+ throw new AgentNameError(`Parent must be a lowercase, alphanumeric, hyphen-separated agent name, or several joined by '/' for nesting (e.g. "support-orchestrator/billing-agent") — got "${parent}"`);
133
+ }
134
+ }
135
+ return segments.slice(1).reduce((dir, segment) => path.join(dir, 'subagents', segment), path.join(baseDir, 'agents', segments[0]));
136
+ }
137
+ /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
138
+ * returns its path, where <parentDir> is `parent` resolved by
139
+ * resolveParentDir — the folder convention run-agent.ts's
140
+ * loadSubagentAsTools auto-discovers, so this subagent becomes one of
141
+ * `parent`'s tools with no further edits: no import, no
142
+ * AgentConfig.tools entry, nothing to register by hand. Requires
143
+ * `parent` to already exist (its own index.ts) — a subagent without a
144
+ * parent to nest under is just a regular top-level agent, which
145
+ * scaffoldAgent already covers. Nesting more than one level is just
146
+ * `parent` itself being a path — see resolveParentDir. */
147
+ export async function scaffoldSubagent(baseDir, parent, name) {
148
+ const parentDir = resolveParentDir(baseDir, parent);
149
+ if (!NAME_PATTERN.test(name)) {
150
+ throw new AgentNameError(`Subagent name must be lowercase, alphanumeric, hyphen-separated (e.g. "billing-agent") — got "${name}"`);
151
+ }
152
+ const parentIndexPath = path.join(parentDir, 'index.ts');
153
+ if (!(await pathExists(parentIndexPath))) {
154
+ throw new AgentNotFoundError(`'${parent}' doesn't exist yet (expected ${parentIndexPath}) — create it first.`);
155
+ }
156
+ const dir = path.join(parentDir, 'subagents', name);
157
+ const indexPath = path.join(dir, 'index.ts');
158
+ if (await pathExists(indexPath)) {
159
+ throw new AgentExistsError(`A subagent named '${name}' already exists under '${parent}' (${indexPath}) — pick a different name or edit it directly.`);
160
+ }
161
+ await mkdir(dir, { recursive: true });
162
+ await writeFile(indexPath, subagentIndexTemplate(name, await configImportSpecifier(baseDir)));
163
+ return indexPath;
164
+ }
165
+ async function pathExists(p) {
166
+ try {
167
+ await access(p);
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
174
+ /** Runs `tsx <args>` in the current project (process.cwd()) — via `npx`,
175
+ * not a bundled copy, so it resolves *that project's own*
176
+ * node_modules/.bin/tsx (the version create-loopengine's own template
177
+ * package.json pins), the same binary "npm run dev"/"npm run cli"
178
+ * already invoke there. Inherits stdio so a REPL-like session, streamed
179
+ * SSE-style logs, or Ctrl+C all behave exactly like running the tsx
180
+ * command by hand — this is that command, just shorter to type. Resolves
181
+ * with the child's own exit code (1 if the process couldn't even start)
182
+ * rather than throwing, so `loopengine run <agent>`'s own exit code
183
+ * mirrors adapters/cli.ts's, not this wrapper's. */
184
+ function runTsx(args) {
185
+ return new Promise((resolve) => {
186
+ const child = spawn('npx', ['tsx', ...args], { cwd: process.cwd(), stdio: 'inherit' });
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));
213
+ });
214
+ }
215
+ /** run/serve/dev all delegate to a file the *project* owns (created by
216
+ * create-loopengine's own scaffold — see its template/adapters/), not
217
+ * one loopengine bundles — so a project that deleted or never had that
218
+ * file needs a clear, actionable error here, not tsx's own generic
219
+ * "cannot find module" a few layers down. */
220
+ async function requireAdapterFile(relPath) {
221
+ if (await pathExists(path.join(process.cwd(), relPath)))
222
+ return true;
223
+ console.error(`${relPath} not found in this project.`);
224
+ console.error('Run "npx create-loopengine@latest" to scaffold one, or add your own at that path.');
225
+ process.exitCode = 1;
226
+ return false;
227
+ }
228
+ async function main() {
229
+ const [, , command, ...rest] = process.argv;
230
+ if (command === 'add-agent') {
231
+ const [name] = rest;
232
+ if (!name) {
233
+ console.error('Usage: loopengine add-agent <name>');
234
+ process.exitCode = 1;
235
+ return;
236
+ }
237
+ try {
238
+ await scaffoldAgent(process.cwd(), name);
239
+ }
240
+ catch (err) {
241
+ console.error(err instanceof Error ? err.message : String(err));
242
+ process.exitCode = 1;
243
+ return;
244
+ }
245
+ console.log(`Created agents/${name}/index.ts`);
246
+ console.log();
247
+ console.log('tools, rules, and skillsDirs are all omitted — they default to:');
248
+ console.log(` agents/${name}/tools/index.ts (no tools until you add one)`);
249
+ console.log(` agents/${name}/actauth.yml (denies every tool until you add one)`);
250
+ console.log(` agents/${name}/skills/ (no skills until you add one)`);
251
+ console.log();
252
+ console.log(`Run it: npx tsx adapters/cli.ts --agent ${name} "hello"`);
253
+ return;
254
+ }
255
+ if (command === 'add-subagent') {
256
+ const [parent, name] = rest;
257
+ if (!parent || !name) {
258
+ console.error('Usage: loopengine add-subagent <parent> <name>');
259
+ console.error(' <parent> can be a single agent name, or several joined by \'/\' to nest deeper (e.g. "support-orchestrator/billing-agent")');
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ let indexPath;
264
+ try {
265
+ indexPath = await scaffoldSubagent(process.cwd(), parent, name);
266
+ }
267
+ catch (err) {
268
+ console.error(err instanceof Error ? err.message : String(err));
269
+ process.exitCode = 1;
270
+ return;
271
+ }
272
+ console.log(`Created ${path.relative(process.cwd(), indexPath)}`);
273
+ console.log();
274
+ console.log(`Auto-registered as a tool on '${parent}' — no import, no tools array edit needed.`);
275
+ console.log(`Fill in toolDescription: it's what '${parent}'s model reads to decide when to call '${name}'.`);
276
+ console.log();
277
+ console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
278
+ return;
279
+ }
280
+ // One-shot: sends one message, prints the reply, exits — exactly
281
+ // adapters/cli.ts's own contract (see its own header comment), not a
282
+ // REPL. `rest` after `<agent>` is forwarded through as-is (--session,
283
+ // the message, and any future flag that file grows), so this wrapper
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.
294
+ if (command === 'run') {
295
+ const [agent, ...forward] = rest;
296
+ if (!agent) {
297
+ console.error('Usage: loopengine run <agent> [--session <id>] "<message>"');
298
+ console.error(' loopengine run <agent> [--session <id>] --input "<message>"');
299
+ process.exitCode = 1;
300
+ return;
301
+ }
302
+ if (!(await requireAdapterFile('adapters/cli.ts')))
303
+ return;
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]);
316
+ return;
317
+ }
318
+ // Serves every registered agent at once (adapters/http.ts has no
319
+ // notion of "just one agent") — same command the create-loopengine
320
+ // template's own "npm run dev" script already runs, see runTsx's own
321
+ // doc comment.
322
+ if (command === 'serve') {
323
+ if (!(await requireAdapterFile('adapters/http.ts')))
324
+ return;
325
+ process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
326
+ return;
327
+ }
328
+ // Same server as `serve`, via `tsx watch` instead of plain `tsx` — tsx
329
+ // itself doesn't hot-reload by default (see playground.ts's own dev
330
+ // instructions in the README, which use plain tsx), so without this,
331
+ // editing an agent's tools/rules/skills mid-session would need a
332
+ // manual restart to take effect. gateway-tools.yml/actauth.yml already
333
+ // read fresh off disk every call regardless (no restart needed for
334
+ // those even under plain `serve`) — this is for the TS source itself:
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.
352
+ if (command === 'dev') {
353
+ if (!(await requireAdapterFile('adapters/http.ts')))
354
+ return;
355
+ process.exitCode = await runTsx(['watch', '--include', 'agents/*/index.ts', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
356
+ return;
357
+ }
358
+ console.error('Usage: loopengine add-agent <name>');
359
+ console.error(' loopengine add-subagent <parent> <name>');
360
+ console.error(' loopengine run <agent> [--session <id>] "<message>"');
361
+ console.error(' loopengine serve');
362
+ console.error(' loopengine dev');
363
+ process.exitCode = 1;
364
+ }
365
+ // A package-manager shim (npx, node_modules/.bin) invokes this file
366
+ // through a symlink, not its real path — process.argv[1] is the
367
+ // symlink's path, while Node resolves import.meta.url to the real path
368
+ // when loading an ES module. Comparing the raw argv[1] against the
369
+ // resolved module URL fails in exactly that case and main() silently
370
+ // never runs — realpath argv[1] first so both sides are resolved. (Same
371
+ // bug, same fix, as create-loopengine's own cli.ts — see its git history
372
+ // for the live symlink repro, and skillgarden's CLI before that.)
373
+ function isMain() {
374
+ if (!process.argv[1])
375
+ return false;
376
+ try {
377
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
378
+ }
379
+ catch {
380
+ return false;
381
+ }
382
+ }
383
+ if (isMain())
384
+ main();
@@ -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