loopengine 0.0.18 → 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 +34 -3
- package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
- package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
- package/dist/{cli.js → bin/cli.js} +131 -16
- 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} +11 -3
- package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
- package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
- 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/{run-agent.d.ts → core/run-agent.d.ts} +77 -9
- package/dist/{run-agent.js → core/run-agent.js} +213 -44
- 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 +32 -26
- package/dist/discover-agents.d.ts +0 -24
- /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.
|
|
@@ -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
|
|
@@ -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
|
|
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
|
|
@@ -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
|
|
24
|
-
|
|
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:
|
|
29
|
-
model: { provider: '
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { LoopEvent } from './loop-events.js';
|
|
2
|
+
import type { Message } from './run-agent.js';
|
|
3
|
+
export interface RequestOptions {
|
|
4
|
+
/** Resumes an existing conversation — omit for a fresh one; see
|
|
5
|
+
* adapters/http.ts's own sessionIdFor for how the server derives one
|
|
6
|
+
* when omitted. */
|
|
7
|
+
sessionId?: string;
|
|
8
|
+
/** Extra headers on top of 'content-type: application/json' — e.g. HTTP
|
|
9
|
+
* Basic Auth (LOOPENGINE_ADMIN_AUTH) or an AgentConfig.tenantFor header. */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
/** Merged into the request body alongside `message`/`sessionId` — for an
|
|
12
|
+
* AgentConfig with its own sessionIdFor/tenantFor reading extra fields
|
|
13
|
+
* (see adapters/http.ts's own resolveTenant). */
|
|
14
|
+
body?: Record<string, unknown>;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
/** POST /agents/:name/messages's own 200 response — the turn finished,
|
|
18
|
+
* genuinely or via a synthetic stopReason (see loop-events.ts's own
|
|
19
|
+
* DoneEvent). `events` is every LoopEvent this turn produced, start to
|
|
20
|
+
* finish — the same array a streamed turn delivers frame-by-frame. */
|
|
21
|
+
export interface SendMessageResult {
|
|
22
|
+
text: string;
|
|
23
|
+
sessionId: string;
|
|
24
|
+
events: LoopEvent[];
|
|
25
|
+
stopReason?: 'max_turns' | 'denied';
|
|
26
|
+
}
|
|
27
|
+
interface PendingResultBase {
|
|
28
|
+
pending: true;
|
|
29
|
+
id: string;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
events: LoopEvent[];
|
|
32
|
+
/** GET this to rehydrate the conversation (and re-discover this same
|
|
33
|
+
* pending item) if the caller navigates away before deciding it. */
|
|
34
|
+
statusUrl: string;
|
|
35
|
+
}
|
|
36
|
+
/** POST /agents/:name/messages's own 202 response when the turn needs a
|
|
37
|
+
* human 'ask' decision before it can continue — resolve it with
|
|
38
|
+
* approveCall/denyCall below, both of which return this same
|
|
39
|
+
* MessageResult union, so a caller can treat approve/deny/answer and the
|
|
40
|
+
* original send as one uniform decide-or-finish loop. */
|
|
41
|
+
export interface PendingApprovalResult extends PendingResultBase {
|
|
42
|
+
type: 'approval';
|
|
43
|
+
tool: string;
|
|
44
|
+
args: Record<string, unknown>;
|
|
45
|
+
scope: {
|
|
46
|
+
tenant: string;
|
|
47
|
+
environment: string;
|
|
48
|
+
agent: string;
|
|
49
|
+
};
|
|
50
|
+
reason: string;
|
|
51
|
+
approveUrl: string;
|
|
52
|
+
denyUrl: string;
|
|
53
|
+
}
|
|
54
|
+
/** Same as PendingApprovalResult, for the system ask_user tool's own
|
|
55
|
+
* open-ended question instead of a fixed allow/deny — resolve with
|
|
56
|
+
* answerQuestion below. */
|
|
57
|
+
export interface PendingQuestionResult extends PendingResultBase {
|
|
58
|
+
type: 'question';
|
|
59
|
+
question: string;
|
|
60
|
+
options?: string[];
|
|
61
|
+
answerUrl: string;
|
|
62
|
+
}
|
|
63
|
+
export type PendingResult = PendingApprovalResult | PendingQuestionResult;
|
|
64
|
+
export type MessageResult = SendMessageResult | PendingResult;
|
|
65
|
+
/** The non-streaming half of the protocol — one request, one response,
|
|
66
|
+
* either done (SendMessageResult) or needing a decision (PendingResult).
|
|
67
|
+
* Reach for this over streamMessage() when the caller doesn't need live,
|
|
68
|
+
* frame-by-frame updates (a bot integration, a batch job, a callback-
|
|
69
|
+
* style webhook handler) — same underlying turn, same event vocabulary in
|
|
70
|
+
* `events`, just delivered all at once instead of incrementally. */
|
|
71
|
+
export declare function sendMessage(baseUrl: string, agent: string, message: string, options?: RequestOptions): Promise<MessageResult>;
|
|
72
|
+
export declare function approveCall(baseUrl: string, id: string, options?: RequestOptions): Promise<MessageResult>;
|
|
73
|
+
export declare function denyCall(baseUrl: string, id: string, options?: RequestOptions): Promise<MessageResult>;
|
|
74
|
+
export declare function answerQuestion(baseUrl: string, id: string, answer: string, options?: RequestOptions): Promise<MessageResult>;
|
|
75
|
+
/** GET /agents/:name/sessions/:id — rehydrates a conversation's stored
|
|
76
|
+
* history, e.g. after a page reload lost whatever in-memory state a React/
|
|
77
|
+
* Vue app was holding (mirrors web/playground.ts's own resumeSession). */
|
|
78
|
+
export declare function getSessionHistory(baseUrl: string, agent: string, sessionId: string, options?: Pick<RequestOptions, 'headers' | 'signal'>): Promise<{
|
|
79
|
+
sessionId: string;
|
|
80
|
+
history: Message[];
|
|
81
|
+
}>;
|
|
82
|
+
/** The streaming half of the protocol — an async generator yielding one
|
|
83
|
+
* LoopEvent per SSE frame as it arrives, always starting with `session`
|
|
84
|
+
* and ending with `done` or `error` (see loop-events.ts's own header
|
|
85
|
+
* comment). `for await` is the natural fit for a React effect or Vue
|
|
86
|
+
* composable driving off this directly:
|
|
87
|
+
*
|
|
88
|
+
* for await (const event of streamMessage(baseUrl, agent, text, { signal })) {
|
|
89
|
+
* setEvents((prev) => [...prev, event])
|
|
90
|
+
* if (event.type === 'done' || event.type === 'error') break
|
|
91
|
+
* }
|
|
92
|
+
*
|
|
93
|
+
* Pass an AbortController's signal to let the caller cancel mid-stream
|
|
94
|
+
* (a React effect's own cleanup, a Vue component's onUnmounted) — the
|
|
95
|
+
* underlying fetch is aborted and the generator simply stops yielding,
|
|
96
|
+
* same as any other AbortSignal-aware fetch call.
|
|
97
|
+
*/
|
|
98
|
+
export declare function streamMessage(baseUrl: string, agent: string, message: string, options?: RequestOptions): AsyncGenerator<LoopEvent, void, void>;
|
|
99
|
+
export interface StreamMessageCallbacks {
|
|
100
|
+
onEvent?: (event: LoopEvent) => void;
|
|
101
|
+
onDone?: (event: Extract<LoopEvent, {
|
|
102
|
+
type: 'done';
|
|
103
|
+
}>) => void;
|
|
104
|
+
onError?: (event: Extract<LoopEvent, {
|
|
105
|
+
type: 'error';
|
|
106
|
+
}>) => void;
|
|
107
|
+
}
|
|
108
|
+
/** Callback-flavored wrapper over streamMessage() for a caller that would
|
|
109
|
+
* rather pass handlers into a `useEffect`/lifecycle hook than manage a
|
|
110
|
+
* `for await` loop and its own break condition by hand. Resolves once the
|
|
111
|
+
* stream ends (a 'done'/'error' event, or the connection just closing). */
|
|
112
|
+
export declare function streamMessageWithCallbacks(baseUrl: string, agent: string, message: string, options: RequestOptions, callbacks: StreamMessageCallbacks): Promise<void>;
|
|
113
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
async function postJson(url, body, options = {}) {
|
|
2
|
+
const res = await fetch(url, {
|
|
3
|
+
method: 'POST',
|
|
4
|
+
headers: { 'content-type': 'application/json', ...options.headers },
|
|
5
|
+
body: JSON.stringify(body),
|
|
6
|
+
signal: options.signal,
|
|
7
|
+
});
|
|
8
|
+
const parsed = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(parsed.error ?? `HTTP ${res.status}`);
|
|
11
|
+
return parsed;
|
|
12
|
+
}
|
|
13
|
+
function messageBody(message, options) {
|
|
14
|
+
return { message, ...(options.sessionId ? { sessionId: options.sessionId } : {}), ...options.body };
|
|
15
|
+
}
|
|
16
|
+
/** The non-streaming half of the protocol — one request, one response,
|
|
17
|
+
* either done (SendMessageResult) or needing a decision (PendingResult).
|
|
18
|
+
* Reach for this over streamMessage() when the caller doesn't need live,
|
|
19
|
+
* frame-by-frame updates (a bot integration, a batch job, a callback-
|
|
20
|
+
* style webhook handler) — same underlying turn, same event vocabulary in
|
|
21
|
+
* `events`, just delivered all at once instead of incrementally. */
|
|
22
|
+
export function sendMessage(baseUrl, agent, message, options = {}) {
|
|
23
|
+
return postJson(`${baseUrl}/agents/${encodeURIComponent(agent)}/messages`, messageBody(message, options), options);
|
|
24
|
+
}
|
|
25
|
+
export function approveCall(baseUrl, id, options = {}) {
|
|
26
|
+
return postJson(`${baseUrl}/approvals/${encodeURIComponent(id)}/approve`, undefined, options);
|
|
27
|
+
}
|
|
28
|
+
export function denyCall(baseUrl, id, options = {}) {
|
|
29
|
+
return postJson(`${baseUrl}/approvals/${encodeURIComponent(id)}/deny`, undefined, options);
|
|
30
|
+
}
|
|
31
|
+
export function answerQuestion(baseUrl, id, answer, options = {}) {
|
|
32
|
+
return postJson(`${baseUrl}/questions/${encodeURIComponent(id)}/answer`, { answer }, options);
|
|
33
|
+
}
|
|
34
|
+
/** GET /agents/:name/sessions/:id — rehydrates a conversation's stored
|
|
35
|
+
* history, e.g. after a page reload lost whatever in-memory state a React/
|
|
36
|
+
* Vue app was holding (mirrors web/playground.ts's own resumeSession). */
|
|
37
|
+
export async function getSessionHistory(baseUrl, agent, sessionId, options = {}) {
|
|
38
|
+
const res = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent)}/sessions/${encodeURIComponent(sessionId)}`, {
|
|
39
|
+
headers: options.headers,
|
|
40
|
+
signal: options.signal,
|
|
41
|
+
});
|
|
42
|
+
const body = (await res.json());
|
|
43
|
+
if (!res.ok)
|
|
44
|
+
throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
function parseSseFrame(frame) {
|
|
48
|
+
let dataLine = '';
|
|
49
|
+
for (const line of frame.split('\n')) {
|
|
50
|
+
if (line.startsWith('data: '))
|
|
51
|
+
dataLine = line.slice('data: '.length);
|
|
52
|
+
}
|
|
53
|
+
if (!dataLine)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(dataLine);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The streaming half of the protocol — an async generator yielding one
|
|
63
|
+
* LoopEvent per SSE frame as it arrives, always starting with `session`
|
|
64
|
+
* and ending with `done` or `error` (see loop-events.ts's own header
|
|
65
|
+
* comment). `for await` is the natural fit for a React effect or Vue
|
|
66
|
+
* composable driving off this directly:
|
|
67
|
+
*
|
|
68
|
+
* for await (const event of streamMessage(baseUrl, agent, text, { signal })) {
|
|
69
|
+
* setEvents((prev) => [...prev, event])
|
|
70
|
+
* if (event.type === 'done' || event.type === 'error') break
|
|
71
|
+
* }
|
|
72
|
+
*
|
|
73
|
+
* Pass an AbortController's signal to let the caller cancel mid-stream
|
|
74
|
+
* (a React effect's own cleanup, a Vue component's onUnmounted) — the
|
|
75
|
+
* underlying fetch is aborted and the generator simply stops yielding,
|
|
76
|
+
* same as any other AbortSignal-aware fetch call.
|
|
77
|
+
*/
|
|
78
|
+
export async function* streamMessage(baseUrl, agent, message, options = {}) {
|
|
79
|
+
const res = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent)}/messages/stream`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json', ...options.headers },
|
|
82
|
+
body: JSON.stringify(messageBody(message, options)),
|
|
83
|
+
signal: options.signal,
|
|
84
|
+
});
|
|
85
|
+
const contentType = res.headers.get('content-type') ?? '';
|
|
86
|
+
if (!contentType.includes('text/event-stream')) {
|
|
87
|
+
const body = (await res.json().catch(() => ({ error: `HTTP ${res.status}` })));
|
|
88
|
+
yield { type: 'error', error: body.error ?? `HTTP ${res.status}` };
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!res.body)
|
|
92
|
+
return;
|
|
93
|
+
const reader = res.body.getReader();
|
|
94
|
+
const decoder = new TextDecoder();
|
|
95
|
+
let buffer = '';
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done)
|
|
99
|
+
break;
|
|
100
|
+
buffer += decoder.decode(value, { stream: true });
|
|
101
|
+
let idx;
|
|
102
|
+
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
103
|
+
const frame = buffer.slice(0, idx);
|
|
104
|
+
buffer = buffer.slice(idx + 2);
|
|
105
|
+
const event = parseSseFrame(frame);
|
|
106
|
+
if (event)
|
|
107
|
+
yield event;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** Callback-flavored wrapper over streamMessage() for a caller that would
|
|
112
|
+
* rather pass handlers into a `useEffect`/lifecycle hook than manage a
|
|
113
|
+
* `for await` loop and its own break condition by hand. Resolves once the
|
|
114
|
+
* stream ends (a 'done'/'error' event, or the connection just closing). */
|
|
115
|
+
export async function streamMessageWithCallbacks(baseUrl, agent, message, options, callbacks) {
|
|
116
|
+
for await (const event of streamMessage(baseUrl, agent, message, options)) {
|
|
117
|
+
callbacks.onEvent?.(event);
|
|
118
|
+
if (event.type === 'done')
|
|
119
|
+
callbacks.onDone?.(event);
|
|
120
|
+
else if (event.type === 'error')
|
|
121
|
+
callbacks.onError?.(event);
|
|
122
|
+
}
|
|
123
|
+
}
|