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.
Files changed (38) hide show
  1. package/README.md +34 -3
  2. package/core/system-skills/composio-large-outputs/SKILL.md +25 -0
  3. package/dist/{cli.d.ts → bin/cli.d.ts} +25 -4
  4. package/dist/{cli.js → bin/cli.js} +131 -16
  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} +11 -3
  11. package/dist/{gateway-tools.d.ts → core/gateway-tools.d.ts} +38 -6
  12. package/dist/{gateway-tools.js → core/gateway-tools.js} +140 -28
  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/{run-agent.d.ts → core/run-agent.d.ts} +77 -9
  19. package/dist/{run-agent.js → core/run-agent.js} +213 -44
  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 +32 -26
  31. package/dist/discover-agents.d.ts +0 -24
  32. /package/dist/{agent-config.d.ts → core/agent-config.d.ts} +0 -0
  33. /package/dist/{agent-config.js → core/agent-config.js} +0 -0
  34. /package/dist/{model-calls → core/model-calls}/anthropic-model-call.js +0 -0
  35. /package/dist/{model-calls → core/model-calls}/deepseek-model-call.js +0 -0
  36. /package/dist/{model-calls → core/model-calls}/openai-model-call.js +0 -0
  37. /package/dist/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
  38. /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 function agentIndexTemplate(name: string): string;
8
+ export declare class AgentModelError extends Error {
9
+ }
10
+ type Provider = 'anthropic' | 'openai' | 'deepseek';
11
+ export interface AgentTemplateOptions {
12
+ systemPrompt?: string;
13
+ model?: {
14
+ provider: Provider;
15
+ model?: string;
16
+ };
17
+ }
18
+ /** Generates agents/<name>/index.ts's real content. `options` is
19
+ * entirely optional and defaults to exactly what this template always
20
+ * wrote before it existed ('You are ...', anthropic/claude-sonnet-5) —
21
+ * `loopengine add-agent <name>` (which never passes it) produces
22
+ * byte-identical output to before this existed. openai/deepseek have no
23
+ * default model (only anthropic's is optional — see AgentModelConfig's
24
+ * own doc comment), so scaffoldAgent validates a model name was given
25
+ * for those rather than this function silently writing a wrong one. */
26
+ export declare function agentIndexTemplate(name: string, options?: AgentTemplateOptions, importSpecifier?: string): string;
9
27
  /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
10
28
  * field a subagent needs that a top-level agent doesn't. run-agent.ts's
11
29
  * loadSubagentAsTools wraps this config with agentAsTool, which throws
12
30
  * without a real toolDescription: it's what the parent agent's model
13
31
  * reads to decide when to delegate here, and systemPrompt (instructions
14
32
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
15
- export declare function subagentIndexTemplate(name: string): string;
33
+ export declare function subagentIndexTemplate(name: string, importSpecifier?: string): string;
16
34
  /** Writes agents/<name>/index.ts under baseDir and returns its path.
17
35
  * Only that one file — tools, rules, and skillsDirs are left to their
18
36
  * smart defaults (see AgentConfig's own doc comments), the same as the
19
- * README's own "simplest possible agent" example. */
20
- export declare function scaffoldAgent(baseDir: string, name: string): Promise<string>;
37
+ * README's own "simplest possible agent" example. `options` (systemPrompt,
38
+ * model) is entirely optional — see agentIndexTemplate's own doc comment
39
+ * for the defaults used when omitted. */
40
+ export declare function scaffoldAgent(baseDir: string, name: string, options?: AgentTemplateOptions): Promise<string>;
21
41
  /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
22
42
  * returns its path, where <parentDir> is `parent` resolved by
23
43
  * resolveParentDir — the folder convention run-agent.ts's
@@ -29,3 +49,4 @@ export declare function scaffoldAgent(baseDir: string, name: string): Promise<st
29
49
  * scaffoldAgent already covers. Nesting more than one level is just
30
50
  * `parent` itself being a path — see resolveParentDir. */
31
51
  export declare function scaffoldSubagent(baseDir: string, parent: string, name: string): Promise<string>;
52
+ export {};
@@ -9,7 +9,7 @@
9
9
  // these stay thin wrappers around those files rather than a built-in
10
10
  // runner/server this package hides inside itself.
11
11
  import { realpathSync } from 'node:fs';
12
- import { access, mkdir, writeFile } from 'node:fs/promises';
12
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
13
13
  import { spawn } from 'node:child_process';
14
14
  import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
@@ -20,13 +20,61 @@ export class AgentExistsError extends Error {
20
20
  }
