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.
- package/README.md +67 -6
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +27 -6
- package/dist/bin/cli.js +384 -0
- package/dist/{agent-as-tool.d.ts → core/agent-as-tool.d.ts} +2 -2
- package/dist/{agent-as-tool.js → core/agent-as-tool.js} +1 -1
- package/dist/core/client.d.ts +113 -0
- package/dist/core/client.js +123 -0
- package/dist/core/discover-agents.d.ts +45 -0
- package/dist/{discover-agents.js → core/discover-agents.js} +12 -4
- package/dist/core/gateway-tools.d.ts +226 -0
- package/dist/core/gateway-tools.js +660 -0
- package/dist/core/loop-events.d.ts +168 -0
- package/dist/core/loop-events.js +1 -0
- package/dist/{model-calls → core/model-calls}/anthropic-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/deepseek-model-call.d.ts +1 -1
- package/dist/{model-calls → core/model-calls}/openai-model-call.d.ts +1 -1
- package/dist/core/run-agent.d.ts +182 -0
- package/dist/{run-agent.js → core/run-agent.js} +233 -51
- package/dist/{session-store.d.ts → core/session-store.d.ts} +13 -1
- package/dist/{session-store.js → core/session-store.js} +19 -0
- package/dist/core/system-tools/ask_user.d.ts +72 -0
- package/dist/core/system-tools/ask_user.js +136 -0
- package/dist/core/system-tools/index.d.ts +3 -0
- package/dist/core/system-tools/index.js +12 -0
- package/dist/core/system-tools/read_file.d.ts +28 -0
- package/dist/core/system-tools/read_file.js +64 -0
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +33 -25
- package/dist/cli.js +0 -191
- package/dist/discover-agents.d.ts +0 -24
- package/dist/run-agent.d.ts +0 -92
- /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
- /package/dist/{agent-config.js → core/agent-config.js} +0 -0
- /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
- /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
- /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
- /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.
|
|
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
|
-
###
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {};
|
package/dist/bin/cli.js
ADDED
|
@@ -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
|