loopengine 0.1.14 → 0.1.22
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 +124 -299
- package/dist/bin/package-manager.js +10 -1
- package/dist/core/agent-config.d.ts +1 -1
- package/dist/core/gateway-tools.js +4 -4
- package/dist/core/mcpplug.d.ts +23 -0
- package/dist/core/mcpplug.js +64 -0
- package/dist/core/run-agent.js +2 -2
- package/dist/core/session-store.d.ts +1 -1
- package/dist/core/session-store.js +1 -1
- package/dist/core/sessionknit.d.ts +87 -0
- package/dist/core/sessionknit.js +189 -0
- package/dist/core/skillgarden/activation.d.ts +4 -0
- package/dist/core/skillgarden/activation.js +16 -0
- package/dist/core/skillgarden/budget.d.ts +20 -0
- package/dist/core/skillgarden/budget.js +28 -0
- package/dist/core/skillgarden/discovery.d.ts +11 -0
- package/dist/core/skillgarden/discovery.js +40 -0
- package/dist/core/skillgarden/frontmatter.d.ts +10 -0
- package/dist/core/skillgarden/frontmatter.js +35 -0
- package/dist/core/skillgarden/index.d.ts +8 -0
- package/dist/core/skillgarden/index.js +12 -0
- package/dist/core/skillgarden/loader.d.ts +31 -0
- package/dist/core/skillgarden/loader.js +70 -0
- package/dist/core/skillgarden/substitute.d.ts +4 -0
- package/dist/core/skillgarden/substitute.js +13 -0
- package/dist/core/skillgarden/types.d.ts +23 -0
- package/dist/core/skillgarden/types.js +1 -0
- package/dist/core/toollane.d.ts +46 -0
- package/dist/core/toollane.js +69 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +4 -1
- package/dist/web/agents-config-page.js +1 -197
- package/dist/web/http-tool-admin.d.ts +11 -0
- package/dist/web/http-tool-admin.js +39 -2
- package/dist/web/skills-admin.js +1 -1
- package/package.json +8 -5
- package/dist/web/skillgarden-admin.d.ts +0 -24
- package/dist/web/skillgarden-admin.js +0 -76
package/README.md
CHANGED
|
@@ -7,25 +7,36 @@ and permission rules, run through a transparent ReAct loop. No chain DSL,
|
|
|
7
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
## Why LoopEngine
|
|
11
|
+
|
|
12
|
+
- **Up and running in one command.** `npx create-loopengine@latest` scaffolds
|
|
13
|
+
a real, standalone project — its own repo, a starter agent, CLI and HTTP
|
|
14
|
+
adapters already wired up. `npm install && npm run dev` and you have a
|
|
15
|
+
running agent server, not a half-finished template to wire up yourself.
|
|
16
|
+
- **A real Admin UI, not just logs.** Open `/agents/config` in a browser to
|
|
17
|
+
see and edit a live agent: its resolved config, permission rules, skills,
|
|
18
|
+
tool connections, and secrets — no redeploy to change a rule or add a
|
|
19
|
+
skill. See [Admin UI](#admin-ui) below.
|
|
20
|
+
- **Human-in-the-loop, wired for real notification channels.** A risky tool
|
|
21
|
+
call or a genuinely ambiguous question routes to a human — live in a chat
|
|
22
|
+
session, or durably via Slack, Lark, email, a generic webhook, or a
|
|
23
|
+
polling queue, resumable minutes or days later. See
|
|
24
|
+
[Human in the loop](#human-in-the-loop) below.
|
|
25
|
+
- **Sessions persist automatically.** Send a message, get a reply, come
|
|
26
|
+
back later with the same session id and continue — no database to wire
|
|
27
|
+
up yourself, and it survives a crash mid-turn cleanly.
|
|
28
|
+
- **A package system for reusable capabilities.** Install a bundle of
|
|
29
|
+
tools, a skill, and the permission rules that gate them with one command
|
|
30
|
+
— real, reviewable files in your own repo, not an opaque dependency. See
|
|
31
|
+
[Package system](#package-system) below.
|
|
32
|
+
- **Does real work, safely.** Tools can hit a database, send emails, call
|
|
16
33
|
GitHub/Slack, anything with an `execute` function. Permission rules gate
|
|
17
|
-
what happens without a human: safe reads auto-run, risky actions
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
different customers/orgs with different permission levels, with no
|
|
24
|
-
forking required.
|
|
25
|
-
- **Composable into bigger agents** — drop one agent's folder under
|
|
26
|
-
another's `subagents/` and it becomes a tool the parent can delegate
|
|
27
|
-
to, own tools/rules/loop intact. No chain DSL for orchestration either:
|
|
28
|
-
the parent's model just decides when to call it, like any other tool.
|
|
34
|
+
what happens without a human: safe reads auto-run, risky actions get
|
|
35
|
+
approval or are denied outright.
|
|
36
|
+
- **Multi-tenant and composable out of the box.** The same deployed agent
|
|
37
|
+
serves different customers with different permission levels, no forking
|
|
38
|
+
required — and one agent's folder drops under another's `subagents/` to
|
|
39
|
+
become a tool the parent can delegate to, own tools/rules/loop intact.
|
|
29
40
|
|
|
30
41
|
So the payoff: you write one `AgentConfig`, and get a deployable,
|
|
31
42
|
persistent, permission-safe service — not just a prompt-and-response demo.
|
|
@@ -69,11 +80,14 @@ conventional path under the agent's own folder:
|
|
|
69
80
|
| `skillsDirs` | `agents/<name>/skills` (missing → no skills) |
|
|
70
81
|
|
|
71
82
|
There's no `subagents` field to omit — `agents/<name>/subagents/*` is its
|
|
72
|
-
own folder convention
|
|
73
|
-
|
|
83
|
+
own folder convention (drop another agent's folder there and it becomes
|
|
84
|
+
one of this agent's tools automatically), always merged into `tools` on
|
|
85
|
+
top of whatever the table above resolves to.
|
|
74
86
|
|
|
75
|
-
`rules` is how you gate what a tool can do without approval —
|
|
76
|
-
|
|
87
|
+
`rules` is how you gate what a tool can do without approval — each rule
|
|
88
|
+
maps a `scope` (tenant/environment) + tool name to `allow`/`ask`/`deny`,
|
|
89
|
+
with an `ask` decision routing to a human — see
|
|
90
|
+
[Human in the loop](#human-in-the-loop) below.
|
|
77
91
|
|
|
78
92
|
The simplest possible agent, `agents/weather-agent/index.ts` — `tools`,
|
|
79
93
|
`rules`, and `skillsDirs` all omitted, so they default to
|
|
@@ -92,281 +106,101 @@ export const config: AgentConfig = {
|
|
|
92
106
|
See `agents/customer-service/` and `agents/file-agent/` for complete,
|
|
93
107
|
working examples.
|
|
94
108
|
|
|
95
|
-
##
|
|
96
|
-
|
|
97
|
-
### 1. The Loop
|
|
98
|
-
|
|
99
|
-
`core/run-agent.ts` is the whole engine — a single, readable ReAct loop with no
|
|
100
|
-
hidden control flow: call the model, act on what it asks for, repeat.
|
|
101
|
-
|
|
102
|
-
1. Call the model with the conversation so far.
|
|
103
|
-
2. If it responds with tool calls, each one is checked against
|
|
104
|
-
`AgentConfig.rules` — allowed calls run, denied calls are refused,
|
|
105
|
-
"ask" calls go to whatever approver applies for the call's own channel
|
|
106
|
-
(see "Tool permission and multi-tenancy" below) — a live approver is
|
|
107
|
-
awaited right there; a durable one returns instantly and the call is
|
|
108
|
-
resolved later instead.
|
|
109
|
-
3. Approved calls execute and their results feed back into the
|
|
110
|
-
conversation.
|
|
111
|
-
4. Repeat from step 1 — until the model stops requesting tools
|
|
112
|
-
(`end_turn`) or `AgentConfig.maxTurns` (default 25) is hit, which ends
|
|
113
|
-
the turn with `RunAgentResult.stopReason: 'max_turns'`, a real result
|
|
114
|
-
rather than a thrown error.
|
|
115
|
-
|
|
116
|
-
`runAgent` itself does no I/O — callers own conversation history and the
|
|
117
|
-
model call — which is what lets the same loop serve a one-shot CLI call,
|
|
118
|
-
a long-lived HTTP chat session, and a fully simulated test, unchanged.
|
|
119
|
-
|
|
120
|
-
### 2. Agents — convention over configuration
|
|
121
|
-
|
|
122
|
-
Beyond the `AgentConfig` shape and folder convention shown in "Define
|
|
123
|
-
your first agent" above, the discovery mechanism is what makes this
|
|
124
|
-
convention-over-configuration: `discoverAgents` scans `agents/` at
|
|
125
|
-
startup, keyed by each module's own `AgentConfig.name` — not the
|
|
126
|
-
filename or folder name — and builds the name → config lookup every
|
|
127
|
-
adapter uses. Nothing to register by hand; a module that exports neither
|
|
128
|
-
`config` nor a way to build a model call fails loudly at startup instead
|
|
129
|
-
of silently not showing up.
|
|
130
|
-
|
|
131
|
-
### 3. Skills and tools: agent-specific vs. global
|
|
132
|
-
|
|
133
|
-
Both skills and tools follow the same rule: **default to agent-specific,
|
|
134
|
-
promote to global only once something is genuinely shared.**
|
|
135
|
-
|
|
136
|
-
- **Agent-specific** (the default) — lives under that agent's own folder,
|
|
137
|
-
seen only by that agent:
|
|
138
|
-
```
|
|
139
|
-
agents/<name>/skills/<skill>/SKILL.md
|
|
140
|
-
agents/<name>/tools/<tool>.ts
|
|
141
|
-
```
|
|
142
|
-
- **Global** — lives at the repo root, seen by every agent that opts in:
|
|
143
|
-
```
|
|
144
|
-
skills/<skill>/SKILL.md # pointed at by any agent whose skillsDirs includes it
|
|
145
|
-
tools/<tool>.ts # imported explicitly into any agent's tools array
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
Skills are discovered via `AgentConfig.skillsDirs` (an array of
|
|
149
|
-
directories, `SKILL.md`-based: a short description stays in context, the
|
|
150
|
-
full body loads only when the model invokes it). Tools have no
|
|
151
|
-
auto-discovery — an agent's `tools` array just imports whichever tool
|
|
152
|
-
files it wants, agent-specific or global.
|
|
153
|
-
|
|
154
|
-
Both root directories (`tools/`, `skills/`) are empty today — every skill
|
|
155
|
-
and tool in this repo is agent-specific. Move something there only when a
|
|
156
|
-
second agent actually needs the exact same one, not because it feels
|
|
157
|
-
reusable in theory.
|
|
158
|
-
|
|
159
|
-
**Opting an agent into a global skill or tool** is explicit either way —
|
|
160
|
-
nothing scans the root for you:
|
|
161
|
-
|
|
162
|
-
```ts
|
|
163
|
-
// Global skill: add the root path alongside the agent's own
|
|
164
|
-
skillsDirs: ['agents/my-agent/skills', 'skills']
|
|
165
|
-
|
|
166
|
-
// Global tool: tools has no auto-discovery, so import it and merge it
|
|
167
|
-
// into the agent's own array by hand
|
|
168
|
-
import { sharedTool } from '../../tools/shared-tool.js'
|
|
169
|
-
import { tools as ownTools } from './tools/index.js'
|
|
170
|
-
|
|
171
|
-
tools: [...ownTools, sharedTool]
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Tools don't have to be hand-written either. For SaaS actions (GitHub,
|
|
175
|
-
Slack, etc.) where you'd rather not own the vendor's OAuth flow yourself,
|
|
176
|
-
[`mcpplug`](https://www.npmjs.com/package/mcpplug) connects through
|
|
177
|
-
[Composio](https://composio.dev), which already holds the OAuth
|
|
178
|
-
relationship with 1000+ apps, and returns tools in the same
|
|
179
|
-
`ToolDefinition` shape — so they drop into `tools` right alongside your
|
|
180
|
-
own:
|
|
181
|
-
|
|
182
|
-
```ts
|
|
183
|
-
import { connectComposioSource } from 'mcpplug'
|
|
184
|
-
|
|
185
|
-
const composioTools = await connectComposioSource('composio', {
|
|
186
|
-
slugs: ['GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER'],
|
|
187
|
-
}).then((source) => source.loadTools())
|
|
188
|
-
|
|
189
|
-
const config: AgentConfig = {
|
|
190
|
-
name: 'my-agent',
|
|
191
|
-
tools: [...handWrittenTools, ...composioTools],
|
|
192
|
-
rules: [{ scopePattern: 'default/production/my-agent', tool: 'composio_GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER', decision: 'allow' }],
|
|
193
|
-
defaultDecision: 'ask',
|
|
194
|
-
}
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
Either kind of tool goes through the same permission gating, parallel
|
|
198
|
-
scheduling, and context-budget tracking. See `agents/file-agent/index.ts`
|
|
199
|
-
for a working example.
|
|
200
|
-
|
|
201
|
-
### 4. Subagents — an agent as another agent's tool
|
|
109
|
+
## Admin UI
|
|
202
110
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
111
|
+
Run the HTTP adapter and open `http://localhost:8787/agents/config` — a
|
|
112
|
+
live, editable view of every registered agent, no code changes or
|
|
113
|
+
redeploy needed for most of it:
|
|
206
114
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
A
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
115
|
+
| Tab | What you can do |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| **Overview** | System prompt, model, every tool (with its JSON schema and parallel-safety), and a read-only view of the rules that would actually apply. |
|
|
118
|
+
| **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
|
|
119
|
+
| **Tools** | Local hand-written tools, gateway-sourced tools (e.g. [Composio](https://composio.dev)), and subagents-as-tools, in one place. Connect a new external gateway source or add/remove a tool without touching a file. |
|
|
120
|
+
| **ActAuth** | Add, edit, and delete permission rules — scope, tool, condition, decision — and change `default_decision`, live. |
|
|
121
|
+
| **Environment** | Every env var a package (see [Package system](#package-system)) declared it needs, across everything installed for this agent — which ones are set, which are missing, and a form to set one. A value is never echoed back once set. |
|
|
122
|
+
|
|
123
|
+
Every tab is backed by a real API (`GET /agents/:name/config`, `.../actauth`,
|
|
124
|
+
`.../env`, ...) that reuses the exact same resolution `runAgent()` itself
|
|
125
|
+
uses — so what you see here can't drift out of sync with what a real
|
|
126
|
+
request actually gets, and never returns a model API key.
|
|
127
|
+
|
|
128
|
+
## Human in the loop
|
|
129
|
+
|
|
130
|
+
A risky tool call (`actauth`'s `ask` decision) or a genuinely ambiguous
|
|
131
|
+
question the model itself raises both need a human — and LoopEngine
|
|
132
|
+
handles both the same way, live or durable:
|
|
133
|
+
|
|
134
|
+
- **Live** — someone's actively watching right now (a terminal, an open
|
|
135
|
+
chat session). The turn just waits; `cli`/`http_stream` get this
|
|
136
|
+
automatically, with nothing to configure.
|
|
137
|
+
- **Durable** — nobody's watching (a webhook-triggered run, a ticket that
|
|
138
|
+
came in overnight). The turn ends immediately with a resumable pending
|
|
139
|
+
state, and a real notification goes out on whichever channel you've
|
|
140
|
+
configured — resolvable minutes or days later without holding any
|
|
141
|
+
process open in between.
|
|
142
|
+
|
|
143
|
+
Six channels ship as real, working notifiers — configure one and the
|
|
144
|
+
"send a notification" side is done for you:
|
|
145
|
+
|
|
146
|
+
| Channel | What it does |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `webhook` | Signed HMAC-SHA256 POST to any URL you own |
|
|
149
|
+
| `slack` | An interactive message via `chat.postMessage`, with Approve/Deny buttons |
|
|
150
|
+
| `lark` | A Lark/Feishu interactive card |
|
|
151
|
+
| `email` | A signed, expiring magic-link per decision |
|
|
152
|
+
| `database` | Writes a row for your own worker/dashboard to poll |
|
|
153
|
+
| `redis` | Pushes a queue entry for your own worker to consume |
|
|
226
154
|
|
|
227
155
|
```ts
|
|
228
156
|
export const config: AgentConfig = {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
157
|
+
// ...
|
|
158
|
+
httpNotifier: {
|
|
159
|
+
channel: 'slack',
|
|
160
|
+
config: { botToken: process.env.SLACK_BOT_TOKEN!, channelId: process.env.SLACK_CHANNEL_ID! },
|
|
161
|
+
events: ['approval', 'question'],
|
|
162
|
+
},
|
|
233
163
|
}
|
|
234
164
|
```
|
|
235
165
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
166
|
+
Reference implementations for the receiving side (verifying a Slack
|
|
167
|
+
click, a webhook HMAC signature, a magic-link token) ship under
|
|
168
|
+
`examples/notifier-handler/` for the four channels that need one. See
|
|
169
|
+
[`HUMAN_IN_THE_LOOP.md`](HUMAN_IN_THE_LOOP.md) for the full setup guide —
|
|
170
|
+
live and durable, worked examples, and how resumption actually works
|
|
171
|
+
under the hood.
|
|
242
172
|
|
|
243
|
-
|
|
244
|
-
|
|
173
|
+
## Package system
|
|
174
|
+
|
|
175
|
+
Giving an agent a new capability is usually three separate, hand-authored
|
|
176
|
+
things: a tool file, a `SKILL.md` teaching the model when to use it, and
|
|
177
|
+
an `actauth` rule allowing it to actually run. A **loopengine package**
|
|
178
|
+
bundles all three into one installable unit:
|
|
245
179
|
|
|
246
180
|
```bash
|
|
247
|
-
npx loopengine add-
|
|
181
|
+
npx loopengine add-package <spec> --agent customer-service
|
|
248
182
|
```
|
|
249
183
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
- **Permissions don't inherit.** Each agent's own `actauth.yml` governs
|
|
257
|
-
only its own tools; delegating to a subagent neither grants nor
|
|
258
|
-
inherits permissions in either direction.
|
|
259
|
-
- **`maxTurns` compounds.** A request that reaches a grandchild subagent
|
|
260
|
-
spends turns at every level (default 25 each) — a "simple" answer can
|
|
261
|
-
cost far more than any one agent's own turn budget suggests.
|
|
262
|
-
|
|
263
|
-
### 5. Gateway tool sources — connecting external tools from a web page
|
|
264
|
-
|
|
265
|
-
Beyond hand-written tools and subagents, an agent can pull tools from an
|
|
266
|
-
external gateway — [Composio](https://composio.dev) today, with
|
|
267
|
-
[`mcpplug`](https://www.npmjs.com/package/mcpplug)'s `ToolSource`
|
|
268
|
-
interface designed so more providers (Nango, Arcade, Scalekit, ...) slot
|
|
269
|
-
in later as thin adapters, same shape. Unlike hand-written tools, these
|
|
270
|
-
are meant to be managed by an operator at runtime, not committed to code:
|
|
271
|
-
run the HTTP adapter, open `/agents/config`, pick an agent, and switch to
|
|
272
|
-
its "Gateway tools" tab — alongside "Overview" and "Actauth" — to see its
|
|
273
|
-
connected sources and add or remove one.
|
|
184
|
+
`<spec>` is anything `npm pack` understands — a public or private npm
|
|
185
|
+
package, a scoped package on a private registry, or a plain git repo. The
|
|
186
|
+
tool files, skill directory, and actauth rules all land as real files in
|
|
187
|
+
the agent's own tree, copied in rather than imported as a `node_modules`
|
|
188
|
+
dependency — reviewable, diffable, and editable the same as anything you
|
|
189
|
+
would have hand-written, not an opaque black box.
|
|
274
190
|
|
|
275
191
|
```bash
|
|
276
|
-
npx
|
|
277
|
-
|
|
192
|
+
npx loopengine upgrade-package <packageName> --agent customer-service
|
|
193
|
+
npx loopengine remove-package <packageName> --agent customer-service
|
|
278
194
|
```
|
|
279
195
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
A newly-added tool is **denied by default**, same "opt-in, not silently
|
|
289
|
-
allowed" rule every tool in this repo follows — pass a `decision` when
|
|
290
|
-
adding a source (or use the page's permission dropdown) to seed an
|
|
291
|
-
`actauth.yml` rule for it instead, or add the rule by hand afterward.
|
|
292
|
-
|
|
293
|
-
### 6. Tool permission and multi-tenancy
|
|
294
|
-
|
|
295
|
-
Every tool call is gated by [`actauth`](https://www.npmjs.com/package/actauth):
|
|
296
|
-
each rule in `AgentConfig.rules` maps a `scope` (tenant/environment) + tool
|
|
297
|
-
name to `allow` / `ask` / `deny`. Anything not covered falls through to
|
|
298
|
-
`defaultDecision`. An `ask` decision routes to whichever approver applies
|
|
299
|
-
for the call's own channel: `cli`/`http_stream` always get the library's
|
|
300
|
-
own live default (`ConsoleApprover`, blocking on stdin, for `cli`; a live
|
|
301
|
-
approval popup for `http_stream`) — nothing to configure there — and
|
|
302
|
-
`http` gets a durable one whenever `AgentConfig.httpNotifier` is
|
|
303
|
-
configured for it (see below), falling back to the same kind of live
|
|
304
|
-
default otherwise.
|
|
305
|
-
|
|
306
|
-
`ask` approvers come in two shapes. A **live** one (`WebchatApprover`,
|
|
307
|
-
`SlackChatApprover`, `ConsoleApprover`) is awaited directly — the turn stays
|
|
308
|
-
suspended until a human decides, which only makes sense when someone's
|
|
309
|
-
actually there right now (a terminal, an open chat connection). A
|
|
310
|
-
**durable** one (`WebhookApprover`) fires a signed webhook and returns
|
|
311
|
-
instantly instead — the turn ends with `stopReason: 'pending_approval'`,
|
|
312
|
-
durably resumable minutes or days later via
|
|
313
|
-
`POST /pending-approvals/:pendingId/resolve`, without holding any
|
|
314
|
-
process open in between. See `HUMAN_IN_THE_LOOP.md` for the full design.
|
|
315
|
-
|
|
316
|
-
The system `ask_user` tool (a model asking a human a genuinely ambiguous
|
|
317
|
-
clarifying question mid-turn) gets the same live/durable split, via
|
|
318
|
-
`RunAgentOptions.questionHandler` (or `AgentConfig.httpNotifier`'s own
|
|
319
|
-
`'question'` event, for the `http` channel specifically): live by default
|
|
320
|
-
(blocks on the channel it's raised on), or durable — a
|
|
321
|
-
`DurableQuestionHandler` (e.g. `core/http-notify-triggers/webhook.ts`'s
|
|
322
|
-
`WebhookNotifier`) instead ends the turn with `stopReason:
|
|
323
|
-
'pending_question'`, resumable via
|
|
324
|
-
`POST /pending-questions/:pendingId/answer`. See `HUMAN_IN_THE_LOOP.md`'s
|
|
325
|
-
own "Durable questions" section.
|
|
326
|
-
|
|
327
|
-
For the practical "what do I actually need to build" version of all of
|
|
328
|
-
this — live chat setup, durable setup, and reference implementations for
|
|
329
|
-
Slack/Lark/email/generic-webhook notification channels — see
|
|
330
|
-
[`HUMAN_IN_THE_LOOP.md`](HUMAN_IN_THE_LOOP.md).
|
|
331
|
-
|
|
332
|
-
A real agent's rules live in `agents/<name>/actauth.yml`:
|
|
333
|
-
|
|
334
|
-
```yaml
|
|
335
|
-
default_decision: deny
|
|
336
|
-
|
|
337
|
-
rules:
|
|
338
|
-
- name: weather-lookup-always-allowed
|
|
339
|
-
scope: "*/*"
|
|
340
|
-
tool: get_weather
|
|
341
|
-
decision: allow
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
`scope` is `tenant/environment` — `"*/*"` here means "every tenant, every
|
|
345
|
-
environment." Rules are matched most-specific-scope-first, so an agent
|
|
346
|
-
that needs different behavior per tenant just adds a more specific rule
|
|
347
|
-
(e.g. `acme-corp/production`) alongside the wildcard fallback.
|
|
348
|
-
|
|
349
|
-
That `scope` is what makes rules multi-tenant: the same agent can behave
|
|
350
|
-
differently for different customers. `environment` is a deployment-wide
|
|
351
|
-
setting (`LOOPENGINE_ENV`, default `production`). `tenant` is resolved per
|
|
352
|
-
request via `AgentConfig.tenantFor?: (headers, body) => string |
|
|
353
|
-
undefined` — from headers only, never the request body, since it feeds
|
|
354
|
-
permission decisions directly. No `tenantFor` means every request is the
|
|
355
|
-
`'default'` tenant.
|
|
356
|
-
|
|
357
|
-
### 7. Sessions
|
|
358
|
-
|
|
359
|
-
A session is one ongoing conversation. Message history persists between
|
|
360
|
-
requests automatically — send a message, get a reply, come back later with
|
|
361
|
-
the same `sessionId` and continue where you left off. Two storage backends,
|
|
362
|
-
picked automatically: a local file store, or Redis if `REDIS_URL` is set
|
|
363
|
-
(needed for multiple server instances).
|
|
364
|
-
|
|
365
|
-
An agent can define `AgentConfig.sessionIdFor(body)` to control what
|
|
366
|
-
counts as "one conversation" for its own domain (e.g. `customer-service`
|
|
367
|
-
hashes `customerEmail`, so a request with no email is rejected rather than
|
|
368
|
-
starting an anonymous session). Without it, the caller just supplies a
|
|
369
|
-
`sessionId`, or gets one generated for them.
|
|
196
|
+
Upgrading does a real three-way merge per file — a hand-edit since install
|
|
197
|
+
survives, a genuine conflict leaves `<<<<<<<` markers to resolve by hand
|
|
198
|
+
instead of silently overwriting either side. Removing refuses a file
|
|
199
|
+
that's been modified since install unless you pass `--force`. A package
|
|
200
|
+
can also declare the env vars its tools need, which then show up in the
|
|
201
|
+
Admin UI's [Environment tab](#admin-ui) automatically once installed. See
|
|
202
|
+
[`PACKAGES.md`](PACKAGES.md) for the full design — format, publishing,
|
|
203
|
+
and how the merge/upgrade mechanics work in detail.
|
|
370
204
|
|
|
371
205
|
## Running an agent
|
|
372
206
|
|
|
@@ -443,18 +277,8 @@ plain-HTTP/CLI transport bindings, and a JSON Schema
|
|
|
443
277
|
`core/client.ts` and the `examples/chatbox/react`/`examples/chatbox/vue`
|
|
444
278
|
hooks are reference implementations of it.
|
|
445
279
|
|
|
446
|
-
**Agent config page:**
|
|
447
|
-
(optionally `?agent=<name>`)
|
|
448
|
-
config — system prompt, model, tools (with their JSON schemas and
|
|
449
|
-
parallel-safety), the actual ActAuth rules that would be enforced (source,
|
|
450
|
-
default decision, per-rule scope/tool/decision/`when`), and whether
|
|
451
|
-
`sessionIdFor`/`tenantFor`/`isSafeTool` are custom or defaulted, and
|
|
452
|
-
whether the `http` channel's approver is custom (`httpNotifier` covers
|
|
453
|
-
`'approval'`) or the library's own default. Backed
|
|
454
|
-
by `GET /agents/:name/config`, which reuses the same
|
|
455
|
-
rule/tool resolution `runAgent()` itself uses, so it can't drift out of
|
|
456
|
-
sync with what a real request actually gets. Never returns
|
|
457
|
-
`AgentModelConfig.apiKey`.
|
|
280
|
+
**Agent config page:** this is the [Admin UI](#admin-ui) described above
|
|
281
|
+
— open `http://localhost:8787/agents/config` (optionally `?agent=<name>`).
|
|
458
282
|
|
|
459
283
|
## Wiring a real model
|
|
460
284
|
|
|
@@ -490,13 +314,14 @@ testing), export your own `createModelCall(): ModelCall` instead.
|
|
|
490
314
|
| `core/budget.ts` / `core/compaction.ts` | Context-window budget tracking and tail-preserving compaction (vendored in-repo, not an external dependency) |
|
|
491
315
|
| `core/recovery.ts` | Retries prompt-too-long / truncated-output failures (vendored in-repo, not an external dependency) |
|
|
492
316
|
| `core/durable-approvals.ts` | `TurnCheckpoint`/`CheckpointStore` (file/Redis) backing durable, resumable `ask` decisions — see `HUMAN_IN_THE_LOOP.md` |
|
|
493
|
-
|
|
|
494
|
-
|
|
|
495
|
-
|
|
|
496
|
-
|
|
|
497
|
-
|
|
498
|
-
Installing `loopengine` pulls
|
|
499
|
-
install
|
|
317
|
+
| `core/sessionknit.ts` | Durable session log with crash-interruption detection (vendored in-repo, not an external dependency) |
|
|
318
|
+
| `core/skillgarden/` | `SKILL.md` discovery and lazy loading (vendored in-repo, not an external dependency) |
|
|
319
|
+
| `core/toollane.ts` | Parallel/solo tool-call scheduling (vendored in-repo, not an external dependency) |
|
|
320
|
+
| `core/mcpplug.ts` | Gateway tool sourcing (e.g. Composio) (vendored in-repo, not an external dependency) |
|
|
321
|
+
|
|
322
|
+
Installing `loopengine` pulls in `actauth` as a regular dependency —
|
|
323
|
+
install it directly only if you want to use it standalone. Everything
|
|
324
|
+
else in this table ships inside `loopengine` itself.
|
|
500
325
|
|
|
501
326
|
## Deployment
|
|
502
327
|
|
|
@@ -24,7 +24,7 @@ import { tmpdir } from 'node:os';
|
|
|
24
24
|
import { parse as parseYaml } from 'yaml';
|
|
25
25
|
import semver from 'semver';
|
|
26
26
|
import { agentDir } from '../core/gateway-tools.js';
|
|
27
|
-
import { addToolToIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
27
|
+
import { addToolToIndex, removeToolFromIndex, toCamelCase } from '../web/http-tool-admin.js';
|
|
28
28
|
import { addActauthRule, updateActauthRule, removeActauthRule, readActauthConfig } from '../web/actauth-admin.js';
|
|
29
29
|
export class PackageManifestError extends Error {
|
|
30
30
|
}
|
|
@@ -484,6 +484,7 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
484
484
|
const refused = [];
|
|
485
485
|
const remainingTools = [];
|
|
486
486
|
const remainingSkills = [];
|
|
487
|
+
const toolsIndexPath = join(agentDir(agentName), 'tools', 'index.ts');
|
|
487
488
|
for (const toolName of record.tools) {
|
|
488
489
|
const relPath = `tools/${toolName}.ts`;
|
|
489
490
|
const fullPath = join(agentDir(agentName), relPath);
|
|
@@ -493,6 +494,14 @@ export function removePackage(agentName, packageName, force = false) {
|
|
|
493
494
|
continue;
|
|
494
495
|
}
|
|
495
496
|
rmSync(fullPath, { force: true });
|
|
497
|
+
// Undoes installPackage's own addToolToIndex call — without this, a
|
|
498
|
+
// deleted tool's import survives in tools/index.ts, and either fails
|
|
499
|
+
// the next build (a dangling import to a file that no longer exists)
|
|
500
|
+
// or, worse, silently duplicates on a later add-package of the same
|
|
501
|
+
// tool (addToolToIndex used to have no idempotence check at all —
|
|
502
|
+
// now it does, but this is the actual fix: the stale entry shouldn't
|
|
503
|
+
// be there to begin with).
|
|
504
|
+
removeToolFromIndex(toolsIndexPath, toolName, toCamelCase(toolName));
|
|
496
505
|
removed.push(relPath);
|
|
497
506
|
}
|
|
498
507
|
for (const skillId of record.skills) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Rule, Decision, Scope } from 'actauth';
|
|
2
2
|
import type { Redis } from 'ioredis';
|
|
3
|
-
import type { SafetyClassifier } from 'toollane';
|
|
3
|
+
import type { SafetyClassifier } from './toollane.js';
|
|
4
4
|
import type { Summarizer } from './compaction.js';
|
|
5
5
|
/** Which channel a runAgent() call is on — the value RunAgentOptions.channel
|
|
6
6
|
* carries, checked against directly by AgentConfig.httpNotifier's own
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Lets an operator register external tool gateways (Composio today —
|
|
2
|
-
// Nango, Arcade, Scalekit are meant to slot in later as thin
|
|
3
|
-
// ToolSource adapters, same shape, once this mechanism is
|
|
4
|
-
// an agent, without hand-writing a tools/index.ts. The registry is
|
|
2
|
+
// Nango, Arcade, Scalekit are meant to slot in later as thin
|
|
3
|
+
// mcpplug.ts ToolSource adapters, same shape, once this mechanism is
|
|
4
|
+
// proven) against an agent, without hand-writing a tools/index.ts. The registry is
|
|
5
5
|
// agents/<name>/gateway-tools.yml — plain data, read fresh off disk every
|
|
6
6
|
// call, same "no restart to see an edit" behavior actauth.yml already
|
|
7
7
|
// has (see run-agent.ts's loadRules). adapters/http.ts's admin routes are
|
|
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { execFile } from 'node:child_process';
|
|
13
13
|
import { promisify } from 'node:util';
|
|
14
14
|
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
15
|
-
import { connectComposioSource } from 'mcpplug';
|
|
15
|
+
import { connectComposioSource } from './mcpplug.js';
|
|
16
16
|
const execFileAsync = promisify(execFile);
|
|
17
17
|
// gateway-tools.yml is plain data (see this file's own header comment),
|
|
18
18
|
// not compiled code — same "resolved against process.cwd(), like
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ToolDefinition } from './agent-config.js';
|
|
2
|
+
/** One connection to one external tool gateway (Composio today; a raw MCP
|
|
3
|
+
* client is a later, separate source). `name` is used to namespace every
|
|
4
|
+
* tool it produces, so two sources never collide. */
|
|
5
|
+
export interface ToolSource {
|
|
6
|
+
name: string;
|
|
7
|
+
loadTools(): Promise<ToolDefinition[]>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface ComposioSourceOptions {
|
|
11
|
+
/** Composio tool slugs to expose, e.g.
|
|
12
|
+
* "GITHUB_LIST_REPOSITORIES_FOR_THE_AUTHENTICATED_USER". Explicit, not
|
|
13
|
+
* auto-discovered — same reasoning actauth rules are opt-in per tool
|
|
14
|
+
* rather than a blanket allow; an agent should only see the Composio
|
|
15
|
+
* actions its config actually lists. */
|
|
16
|
+
slugs: string[];
|
|
17
|
+
/** Defaults to 'composio' — override to point at a stand-in binary in tests. */
|
|
18
|
+
cliCommand?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Sources ToolDefinitions from the Composio CLI — already-authenticated
|
|
21
|
+
* (`composio link <toolkit>`) tool execution across 1000+ apps, so this
|
|
22
|
+
* source needs no OAuth code of its own; Composio owns that entirely. */
|
|
23
|
+
export declare function connectComposioSource(name: string, options: ComposioSourceOptions): Promise<ToolSource>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Formerly the standalone `mcpplug` package — folded in here since it
|
|
2
|
+
// had exactly one real consumer (gateway-tools.ts, below) and the
|
|
3
|
+
// "usable by any agent runtime" framing it shipped with was aspirational,
|
|
4
|
+
// never actually exercised by a second host. Reuses this repo's own
|
|
5
|
+
// ToolDefinition (agent-config.ts) directly instead of mcpplug's own
|
|
6
|
+
// parallel-but-identical copy — that duplication only existed to keep
|
|
7
|
+
// mcpplug import-free of loopengine, which stopped mattering once it
|
|
8
|
+
// moved in.
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
function humanize(slug) {
|
|
13
|
+
return slug.toLowerCase().split('_').join(' ');
|
|
14
|
+
}
|
|
15
|
+
async function runCli(cliCommand, args) {
|
|
16
|
+
let stdout;
|
|
17
|
+
try {
|
|
18
|
+
;
|
|
19
|
+
({ stdout } = await run(cliCommand, args));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23
|
+
throw new Error(`composio CLI call failed (${args.join(' ')}): ${message}`);
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(stdout);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error(`composio CLI returned non-JSON output for ${args.join(' ')}: ${stdout}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Sources ToolDefinitions from the Composio CLI — already-authenticated
|
|
33
|
+
* (`composio link <toolkit>`) tool execution across 1000+ apps, so this
|
|
34
|
+
* source needs no OAuth code of its own; Composio owns that entirely. */
|
|
35
|
+
export async function connectComposioSource(name, options) {
|
|
36
|
+
const cliCommand = options.cliCommand ?? 'composio';
|
|
37
|
+
return {
|
|
38
|
+
name,
|
|
39
|
+
async loadTools() {
|
|
40
|
+
const tools = [];
|
|
41
|
+
for (const slug of options.slugs) {
|
|
42
|
+
const schema = await runCli(cliCommand, ['execute', slug, '--get-schema']);
|
|
43
|
+
const inputSchema = schema.inputSchema ?? {};
|
|
44
|
+
tools.push({
|
|
45
|
+
name: `${name}_${slug}`,
|
|
46
|
+
description: humanize(slug),
|
|
47
|
+
input_schema: inputSchema,
|
|
48
|
+
execute: async (input) => {
|
|
49
|
+
const result = await runCli(cliCommand, ['execute', slug, '-d', JSON.stringify(input)]);
|
|
50
|
+
if (result.successful === false || result.error) {
|
|
51
|
+
throw new Error(`composio tool ${slug} failed: ${JSON.stringify(result.error ?? result)}`);
|
|
52
|
+
}
|
|
53
|
+
return result.data ?? result;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return tools;
|
|
58
|
+
},
|
|
59
|
+
// Each call is its own subprocess — nothing held open between calls,
|
|
60
|
+
// so there's nothing to tear down. Kept for symmetry with sources
|
|
61
|
+
// that do hold a live connection (e.g. a future raw MCP client).
|
|
62
|
+
close: async () => { },
|
|
63
|
+
};
|
|
64
|
+
}
|