21
21
  export class AgentNotFoundError extends Error {
22
22
  }
23
- export function agentIndexTemplate(name) {
24
- return `import type { AgentConfig } from 'loopengine'
23
+ export class AgentModelError extends Error {
24
+ }
25
+ const MODEL_ENV_VAR = {
26
+ anthropic: 'ANTHROPIC_API_KEY',
27
+ openai: 'OPENAI_API_KEY',
28
+ deepseek: 'DEEPSEEK_API_KEY',
29
+ };
30
+ // Escapes a value for safe interpolation into a single-quoted TS string
31
+ // literal in generated code — an operator-supplied systemPrompt (from
32
+ // the admin UI's Create new agent form, say) containing a literal quote
33
+ // or backslash would otherwise produce invalid, or silently different,
34
+ // generated TypeScript.
35
+ function tsStringLiteral(value) {
36
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
37
+ }
38
+ /** 'loopengine' for a real consumer project — the only thing that ever
39
+ * resolves once this package is genuinely installed as a dependency. A
40
+ * checkout of this repo itself has no such install (see this repo's own
41
+ * git history around the reverted self-link attempt: pointing
42
+ * node_modules/loopengine back at itself collides with tsc trying to
43
+ * write dist/ while also reading it as an input), so a file scaffolded
44
+ * *into this repo* — an agent created via `loopengine dev`'s admin UI
45
+ * while hacking on loopengine itself, say — needs the same internal
46
+ * alias every hand-maintained demo agent here already uses instead.
47
+ * Detected by baseDir's own package.json name rather than a flag the
48
+ * caller has to remember to pass, so this can never drift out of sync
49
+ * with which project is actually being scaffolded into. */
50
+ async function configImportSpecifier(baseDir) {
51
+ try {
52
+ const raw = await readFile(path.join(baseDir, 'package.json'), 'utf8');
53
+ const pkg = JSON.parse(raw);
54
+ return pkg.name === 'loopengine' ? '#core/agent-config.js' : 'loopengine';
55
+ }
56
+ catch {
57
+ return 'loopengine';
58
+ }
59
+ }
60
+ /** Generates agents/<name>/index.ts's real content. `options` is
61
+ * entirely optional and defaults to exactly what this template always
62
+ * wrote before it existed ('You are ...', anthropic/claude-sonnet-5) —
63
+ * `loopengine add-agent <name>` (which never passes it) produces
64
+ * byte-identical output to before this existed. openai/deepseek have no
65
+ * default model (only anthropic's is optional — see AgentModelConfig's
66
+ * own doc comment), so scaffoldAgent validates a model name was given
67
+ * for those rather than this function silently writing a wrong one. */
68
+ export function agentIndexTemplate(name, options = {}, importSpecifier = 'loopengine') {
69
+ const systemPrompt = options.systemPrompt?.trim() || 'You are ...';
70
+ const provider = options.model?.provider ?? 'anthropic';
71
+ const modelName = options.model?.model?.trim() || (provider === 'anthropic' ? 'claude-sonnet-5' : '');
72
+ return `import type { AgentConfig } from '${importSpecifier}'
25
73
 
26
74
  export const config: AgentConfig = {
27
75
  name: '${name}',
28
- systemPrompt: 'You are ...',
29
- model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
76
+ systemPrompt: ${tsStringLiteral(systemPrompt)},
77
+ model: { provider: '${provider}', model: ${tsStringLiteral(modelName)} }, // reads ${MODEL_ENV_VAR[provider]}
30
78
  }
31
79
  `;
32
80
  }
@@ -36,8 +84,8 @@ export const config: AgentConfig = {
36
84
  * without a real toolDescription: it's what the parent agent's model
37
85
  * reads to decide when to delegate here, and systemPrompt (instructions
38
86
  * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
39
- export function subagentIndexTemplate(name) {
40
- return `import type { AgentConfig } from 'loopengine'
87
+ export function subagentIndexTemplate(name, importSpecifier = 'loopengine') {
88
+ return `import type { AgentConfig } from '${importSpecifier}'
41
89
 
42
90
  export const config: AgentConfig = {
43
91
  name: '${name}',
@@ -50,18 +98,24 @@ export const config: AgentConfig = {
50
98
  /** Writes agents/<name>/index.ts under baseDir and returns its path.
51
99
  * Only that one file — tools, rules, and skillsDirs are left to their
52
100
  * smart defaults (see AgentConfig's own doc comments), the same as the
53
- * README's own "simplest possible agent" example. */
54
- export async function scaffoldAgent(baseDir, name) {
101
+ * README's own "simplest possible agent" example. `options` (systemPrompt,
102
+ * model) is entirely optional — see agentIndexTemplate's own doc comment
103
+ * for the defaults used when omitted. */
104
+ export async function scaffoldAgent(baseDir, name, options = {}) {
55
105
  if (!NAME_PATTERN.test(name)) {
56
106
  throw new AgentNameError(`Agent name must be lowercase, alphanumeric, hyphen-separated (e.g. "weather-agent") — got "${name}"`);
57
107
  }
108
+ const provider = options.model?.provider ?? 'anthropic';
109
+ if (provider !== 'anthropic' && !options.model?.model?.trim()) {
110
+ throw new AgentModelError(`A model name is required for provider '${provider}' — only anthropic has a default (claude-sonnet-5).`);
111
+ }
58
112
  const dir = path.join(baseDir, 'agents', name);
59
113
  const indexPath = path.join(dir, 'index.ts');
60
114
  if (await pathExists(indexPath)) {
61
115
  throw new AgentExistsError(`agents/${name}/index.ts already exists — pick a different name or edit it directly.`);
62
116
  }
63
117
  await mkdir(dir, { recursive: true });
64
- await writeFile(indexPath, agentIndexTemplate(name));
118
+ await writeFile(indexPath, agentIndexTemplate(name, options, await configImportSpecifier(baseDir)));
65
119
  return indexPath;
66
120
  }
67
121
  /** Resolves `parent` — a single agent name, or a `/`-joined path of
@@ -105,7 +159,7 @@ export async function scaffoldSubagent(baseDir, parent, name) {
105
159
  throw new AgentExistsError(`A subagent named '${name}' already exists under '${parent}' (${indexPath}) — pick a different name or edit it directly.`);
106
160
  }
107
161
  await mkdir(dir, { recursive: true });
108
- await writeFile(indexPath, subagentIndexTemplate(name));
162
+ await writeFile(indexPath, subagentIndexTemplate(name, await configImportSpecifier(baseDir)));
109
163
  return indexPath;
110
164
  }
111
165
  async function pathExists(p) {
@@ -130,8 +184,32 @@ async function pathExists(p) {
130
184
  function runTsx(args) {
131
185
  return new Promise((resolve) => {
132
186
  const child = spawn('npx', ['tsx', ...args], { cwd: process.cwd(), stdio: 'inherit' });
133
- child.on('exit', (code) => resolve(code ?? 1));
134
- child.on('error', () => resolve(1));
187
+ // `stdio: 'inherit'` with no `detached` puts this child in the same
188
+ // foreground process group as this process itself — so a terminal's
189
+ // Ctrl+C delivers SIGINT to *both* at once, not just to this process
190
+ // for it to relay. Node's default disposition for an unhandled
191
+ // SIGINT/SIGTERM is to terminate immediately — without a listener,
192
+ // this process would die right then, before ever observing the
193
+ // child's own 'exit' below. That's a real bug, not cosmetic: this
194
+ // process is the shell's *direct* child, so the shell returns control
195
+ // as soon as it exits — but the child (tsx watch, itself possibly
196
+ // still killing *its* own child, adapters/http.ts) can still be alive
197
+ // for a moment after, orphaned but still attached to the same TTY.
198
+ // A plain no-op listener is enough to opt out of that default
199
+ // immediate-termination behavior — the child already gets the same
200
+ // signal directly (same process group), nothing needs forwarding —
201
+ // so this just keeps this process alive long enough to actually see
202
+ // the child finish, and exit in step with it instead of ahead of it.
203
+ const noop = () => { };
204
+ process.on('SIGINT', noop);
205
+ process.on('SIGTERM', noop);
206
+ function settle(result) {
207
+ process.off('SIGINT', noop);
208
+ process.off('SIGTERM', noop);
209
+ resolve(result);
210
+ }
211
+ child.on('exit', (code) => settle(code ?? 1));
212
+ child.on('error', () => settle(1));
135
213
  });
136
214
  }
137
215
  /** run/serve/dev all delegate to a file the *project* owns (created by
@@ -203,17 +281,38 @@ async function main() {
203
281
  // adapters/cli.ts's own contract (see its own header comment), not a
204
282
  // REPL. `rest` after `<agent>` is forwarded through as-is (--session,
205
283
  // the message, and any future flag that file grows), so this wrapper
206
- // never needs to change in step with adapters/cli.ts's own arg parsing.
284
+ // never needs to change in step with adapters/cli.ts's own arg parsing
285
+ // — except --input, which is this wrapper's own convenience flag, not
286
+ // adapters/cli.ts's: that file only understands --agent/--session plus
287
+ // one trailing positional message (see its own parseArgs, which joins
288
+ // every non-flag arg back into one string), so --input's value is
289
+ // translated into that positional form before forwarding, rather than
290
+ // taught to adapters/cli.ts itself. Exists for callers building the
291
+ // arg list programmatically, where getting a trailing positional's
292
+ // exact position right (after --session, after --agent, ...) is easy
293
+ // to get wrong; an explicit flag has no position to get wrong.
207
294
  if (command === 'run') {
208
295
  const [agent, ...forward] = rest;
209
296
  if (!agent) {
210
297
  console.error('Usage: loopengine run <agent> [--session <id>] "<message>"');
298
+ console.error(' loopengine run <agent> [--session <id>] --input "<message>"');
211
299
  process.exitCode = 1;
212
300
  return;
213
301
  }
214
302
  if (!(await requireAdapterFile('adapters/cli.ts')))
215
303
  return;
216
- process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/cli.ts', '--agent', agent, ...forward]);
304
+ let finalArgs = forward;
305
+ const inputIndex = forward.indexOf('--input');
306
+ if (inputIndex !== -1) {
307
+ const message = forward[inputIndex + 1];
308
+ if (message === undefined) {
309
+ console.error('--input requires a value.');
310
+ process.exitCode = 1;
311
+ return;
312
+ }
313
+ finalArgs = [...forward.slice(0, inputIndex), ...forward.slice(inputIndex + 2), message];
314
+ }
315
+ process.exitCode = await runTsx(['--env-file-if-exists=.env', 'adapters/cli.ts', '--agent', agent, ...finalArgs]);
217
316
  return;
218
317
  }
219
318
  // Serves every registered agent at once (adapters/http.ts has no
@@ -234,10 +333,26 @@ async function main() {
234
333
  // read fresh off disk every call regardless (no restart needed for
235
334
  // those even under plain `serve`) — this is for the TS source itself:
236
335
  // a new tool file, an edited AgentConfig, ...
336
+ //
337
+ // --include 'agents/*/index.ts' is deliberately narrow, not a blanket
338
+ // 'agents/**' — tsx watch on its own only tracks the actual ES module
339
+ // graph, so a *new* agents/<name>/index.ts (e.g. from the admin UI's
340
+ // "Create new agent," or from `loopengine add-agent`) wouldn't
341
+ // otherwise trigger a restart at all: core/agent-registry.ts discovers
342
+ // agents once via a top-level `readdirSync` (see discover-agents.ts),
343
+ // not an import, so tsx's watcher has no static edge to that new file
344
+ // until this flag adds one. A wider 'agents/**' would also match
345
+ // gateway-tools.yml/actauth.yml/skills/*.md — restarting the whole
346
+ // server (dropping in-flight sessions) on every admin-UI edit to
347
+ // those, which is exactly the "no restart needed" behavior this repo
348
+ // went out of its way to build for them elsewhere. Confirmed live
349
+ // (both directions) before landing this: editing gateway-tools.yml
350
+ // under this exact glob triggers no restart; adding a new
351
+ // agents/<name>/index.ts does.
237
352
  if (command === 'dev') {
238
353
  if (!(await requireAdapterFile('adapters/http.ts')))
239
354
  return;
240
- process.exitCode = await runTsx(['watch', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
355
+ process.exitCode = await runTsx(['watch', '--include', 'agents/*/index.ts', '--env-file-if-exists=.env', 'adapters/http.ts', ...rest]);
241
356
  return;
242
357
  }
243
358
  console.error('Usage: loopengine add-agent <name>');
@@ -1,5 +1,5 @@
1
- import type { AgentConfig, ToolDefinition } from '#agent-config.js';
2
- import { type ModelCall } from '#run-agent.js';
1
+ import type { AgentConfig, ToolDefinition } from '#core/agent-config.js';
2
+ import { type ModelCall } from '#core/run-agent.js';
3
3
  /** `createModelCall` is a factory, not a shared instance — the same
4
4
  * `AgentModule.createModelCall` contract discover-agents.ts already
5
5
  * uses, so each call to the returned tool gets its own ModelCall rather
@@ -1,4 +1,4 @@
1
- import { runAgent } from '#run-agent.js';
1
+ import { runAgent } from '#core/run-agent.js';
2
2
  /** `createModelCall` is a factory, not a shared instance — the same
3
3
  * `AgentModule.createModelCall` contract discover-agents.ts already
4
4
  * uses, so each call to the returned tool gets its own ModelCall rather
@@ -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
+ }