loopengine 0.0.18 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +245 -0
- package/README.md +44 -5
- 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 +175 -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/{model-calls → core/model-calls}/openai-model-call.js +1 -1
- package/dist/{run-agent.d.ts → core/run-agent.d.ts} +79 -11
- package/dist/{run-agent.js → core/run-agent.js} +242 -60
- package/dist/{session-store.d.ts → core/session-store.d.ts} +14 -2
- 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/{vector-index.js → core/vector-index.js} +1 -1
- package/dist/index.d.ts +19 -16
- package/dist/index.js +16 -9
- package/package.json +34 -28
- package/protocol/loop-event.schema.json +257 -0
- 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/{vector-index.d.ts → core/vector-index.d.ts} +0 -0
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# The loopengine event protocol
|
|
2
|
+
|
|
3
|
+
**Status:** v1 · **Schema:** [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
|
|
4
|
+
|
|
5
|
+
One `runAgent()` turn emits an ordered sequence of typed `LoopEvent`
|
|
6
|
+
objects. That sequence — not any particular transport — is the actual
|
|
7
|
+
contract. The SSE stream, the plain HTTP response, and the CLI's `--json`
|
|
8
|
+
mode are three different ways of delivering the exact same objects; a
|
|
9
|
+
client written against one of them can switch to another without
|
|
10
|
+
changing how it interprets a single event.
|
|
11
|
+
|
|
12
|
+
This document specifies that sequence: every event's shape, when it
|
|
13
|
+
fires, what's guaranteed about ordering, and how each transport carries
|
|
14
|
+
it. If you're building a UI or integration against loopengine in any
|
|
15
|
+
language — not just TypeScript — this is what to implement against
|
|
16
|
+
instead of reverse-engineering `core/loop-events.ts`.
|
|
17
|
+
|
|
18
|
+
## Stability
|
|
19
|
+
|
|
20
|
+
This is v1. Within v1:
|
|
21
|
+
|
|
22
|
+
- **Additive is safe to rely on being additive.** A new event `type` may
|
|
23
|
+
be introduced; a new optional field may be added to an existing event.
|
|
24
|
+
A consumer that ignores unrecognized `type` values and unrecognized
|
|
25
|
+
fields will never break.
|
|
26
|
+
- **These are breaking changes**, and won't happen inside v1: renaming or
|
|
27
|
+
removing an event `type`; renaming, removing, or changing the meaning
|
|
28
|
+
of an existing field; turning an optional field required; changing an
|
|
29
|
+
established transport's framing (SSE's `event: <type>\ndata: <json>`,
|
|
30
|
+
the plain response's `events` array, or `--json`'s one-object-per-line
|
|
31
|
+
NDJSON).
|
|
32
|
+
- A breaking change ships as v2, documented as a new section here, not a
|
|
33
|
+
silent edit to this one.
|
|
34
|
+
|
|
35
|
+
## Lifecycle
|
|
36
|
+
|
|
37
|
+
Every event has a `type` field, which doubles as the discriminant and,
|
|
38
|
+
on the SSE transport, the literal frame name. Two families:
|
|
39
|
+
|
|
40
|
+
- **Engine events** — emitted by `runAgent()` itself, identically
|
|
41
|
+
regardless of which transport is carrying them.
|
|
42
|
+
- **Adapter events** — synthesized by the HTTP/CLI adapter around a turn
|
|
43
|
+
(`session`, `approval:pending`, `question:pending`, `done`, `error`).
|
|
44
|
+
`runAgent()` itself has no notion of sessions or HTTP-level failures;
|
|
45
|
+
these exist because a real caller needs them.
|
|
46
|
+
|
|
47
|
+
A turn always starts with `session` (streamed) and always ends with
|
|
48
|
+
exactly one `done`. The only real branch is the ActAuth Gate's decision
|
|
49
|
+
on each requested tool call:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
session → budget:check → assistant:text? → actauth:decision
|
|
53
|
+
│
|
|
54
|
+
┌────────────────────────┴───────────────────────┐
|
|
55
|
+
│ allow │ deny
|
|
56
|
+
▼ ▼
|
|
57
|
+
[approval:pending first, tool:result (statusText: "Denied.")
|
|
58
|
+
if the rule said "ask"] — no execution, no tool:started.
|
|
59
|
+
│ Any sibling call already approved
|
|
60
|
+
▼ in the same batch gets loop:skipped
|
|
61
|
+
tool:started + its own tool:result ("Skipped.")
|
|
62
|
+
▼ │
|
|
63
|
+
toollane:result │
|
|
64
|
+
▼ │
|
|
65
|
+
tool:result (Approved./Error.) │
|
|
66
|
+
│ │
|
|
67
|
+
└─────────────────┬──────────────────────────────┘
|
|
68
|
+
▼
|
|
69
|
+
loops back to budget:check for the
|
|
70
|
+
next tool call, or the model's next turn,
|
|
71
|
+
until exactly one of:
|
|
72
|
+
loop:done / loop:max_turns / loop:denied
|
|
73
|
+
▼
|
|
74
|
+
done
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`approval:pending`/`question:pending` are the only events without a
|
|
78
|
+
fixed position in that diagram — they can arrive at any point a tool call
|
|
79
|
+
is being evaluated. At most one is ever pending at a time (the Gate
|
|
80
|
+
evaluates one call before starting the next), so a consumer never needs
|
|
81
|
+
to track more than one open decision per turn.
|
|
82
|
+
|
|
83
|
+
## Event catalog
|
|
84
|
+
|
|
85
|
+
### Engine — emitted by `runAgent()`
|
|
86
|
+
|
|
87
|
+
| `type` | Fires | Fields |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `budget:check` | Once per model call, before it | `action: 'ok'\|'nudge'\|'over_hard_limit'`, `usedTokens`, `budgetTokens`, `ratio`, `nudge?` |
|
|
90
|
+
| `prompt:compaction` | A prompt was rejected as too large | `from`, `to` (message-array lengths, not tokens) |
|
|
91
|
+
| `assistant:text` | The model's "I'll do X" preamble alongside a tool call | `text` |
|
|
92
|
+
| `actauth:decision` | Once per requested tool call | `tool`, `decision: 'allow'\|'ask'\|'deny'`, `reason` |
|
|
93
|
+
| `tool:started` | The instant a call is decided, before it runs | `id`, `tool`, `args`, `detailText` |
|
|
94
|
+
| `tool:result` | A call resolves — allowed, denied, or skipped | `id`, `tool`, `args?`, `detailText?`, `statusText` |
|
|
95
|
+
| `toollane:result` | ToolLane's own raw outcome, alongside `tool:result` | `name`, `summary` |
|
|
96
|
+
| `skill:loaded` | A `Skill` block resolved to a real skill body | `skill` |
|
|
97
|
+
| `loop:skipped` | A sibling call never ran because another was denied | `name`, `deniedTools` |
|
|
98
|
+
| `loop:max_turns` | Hit `config.maxTurns` with no final answer | `maxTurns` |
|
|
99
|
+
| `loop:denied` | A human denied a call — the turn stops | `deniedTools` |
|
|
100
|
+
| `loop:done` | A genuine finish — no more tool calls | `text` |
|
|
101
|
+
|
|
102
|
+
### Adapter — synthesized around a turn
|
|
103
|
+
|
|
104
|
+
| `type` | Fires | Fields |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `session` | First event of every streamed turn | `sessionId` |
|
|
107
|
+
| `approval:pending` | A tool call needs a live human decision | `id`, `tool`, `args`, `scope: {tenant,environment,agent}`, `reason`, `requestedAt` |
|
|
108
|
+
| `question:pending` | The `ask_user` tool raised a real question | `id`, `question`, `options?`, `agent`, `sessionId?`, `requestedAt` |
|
|
109
|
+
| `done` | The turn is over — wraps `loop:done`/`max_turns`/`denied` | `text`, `stopReason?: 'max_turns'\|'denied'` |
|
|
110
|
+
| `error` | Something failed after the response was already committed | `error` |
|
|
111
|
+
|
|
112
|
+
Full field types, `required` vs optional, and `additionalProperties: false`
|
|
113
|
+
enforcement live in [`protocol/loop-event.schema.json`](protocol/loop-event.schema.json)
|
|
114
|
+
— the table above is a summary, the schema is the source of truth.
|
|
115
|
+
|
|
116
|
+
## Transports
|
|
117
|
+
|
|
118
|
+
### SSE stream — live, frame by frame
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
POST /agents/:name/messages/stream
|
|
122
|
+
content-type: application/json
|
|
123
|
+
|
|
124
|
+
{"message": "order A-1001 arrived broken, can you refund it?"}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Each frame is `event: <type>\ndata: <json>\n\n`, `<json>` being the whole
|
|
128
|
+
event object (`type` included, redundantly with the frame name — parse
|
|
129
|
+
whichever is more convenient):
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
event: tool:started
|
|
133
|
+
data: {"type":"tool:started","id":"call_1","tool":"issue_refund","args":{"orderId":"A-1001"},"detailText":"matched rule 'refunds-need-approval'"}
|
|
134
|
+
|
|
135
|
+
event: approval:pending
|
|
136
|
+
data: {"type":"approval:pending","id":"3f9c...","tool":"issue_refund","args":{"orderId":"A-1001"},"scope":{"tenant":"default","environment":"production","agent":"customer-service"},"reason":"matched rule 'refunds-need-approval'","requestedAt":"2026-08-28T01:34:32.193Z"}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Plain HTTP — one request, the full array
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
POST /agents/:name/messages
|
|
143
|
+
content-type: application/json
|
|
144
|
+
|
|
145
|
+
{"message": "..."}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Two response shapes, both carrying the identical `events` array a
|
|
149
|
+
streamed version of the same turn would have delivered live:
|
|
150
|
+
|
|
151
|
+
**200 — the turn finished:**
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"text": "Refunded $42.00 to your original payment method.",
|
|
156
|
+
"sessionId": "5a83c6b4-...",
|
|
157
|
+
"events": [ /* every LoopEvent this turn produced, in order */ ],
|
|
158
|
+
"stopReason": "max_turns"
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`stopReason` is present only for a synthetic finish (`max_turns`/`denied`).
|
|
163
|
+
|
|
164
|
+
**202 — the turn needs a decision right now:**
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"pending": true,
|
|
169
|
+
"type": "approval",
|
|
170
|
+
"id": "3f9c...",
|
|
171
|
+
"sessionId": "5a83c6b4-...",
|
|
172
|
+
"tool": "issue_refund",
|
|
173
|
+
"args": { "orderId": "A-1001" },
|
|
174
|
+
"scope": { "tenant": "default", "environment": "production", "agent": "customer-service" },
|
|
175
|
+
"reason": "matched rule 'refunds-need-approval'",
|
|
176
|
+
"approveUrl": "/approvals/3f9c.../approve",
|
|
177
|
+
"denyUrl": "/approvals/3f9c.../deny",
|
|
178
|
+
"events": [ /* everything so far */ ],
|
|
179
|
+
"statusUrl": "/agents/customer-service/sessions/5a83c6b4-..."
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
(`type: "question"` carries `question`/`options`/`answerUrl` instead.)
|
|
184
|
+
|
|
185
|
+
### CLI — one JSON object per line
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
loopengine run customer-service --json "order A-1001 arrived broken, can you refund it?"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
{"type":"session","sessionId":"5a83c6b4-..."}
|
|
193
|
+
{"type":"budget:check","action":"ok","usedTokens":2,"budgetTokens":8000,"ratio":0.00025}
|
|
194
|
+
{"type":"tool:started","id":"call_1","tool":"issue_refund", ...}
|
|
195
|
+
{"type":"done","text":"Refunded $42.00..."}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Every line is a complete `LoopEvent`, pipeable straight into `jq` or
|
|
199
|
+
anything else that reads NDJSON. Without `--json`, stdout stays
|
|
200
|
+
human-readable (just the final answer) and events go to stderr instead.
|
|
201
|
+
|
|
202
|
+
### Resolving a pending item
|
|
203
|
+
|
|
204
|
+
```
|
|
205
|
+
POST /approvals/:id/approve POST /approvals/:id/deny
|
|
206
|
+
POST /questions/:id/answer {"answer": "..."}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
All three return the exact same two-shape family as plain
|
|
210
|
+
`POST /messages` above (200 done, or 202 the *next* pending item, if the
|
|
211
|
+
turn needed another decision) — a caller can treat send → approve/deny/
|
|
212
|
+
answer → approve/deny/answer → ... as one uniform loop regardless of
|
|
213
|
+
which endpoint produced the response.
|
|
214
|
+
|
|
215
|
+
## Validating against the schema
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
219
|
+
import addFormats from 'ajv-formats' // schema uses format: 'date-time'
|
|
220
|
+
import schema from './protocol/loop-event.schema.json' with { type: 'json' }
|
|
221
|
+
|
|
222
|
+
const ajv = new Ajv2020({ strict: true })
|
|
223
|
+
addFormats(ajv)
|
|
224
|
+
const validate = ajv.compile(schema)
|
|
225
|
+
validate(event) // true/false; validate.errors on failure
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The schema is hand-maintained alongside `core/loop-events.ts`, not
|
|
229
|
+
generated from it — a PR that changes one without the other is
|
|
230
|
+
incomplete. It's plain JSON Schema (draft 2020-12), so it works from any
|
|
231
|
+
language with a validator, not just JavaScript.
|
|
232
|
+
|
|
233
|
+
## Reference implementations
|
|
234
|
+
|
|
235
|
+
- **`core/client.ts`** — the framework-agnostic TypeScript client this
|
|
236
|
+
spec was extracted from: SSE parsing, the plain-request helpers,
|
|
237
|
+
approve/deny/answer. Re-exported through the package's own entry point.
|
|
238
|
+
- **`ui-examples/react/`**, **`ui-examples/vue/`** — a hook/composable
|
|
239
|
+
and a chat component each, built on `core/client.ts`, rendering
|
|
240
|
+
`approval:pending`/`question:pending` as real UI.
|
|
241
|
+
|
|
242
|
+
Implementing this protocol in another language means reproducing what
|
|
243
|
+
`core/client.ts` does: parse whichever transport you chose into
|
|
244
|
+
`LoopEvent` objects per this schema, and drive your own UI or logic off
|
|
245
|
+
`type`.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
LoopEngine is a runtime for building AI agents: a persona, a set of tools,
|
|
6
6
|
and permission rules, run through a transparent ReAct loop. No chain DSL,
|
|
7
|
-
no hidden control flow — `run-agent.ts` is a single function you can read
|
|
7
|
+
no hidden control flow — `core/run-agent.ts` is a single function you can read
|
|
8
8
|
top to bottom. Run agents over a CLI, an HTTP API, or both.
|
|
9
9
|
|
|
10
10
|
Once you define an agent, the loop turns it into something you can put in
|
|
@@ -37,7 +37,7 @@ npx create-loopengine@latest my-agents
|
|
|
37
37
|
cd my-agents
|
|
38
38
|
npm install
|
|
39
39
|
cp .env.example .env # fill in ANTHROPIC_API_KEY
|
|
40
|
-
npm run dev # HTTP server on :8787
|
|
40
|
+
npm run dev # HTTP server on :8787 — same as `npx loopengine dev`
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
This scaffolds a standalone project — its own repo, with `loopengine` as
|
|
@@ -96,7 +96,7 @@ working examples.
|
|
|
96
96
|
|
|
97
97
|
### 1. The Loop
|
|
98
98
|
|
|
99
|
-
`run-agent.ts` is the whole engine — a single, readable ReAct loop with no
|
|
99
|
+
`core/run-agent.ts` is the whole engine — a single, readable ReAct loop with no
|
|
100
100
|
hidden control flow: call the model, act on what it asks for, repeat.
|
|
101
101
|
|
|
102
102
|
1. Call the model with the conversation so far.
|
|
@@ -338,6 +338,37 @@ starting an anonymous session). Without it, the caller just supplies a
|
|
|
338
338
|
|
|
339
339
|
## Running an agent
|
|
340
340
|
|
|
341
|
+
`adapters/cli.ts` and `adapters/http.ts` are files your own scaffolded
|
|
342
|
+
project owns (see "Quick start" above), not something hidden inside the
|
|
343
|
+
`loopengine` package — run them directly with `tsx`, or through shorter
|
|
344
|
+
`loopengine` commands that just call the same files for you:
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
npx loopengine run customer-service --session s1 "order A-1001 arrived broken"
|
|
348
|
+
npx loopengine run customer-service --session s1 --input "order A-1001 arrived broken"
|
|
349
|
+
npx loopengine serve # HTTP server on :8787
|
|
350
|
+
npx loopengine dev # same server, restarts on file changes (tsx watch)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`--session <id>` is just an arbitrary string you pick — `s1` above isn't
|
|
354
|
+
special, it's only a label. Omit it entirely for a fresh, one-off
|
|
355
|
+
conversation each call; reuse the same value on a later call to continue
|
|
356
|
+
that exact conversation (the id is printed to stderr if you omit it, so
|
|
357
|
+
you can capture it for next time). `--input "<message>"` is an
|
|
358
|
+
alternative to the trailing positional message, for scripts that build
|
|
359
|
+
the argument list programmatically and would rather not depend on the
|
|
360
|
+
message always being the last argument.
|
|
361
|
+
|
|
362
|
+
`run`/`serve`/`dev` are thin wrappers, not a separate implementation —
|
|
363
|
+
each shells out to your project's own `adapters/cli.ts` or
|
|
364
|
+
`adapters/http.ts` via `npx tsx` (so it's always *your* copy that runs,
|
|
365
|
+
edits included, resolved from your project's own `node_modules`), and
|
|
366
|
+
fails with a clear message pointing at `create-loopengine` if that file
|
|
367
|
+
doesn't exist yet. `.env` is loaded automatically for all three,
|
|
368
|
+
equivalent to passing `--env-file-if-exists=.env` to `tsx` yourself.
|
|
369
|
+
|
|
370
|
+
The same thing, spelled out without the wrapper:
|
|
371
|
+
|
|
341
372
|
**CLI:**
|
|
342
373
|
|
|
343
374
|
```bash
|
|
@@ -372,6 +403,14 @@ into the playground and config page below.
|
|
|
372
403
|
that same loop-step event stream render live instead of reading raw SSE
|
|
373
404
|
frames. Same `/messages/stream` route underneath.
|
|
374
405
|
|
|
406
|
+
**Building your own client:** the playground is one UI on top of a typed
|
|
407
|
+
event protocol, not the only way to consume a turn — see
|
|
408
|
+
[`PROTOCOL.md`](PROTOCOL.md) for the full `LoopEvent` catalog, the SSE/
|
|
409
|
+
plain-HTTP/CLI transport bindings, and a JSON Schema
|
|
410
|
+
(`protocol/loop-event.schema.json`) any language can validate against.
|
|
411
|
+
`core/client.ts` and the `ui-examples/react`/`ui-examples/vue` hooks are
|
|
412
|
+
reference implementations of it.
|
|
413
|
+
|
|
375
414
|
**Agent config page:** open `http://localhost:8787/agents/config`
|
|
376
415
|
(optionally `?agent=<name>`) to browse every registered agent's resolved
|
|
377
416
|
config — system prompt, model, tools (with their JSON schemas and
|
|
@@ -402,8 +441,8 @@ testing), export your own `createModelCall(): ModelCall` instead.
|
|
|
402
441
|
| Package | Responsibility |
|
|
403
442
|
|---|---|
|
|
404
443
|
| [`actauth`](https://www.npmjs.com/package/actauth) | Permission gating (allow/ask/deny) with human-approval hooks |
|
|
405
|
-
|
|
|
406
|
-
|
|
|
444
|
+
| `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
|
|
445
|
+
| `core/recovery.ts` | Retries prompt-too-long / truncated-output failures (vendored in-repo, not an external dependency) |
|
|
407
446
|
| [`sessionknit`](https://www.npmjs.com/package/sessionknit) | Durable session log with crash-interruption detection |
|
|
408
447
|
| [`skillgarden`](https://www.npmjs.com/package/skillgarden) | `SKILL.md` discovery and lazy loading |
|
|
409
448
|
| [`toollane`](https://www.npmjs.com/package/toollane) | Parallel/solo tool-call scheduling |
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: composio-large-outputs
|
|
3
|
+
description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Gateway tools with a large result
|
|
7
|
+
|
|
8
|
+
Some gateway tools (Composio-sourced ones in particular) return a small
|
|
9
|
+
pointer object instead of their real output when the actual result is
|
|
10
|
+
too large to return inline:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
{
|
|
14
|
+
"successful": true,
|
|
15
|
+
"storedInFile": true,
|
|
16
|
+
"tokenCount": 10234,
|
|
17
|
+
"outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When you see `storedInFile: true` in a tool result, the real data is
|
|
22
|
+
**not** in that object — it's in the file at `outputFilePath`. Call
|
|
23
|
+
`system_read_file` with that exact path to retrieve the actual content
|
|
24
|
+
before answering. Don't guess at an answer, summarize from the pointer
|
|
25
|
+
alone, or claim you don't have the data — read the file first.
|
|
@@ -5,19 +5,39 @@ export declare class AgentExistsError extends Error {
|
|
|
5
5
|
}
|
|
6
6
|
export declare class AgentNotFoundError extends Error {
|
|
7
7
|
}
|
|
8
|
-
export declare
|
|
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
|