loopengine 0.1.2 → 0.1.4
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/dist/core/agent-config.d.ts +9 -5
- package/dist/core/durable-approvals.d.ts +106 -0
- package/dist/core/durable-approvals.js +158 -0
- package/dist/core/gateway-tools.js +10 -9
- package/dist/core/run-agent.js +20 -3
- package/dist/index.d.ts +24 -1
- package/dist/index.js +26 -1
- package/dist/web/actauth-admin.d.ts +36 -0
- package/dist/web/actauth-admin.js +108 -0
- package/dist/web/agent-file-admin.d.ts +30 -0
- package/dist/web/agent-file-admin.js +165 -0
- package/dist/web/agents-config-page.d.ts +1 -0
- package/dist/web/agents-config-page.js +1628 -0
- package/dist/web/agents-list-page.d.ts +1 -0
- package/dist/web/agents-list-page.js +246 -0
- package/dist/web/dev-ui-styles.d.ts +1 -0
- package/dist/web/dev-ui-styles.js +63 -0
- package/dist/web/global-config-page.d.ts +1 -0
- package/dist/web/global-config-page.js +303 -0
- package/dist/web/global-config.d.ts +57 -0
- package/dist/web/global-config.js +67 -0
- package/dist/web/playground.d.ts +1 -0
- package/dist/web/playground.js +1271 -0
- package/dist/web/skills-admin.d.ts +23 -0
- package/dist/web/skills-admin.js +67 -0
- package/dist/web/web-approver.d.ts +35 -0
- package/dist/web/web-approver.js +88 -0
- package/package.json +5 -2
|
@@ -295,11 +295,15 @@ export interface AgentConfig {
|
|
|
295
295
|
* Omit entirely and it defaults to importing
|
|
296
296
|
* `agents/<name>/tools/index.{ts,js}` and using its exported `tools` —
|
|
297
297
|
* this repo's own aggregation-file convention (see
|
|
298
|
-
* `agents/customer-service/tools/index.ts`)
|
|
299
|
-
* `run-agent.ts` itself
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
298
|
+
* `agents/customer-service/tools/index.ts`). Tried first next to
|
|
299
|
+
* `run-agent.ts` itself, since that file is real compiled code, not
|
|
300
|
+
* data — right when `agents/` is built alongside `core/` into the same
|
|
301
|
+
* `dist/` tree (this repo's own reference agents); falls back to
|
|
302
|
+
* `process.cwd()`, same as `skillsDirs`/`rules`, when nothing's built
|
|
303
|
+
* there — right for a project that only depends on the published
|
|
304
|
+
* package, whose own `agents/<name>/tools/` was never compiled into
|
|
305
|
+
* `node_modules/loopengine` at all.
|
|
306
|
+
* A missing file either place is just `[]`, not an error — same
|
|
303
307
|
* missing-is-fine treatment `skillsDirs` gets. An agent that needs to
|
|
304
308
|
* merge in tools from somewhere else too (e.g.
|
|
305
309
|
* `agents/file-agent/index.ts`'s Composio-sourced ones) can't rely on
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Redis } from 'ioredis';
|
|
2
|
+
import type { ModelContentBlock } from './run-agent.js';
|
|
3
|
+
export interface OutstandingItem {
|
|
4
|
+
/** Which kind of durable decision this is — 'approval' (a gated tool
|
|
5
|
+
* call) or 'question' (a system_ask_user call), see
|
|
6
|
+
* HUMAN_IN_THE_LOOP.md's "Durable questions" section. Optional, not
|
|
7
|
+
* required: this store is deliberately agnostic to what it's storing
|
|
8
|
+
* (see this module's own header comment) — undefined means 'approval',
|
|
9
|
+
* preserving every checkpoint written before 'question' existed.
|
|
10
|
+
* adapters/http.ts's two resolve routes are the only things that ever
|
|
11
|
+
* read this. */
|
|
12
|
+
kind?: 'approval' | 'question';
|
|
13
|
+
toolUseId: string;
|
|
14
|
+
tool: string;
|
|
15
|
+
args: Record<string, unknown>;
|
|
16
|
+
reason: string;
|
|
17
|
+
}
|
|
18
|
+
export interface TurnCheckpoint {
|
|
19
|
+
id: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
agent: string;
|
|
22
|
+
tenant: string;
|
|
23
|
+
/** tool_result blocks already computed — either safely auto-allowed
|
|
24
|
+
* calls from the same batch that ran immediately (see run-agent.ts's
|
|
25
|
+
* bucket-then-execute), or outstanding items resolved since this
|
|
26
|
+
* checkpoint was created. Complete and ready to push as the turn's
|
|
27
|
+
* completing message the instant `outstanding` is empty. */
|
|
28
|
+
resultsSoFar: ModelContentBlock[];
|
|
29
|
+
/** Keyed by pendingId, not toolUseId — a pendingId is what an incoming
|
|
30
|
+
* resolution actually names (a webhook callback, a magic-link visit),
|
|
31
|
+
* and is what DurableApprover.requestDurableApproval handed out. */
|
|
32
|
+
outstanding: Record<string, OutstandingItem>;
|
|
33
|
+
closed: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface CreateCheckpointInput {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
agent: string;
|
|
38
|
+
tenant: string;
|
|
39
|
+
resultsSoFar: ModelContentBlock[];
|
|
40
|
+
outstanding: Record<string, OutstandingItem>;
|
|
41
|
+
}
|
|
42
|
+
export interface CheckpointStore {
|
|
43
|
+
create(input: CreateCheckpointInput): Promise<TurnCheckpoint>;
|
|
44
|
+
/** Loads the checkpoint `pendingId` belongs to, runs `fn` exclusively
|
|
45
|
+
* for that checkpoint (serializes concurrent resolutions of different
|
|
46
|
+
* outstanding items in the same checkpoint — see HUMAN_IN_THE_LOOP.md's
|
|
47
|
+
* own "denial closes the checkpoint immediately" race), and persists
|
|
48
|
+
* whatever checkpoint `fn` returns (or deletes it, once closed —
|
|
49
|
+
* nothing revisits a closed checkpoint again).
|
|
50
|
+
*
|
|
51
|
+
* `fn` receives `undefined` for an unknown pendingId, or one whose
|
|
52
|
+
* checkpoint is already closed — the graceful no-op case (a sibling
|
|
53
|
+
* denial, or an earlier resolve of the very same pendingId, got there
|
|
54
|
+
* first). `fn` itself decides what "closed" means (every outstanding
|
|
55
|
+
* item resolved, or a denial that closes early and skips the rest) —
|
|
56
|
+
* this store only persists the result, it never decides it. */
|
|
57
|
+
withCheckpoint<T>(pendingId: string, fn: (checkpoint: TurnCheckpoint | undefined) => Promise<{
|
|
58
|
+
checkpoint: TurnCheckpoint | undefined;
|
|
59
|
+
result: T;
|
|
60
|
+
}>): Promise<T>;
|
|
61
|
+
close(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
64
|
+
* small pendingId -> checkpointId pointer file per outstanding item under
|
|
65
|
+
* `<dir>/pending-index/`. Fine for a single local process; not safe
|
|
66
|
+
* across multiple processes/instances — those need RedisCheckpointStore. */
|
|
67
|
+
export declare class FileCheckpointStore implements CheckpointStore {
|
|
68
|
+
private readonly checkpointsDir;
|
|
69
|
+
private readonly indexDir;
|
|
70
|
+
constructor(dir: string);
|
|
71
|
+
private checkpointPath;
|
|
72
|
+
private indexPath;
|
|
73
|
+
create(input: CreateCheckpointInput): Promise<TurnCheckpoint>;
|
|
74
|
+
withCheckpoint<T>(pendingId: string, fn: (checkpoint: TurnCheckpoint | undefined) => Promise<{
|
|
75
|
+
checkpoint: TurnCheckpoint | undefined;
|
|
76
|
+
result: T;
|
|
77
|
+
}>): Promise<T>;
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/** Redis-backed store for multi-instance deployments. Unlike
|
|
81
|
+
* RedisSessionStore's lock (held for an entire turn, including however
|
|
82
|
+
* long a live human takes), this one only ever needs to be held for a
|
|
83
|
+
* quick read-modify-write — durable resolution never blocks on a human
|
|
84
|
+
* while holding it — so a short SET NX PX lock with retry, no renewal
|
|
85
|
+
* loop, is enough. */
|
|
86
|
+
export declare class RedisCheckpointStore implements CheckpointStore {
|
|
87
|
+
private readonly redis;
|
|
88
|
+
private readonly lockTtlMs;
|
|
89
|
+
private readonly lockRetryDelayMs;
|
|
90
|
+
private readonly lockMaxWaitMs;
|
|
91
|
+
constructor(redisUrl: string, client?: Redis);
|
|
92
|
+
private checkpointKey;
|
|
93
|
+
private indexKey;
|
|
94
|
+
private lockKey;
|
|
95
|
+
private withLock;
|
|
96
|
+
create(input: CreateCheckpointInput): Promise<TurnCheckpoint>;
|
|
97
|
+
withCheckpoint<T>(pendingId: string, fn: (checkpoint: TurnCheckpoint | undefined) => Promise<{
|
|
98
|
+
checkpoint: TurnCheckpoint | undefined;
|
|
99
|
+
result: T;
|
|
100
|
+
}>): Promise<T>;
|
|
101
|
+
close(): Promise<void>;
|
|
102
|
+
}
|
|
103
|
+
/** Same REDIS_URL-gated pattern as session-store.ts's own
|
|
104
|
+
* createSessionStore(): set -> RedisCheckpointStore, otherwise
|
|
105
|
+
* FileCheckpointStore under .checkpoints. */
|
|
106
|
+
export declare function createCheckpointStore(): CheckpointStore;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Durable storage for a turn paused on one or more DurableApprover
|
|
2
|
+
// decisions — see HUMAN_IN_THE_LOOP.md for the full design. Mirrors
|
|
3
|
+
// session-store.ts's own shape deliberately: a small "load, run
|
|
4
|
+
// exclusively, persist" contract (withCheckpoint, here, plays the same
|
|
5
|
+
// role withSession does there), a file-backed store for local dev and a
|
|
6
|
+
// Redis-backed one for multi-instance deployments, and a
|
|
7
|
+
// REDIS_URL-gated factory. This module does no tool execution and knows
|
|
8
|
+
// nothing about ActAuth or the model loop — it only stores and locks;
|
|
9
|
+
// run-agent.ts/adapters/http.ts own deciding what a resolution actually
|
|
10
|
+
// does (see HUMAN_IN_THE_LOOP.md's own "request + raw resolve only"
|
|
11
|
+
// division of labor).
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { Redis } from 'ioredis';
|
|
16
|
+
import { KeyedMutex } from './session-store.js';
|
|
17
|
+
const fileMutex = new KeyedMutex();
|
|
18
|
+
/** One JSON file per checkpoint under `<dir>/checkpoints/`, plus one
|
|
19
|
+
* small pendingId -> checkpointId pointer file per outstanding item under
|
|
20
|
+
* `<dir>/pending-index/`. Fine for a single local process; not safe
|
|
21
|
+
* across multiple processes/instances — those need RedisCheckpointStore. */
|
|
22
|
+
export class FileCheckpointStore {
|
|
23
|
+
checkpointsDir;
|
|
24
|
+
indexDir;
|
|
25
|
+
constructor(dir) {
|
|
26
|
+
this.checkpointsDir = path.join(dir, 'checkpoints');
|
|
27
|
+
this.indexDir = path.join(dir, 'pending-index');
|
|
28
|
+
mkdirSync(this.checkpointsDir, { recursive: true });
|
|
29
|
+
mkdirSync(this.indexDir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
checkpointPath(id) {
|
|
32
|
+
return path.join(this.checkpointsDir, `${id}.json`);
|
|
33
|
+
}
|
|
34
|
+
indexPath(pendingId) {
|
|
35
|
+
return path.join(this.indexDir, pendingId);
|
|
36
|
+
}
|
|
37
|
+
async create(input) {
|
|
38
|
+
const checkpoint = { id: randomUUID(), closed: false, ...input };
|
|
39
|
+
writeFileSync(this.checkpointPath(checkpoint.id), JSON.stringify(checkpoint));
|
|
40
|
+
for (const pendingId of Object.keys(checkpoint.outstanding)) {
|
|
41
|
+
writeFileSync(this.indexPath(pendingId), checkpoint.id);
|
|
42
|
+
}
|
|
43
|
+
return checkpoint;
|
|
44
|
+
}
|
|
45
|
+
async withCheckpoint(pendingId, fn) {
|
|
46
|
+
const indexPath = this.indexPath(pendingId);
|
|
47
|
+
if (!existsSync(indexPath))
|
|
48
|
+
return (await fn(undefined)).result;
|
|
49
|
+
const checkpointId = readFileSync(indexPath, 'utf8');
|
|
50
|
+
return fileMutex.run(checkpointId, async () => {
|
|
51
|
+
const checkpointPath = this.checkpointPath(checkpointId);
|
|
52
|
+
// A concurrent resolution of a sibling pendingId in the same
|
|
53
|
+
// checkpoint may have already closed (and removed) it while this
|
|
54
|
+
// call was queued behind the mutex above.
|
|
55
|
+
const current = existsSync(checkpointPath)
|
|
56
|
+
? JSON.parse(readFileSync(checkpointPath, 'utf8'))
|
|
57
|
+
: undefined;
|
|
58
|
+
const { checkpoint, result } = await fn(current && !current.closed ? current : undefined);
|
|
59
|
+
if (checkpoint) {
|
|
60
|
+
if (checkpoint.closed) {
|
|
61
|
+
rmSync(checkpointPath, { force: true });
|
|
62
|
+
for (const id of [...Object.keys(current?.outstanding ?? {})])
|
|
63
|
+
rmSync(this.indexPath(id), { force: true });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
writeFileSync(checkpointPath, JSON.stringify(checkpoint));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async close() { }
|
|
73
|
+
}
|
|
74
|
+
/** Redis-backed store for multi-instance deployments. Unlike
|
|
75
|
+
* RedisSessionStore's lock (held for an entire turn, including however
|
|
76
|
+
* long a live human takes), this one only ever needs to be held for a
|
|
77
|
+
* quick read-modify-write — durable resolution never blocks on a human
|
|
78
|
+
* while holding it — so a short SET NX PX lock with retry, no renewal
|
|
79
|
+
* loop, is enough. */
|
|
80
|
+
export class RedisCheckpointStore {
|
|
81
|
+
redis;
|
|
82
|
+
lockTtlMs = 5_000;
|
|
83
|
+
lockRetryDelayMs = 50;
|
|
84
|
+
lockMaxWaitMs = 10_000;
|
|
85
|
+
constructor(redisUrl, client) {
|
|
86
|
+
this.redis = client ?? new Redis(redisUrl);
|
|
87
|
+
}
|
|
88
|
+
checkpointKey(id) {
|
|
89
|
+
return `checkpoint:${id}`;
|
|
90
|
+
}
|
|
91
|
+
indexKey(pendingId) {
|
|
92
|
+
return `checkpoint-pending-index:${pendingId}`;
|
|
93
|
+
}
|
|
94
|
+
lockKey(checkpointId) {
|
|
95
|
+
return `checkpoint-lock:${checkpointId}`;
|
|
96
|
+
}
|
|
97
|
+
async withLock(checkpointId, fn) {
|
|
98
|
+
const token = randomUUID();
|
|
99
|
+
const key = this.lockKey(checkpointId);
|
|
100
|
+
const deadline = Date.now() + this.lockMaxWaitMs;
|
|
101
|
+
for (;;) {
|
|
102
|
+
const ok = await this.redis.set(key, token, 'PX', this.lockTtlMs, 'NX');
|
|
103
|
+
if (ok)
|
|
104
|
+
break;
|
|
105
|
+
if (Date.now() > deadline)
|
|
106
|
+
throw new Error(`timed out waiting for lock on checkpoint '${checkpointId}'`);
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, this.lockRetryDelayMs));
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return await fn();
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
// Compare-and-delete, same script shape session-store.ts's own
|
|
114
|
+
// UNLOCK_IF_OWNER_SCRIPT uses — don't release a lock a slower
|
|
115
|
+
// caller's TTL expiry already handed to someone else.
|
|
116
|
+
await this.redis.eval(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`, 1, key, token);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async create(input) {
|
|
120
|
+
const checkpoint = { id: randomUUID(), closed: false, ...input };
|
|
121
|
+
await this.redis.set(this.checkpointKey(checkpoint.id), JSON.stringify(checkpoint));
|
|
122
|
+
for (const pendingId of Object.keys(checkpoint.outstanding)) {
|
|
123
|
+
await this.redis.set(this.indexKey(pendingId), checkpoint.id);
|
|
124
|
+
}
|
|
125
|
+
return checkpoint;
|
|
126
|
+
}
|
|
127
|
+
async withCheckpoint(pendingId, fn) {
|
|
128
|
+
const checkpointId = await this.redis.get(this.indexKey(pendingId));
|
|
129
|
+
if (!checkpointId)
|
|
130
|
+
return (await fn(undefined)).result;
|
|
131
|
+
return this.withLock(checkpointId, async () => {
|
|
132
|
+
const raw = await this.redis.get(this.checkpointKey(checkpointId));
|
|
133
|
+
const current = raw ? JSON.parse(raw) : undefined;
|
|
134
|
+
const { checkpoint, result } = await fn(current && !current.closed ? current : undefined);
|
|
135
|
+
if (checkpoint) {
|
|
136
|
+
if (checkpoint.closed) {
|
|
137
|
+
await this.redis.del(this.checkpointKey(checkpointId));
|
|
138
|
+
for (const id of Object.keys(current?.outstanding ?? {}))
|
|
139
|
+
await this.redis.del(this.indexKey(id));
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
await this.redis.set(this.checkpointKey(checkpointId), JSON.stringify(checkpoint));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async close() {
|
|
149
|
+
await this.redis.quit();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Same REDIS_URL-gated pattern as session-store.ts's own
|
|
153
|
+
* createSessionStore(): set -> RedisCheckpointStore, otherwise
|
|
154
|
+
* FileCheckpointStore under .checkpoints. */
|
|
155
|
+
export function createCheckpointStore() {
|
|
156
|
+
const redisUrl = process.env.REDIS_URL;
|
|
157
|
+
return redisUrl ? new RedisCheckpointStore(redisUrl) : new FileCheckpointStore('.checkpoints');
|
|
158
|
+
}
|
|
@@ -8,20 +8,21 @@
|
|
|
8
8
|
// the only intended writer of that file; loadGatewayToolsFromDir is what
|
|
9
9
|
// run-agent.ts calls to actually resolve it into ToolDefinitions.
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import {
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { join } from 'node:path';
|
|
13
12
|
import { execFile } from 'node:child_process';
|
|
14
13
|
import { promisify } from 'node:util';
|
|
15
14
|
import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from 'yaml';
|
|
16
15
|
import { connectComposioSource } from 'mcpplug';
|
|
17
16
|
const execFileAsync = promisify(execFile);
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
17
|
+
// gateway-tools.yml is plain data (see this file's own header comment),
|
|
18
|
+
// not compiled code — same "resolved against process.cwd(), like
|
|
19
|
+
// actauth.yml/skillsDirs, not import.meta.url" reasoning run-agent.ts's
|
|
20
|
+
// own loadRules/skillsDirs defaults use, and unlike that file's tools/
|
|
21
|
+
// subagents defaults (real compiled code, needing the built-alongside-
|
|
22
|
+
// core/ fallback) this one doesn't need a dual-path check: the physical
|
|
23
|
+
// file is at the same cwd-relative path whether it's this repo's own
|
|
24
|
+
// agents/ or an external project's.
|
|
25
|
+
const agentsRootDir = join(process.cwd(), 'agents');
|
|
25
26
|
function gatewayToolsPath(dir) {
|
|
26
27
|
return join(dir, 'gateway-tools.yml');
|
|
27
28
|
}
|
package/dist/core/run-agent.js
CHANGED
|
@@ -33,6 +33,23 @@ export { systemTools } from './system-tools/index.js';
|
|
|
33
33
|
// tools/index.ts is real code that only exists compiled, at a different
|
|
34
34
|
// relative location, once dist/ is what's actually running.
|
|
35
35
|
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
36
|
+
// agentsRootDir above is right when agents/ is compiled alongside core/
|
|
37
|
+
// into the same dist/ tree (this repo's own reference agents; also this
|
|
38
|
+
// repo's own Dockerfile, which copies dist/agents/**/*.js this way) — but
|
|
39
|
+
// a project that only *depends on* the published loopengine package has
|
|
40
|
+
// no dist/agents/<name> inside node_modules/loopengine at all, just its
|
|
41
|
+
// own agents/<name>/ at the project root, found only via process.cwd(),
|
|
42
|
+
// the same way loadRules/skillsDirs already resolve their own (data, not
|
|
43
|
+
// code) defaults. Tried first since it's this repo's own existing,
|
|
44
|
+
// tested behavior; process.cwd() only kicks in when nothing's built
|
|
45
|
+
// there to find — so this repo's own agents are unaffected either way.
|
|
46
|
+
function resolveAgentCodeDir(name, subpath, indexRequired) {
|
|
47
|
+
const built = join(agentsRootDir, name, subpath);
|
|
48
|
+
const builtExists = indexRequired
|
|
49
|
+
? existsSync(join(built, 'index.ts')) || existsSync(join(built, 'index.js'))
|
|
50
|
+
: existsSync(built);
|
|
51
|
+
return builtExists ? built : join(process.cwd(), 'agents', name, subpath);
|
|
52
|
+
}
|
|
36
53
|
// Same "resolved relative to this file's own location" reasoning as
|
|
37
54
|
// agentsRootDir above, but system-skills/ is a same-level sibling of
|
|
38
55
|
// run-agent.ts itself (both live directly under core/ — see package.json's
|
|
@@ -157,7 +174,7 @@ async function loadToolsFromDir(toolsDir) {
|
|
|
157
174
|
return [];
|
|
158
175
|
}
|
|
159
176
|
export async function loadDefaultTools(config) {
|
|
160
|
-
return loadToolsFromDir(
|
|
177
|
+
return loadToolsFromDir(resolveAgentCodeDir(config.name, 'tools', true));
|
|
161
178
|
}
|
|
162
179
|
/** Every other folder-form default in this file (loadDefaultTools,
|
|
163
180
|
* loadRules, the skillsDirs default below) resolves against
|
|
@@ -229,7 +246,7 @@ async function resolveSubagentConfig(config, dir) {
|
|
|
229
246
|
* this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
|
|
230
247
|
* elsewhere could. */
|
|
231
248
|
export async function loadSubagentAsTools(config) {
|
|
232
|
-
const subagentsDir =
|
|
249
|
+
const subagentsDir = resolveAgentCodeDir(config.name, 'subagents', false);
|
|
233
250
|
if (!existsSync(subagentsDir))
|
|
234
251
|
return [];
|
|
235
252
|
const tools = [];
|
|
@@ -280,7 +297,7 @@ async function buildTurnContext(config, modelCall, options) {
|
|
|
280
297
|
askUserTool,
|
|
281
298
|
...(config.tools ?? (await loadDefaultTools(config))),
|
|
282
299
|
...(await loadSubagentAsTools(config)),
|
|
283
|
-
...(await loadGatewayToolsFromDir(join(
|
|
300
|
+
...(await loadGatewayToolsFromDir(join(process.cwd(), 'agents', config.name))),
|
|
284
301
|
]);
|
|
285
302
|
// Omitted entirely (undefined): default to this agent's own
|
|
286
303
|
// agents/<name>/skills — the folder-form convention every agent in
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,29 @@ export { createOpenAIModelCall } from './core/model-calls/openai-model-call.js';
|
|
|
14
14
|
export type { OpenAIModelCallOptions } from './core/model-calls/openai-model-call.js';
|
|
15
15
|
export { createDeepSeekModelCall } from './core/model-calls/deepseek-model-call.js';
|
|
16
16
|
export type { DeepSeekModelCallOptions } from './core/model-calls/deepseek-model-call.js';
|
|
17
|
-
export { discoverAgents, loadAgentModule } from './core/discover-agents.js';
|
|
17
|
+
export { discoverAgents, loadAgentModule, synthesizeCreateModelCall } from './core/discover-agents.js';
|
|
18
18
|
export type { AgentModule } from './core/discover-agents.js';
|
|
19
19
|
export { agentAsTool } from './core/agent-as-tool.js';
|
|
20
|
+
export { resumeAgent, loadRules, loadDefaultTools, loadSubagentAsTools, systemTools, systemSkillsDir } from './core/run-agent.js';
|
|
21
|
+
export { createCheckpointStore } from './core/durable-approvals.js';
|
|
22
|
+
export type { TurnCheckpoint, CheckpointStore, OutstandingItem } from './core/durable-approvals.js';
|
|
23
|
+
export { addGatewayTool, agentDir, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
|
|
24
|
+
export type { GatewayToolEntry, GatewayToolDecision } from './core/gateway-tools.js';
|
|
25
|
+
export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
|
|
26
|
+
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
|
|
27
|
+
export type { PendingQuestion } from './core/system-tools/index.js';
|
|
28
|
+
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
29
|
+
export type { AgentEditableFields, AgentEditResult } from './web/agent-file-admin.js';
|
|
30
|
+
export { agentsConfigPageHtml } from './web/agents-config-page.js';
|
|
31
|
+
export { agentsListPageHtml } from './web/agents-list-page.js';
|
|
32
|
+
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
33
|
+
export { playgroundHtml } from './web/playground.js';
|
|
34
|
+
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
35
|
+
export type { SkillContent } from './web/skills-admin.js';
|
|
36
|
+
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
37
|
+
export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
|
|
38
|
+
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
39
|
+
export type { ModelProviderInfo, AgentModelUsage, ModelsView, GatewayProviderInfo, GatewaysView } from './web/global-config.js';
|
|
40
|
+
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
41
|
+
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
42
|
+
export type { AgentTemplateOptions } from './bin/cli.js';
|
package/dist/index.js
CHANGED
|
@@ -16,5 +16,30 @@ export { VectorIndex, embed, cosineSimilarity } from './core/vector-index.js';
|
|
|
16
16
|
export { createAnthropicModelCall } from './core/model-calls/anthropic-model-call.js';
|
|
17
17
|
export { createOpenAIModelCall } from './core/model-calls/openai-model-call.js';
|
|
18
18
|
export { createDeepSeekModelCall } from './core/model-calls/deepseek-model-call.js';
|
|
19
|
-
export { discoverAgents, loadAgentModule } from './core/discover-agents.js';
|
|
19
|
+
export { discoverAgents, loadAgentModule, synthesizeCreateModelCall } from './core/discover-agents.js';
|
|
20
20
|
export { agentAsTool } from './core/agent-as-tool.js';
|
|
21
|
+
// Everything below backs the Admin UI (agents-config/list/global-config
|
|
22
|
+
// pages, playground) that this repo's own adapters/http.ts wires up —
|
|
23
|
+
// re-exported here, same "one entry point, no deep subpath imports"
|
|
24
|
+
// convention as everything above, so a project scaffolded by
|
|
25
|
+
// create-loopengine can build the same admin surface on top of its own
|
|
26
|
+
// copy of adapters/http.ts (which, like agent-registry.ts, is meant to be
|
|
27
|
+
// owned/edited, not imported — see this file's own header comment).
|
|
28
|
+
export { resumeAgent, loadRules, loadDefaultTools, loadSubagentAsTools, systemTools, systemSkillsDir } from './core/run-agent.js';
|
|
29
|
+
export { createCheckpointStore } from './core/durable-approvals.js';
|
|
30
|
+
export { addGatewayTool, agentDir, disconnectComposioAccount, describeGatewayTools, listComposioConnections, listComposioTools, loadGatewayToolsFromDir, removeGatewayTool, removeGatewayToolSlug, GatewayToolExistsError, GatewayToolNotFoundError, } from './core/gateway-tools.js';
|
|
31
|
+
export { WebhookNotifier } from './core/http-notify-triggers/webhook.js';
|
|
32
|
+
// answerQuestion above (core/client.ts) is the HTTP client-side helper —
|
|
33
|
+
// this is the server-side function that actually resolves a pending
|
|
34
|
+
// question in-process, aliased to avoid colliding with that name.
|
|
35
|
+
export { listQuestions, answerQuestion as resolvePendingQuestion, findQuestion, createAskUserTool } from './core/system-tools/index.js';
|
|
36
|
+
export { editAgentFile, AgentEditNotSupportedError, AgentFileNotFoundError } from './web/agent-file-admin.js';
|
|
37
|
+
export { agentsConfigPageHtml } from './web/agents-config-page.js';
|
|
38
|
+
export { agentsListPageHtml } from './web/agents-list-page.js';
|
|
39
|
+
export { globalConfigPageHtml } from './web/global-config-page.js';
|
|
40
|
+
export { playgroundHtml } from './web/playground.js';
|
|
41
|
+
export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
|
|
42
|
+
export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
|
|
43
|
+
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
44
|
+
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.js';
|
|
45
|
+
export { scaffoldAgent, scaffoldSubagent, AgentNameError, AgentExistsError, AgentNotFoundError, AgentModelError } from './bin/cli.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Decision } from 'actauth';
|
|
2
|
+
export declare class ActauthRuleExistsError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare class ActauthRuleNotFoundError extends Error {
|
|
5
|
+
}
|
|
6
|
+
export interface ActauthRuleInput {
|
|
7
|
+
name: string;
|
|
8
|
+
scope: string;
|
|
9
|
+
tool: string;
|
|
10
|
+
decision: Decision;
|
|
11
|
+
}
|
|
12
|
+
export interface ActauthConfigView {
|
|
13
|
+
defaultDecision: Decision;
|
|
14
|
+
rules: ActauthRuleInput[];
|
|
15
|
+
}
|
|
16
|
+
/** The admin page's read model for the Actauth tab — every rule as plain
|
|
17
|
+
* data, in file order. Unlike run-agent.ts's loadRules, this reads the
|
|
18
|
+
* raw per-agent scope (e.g. "default/production", a wildcard pair)
|
|
19
|
+
* rather than the 3-segment form actauth.RuleSet works with internally —
|
|
20
|
+
* the admin UI edits the same file an operator would hand-edit, so it
|
|
21
|
+
* should show exactly what's on disk, not a resolved/expanded form. */
|
|
22
|
+
export declare function readActauthConfig(agentName: string): ActauthConfigView;
|
|
23
|
+
/** Appends a new rule. `rule.name` must be unique among this agent's
|
|
24
|
+
* existing rules — it's the only handle update/removeActauthRule below
|
|
25
|
+
* have to find it again, so a duplicate would make those ambiguous. */
|
|
26
|
+
export declare function addActauthRule(agentName: string, rule: ActauthRuleInput): void;
|
|
27
|
+
/** Mutates an existing rule's scope/tool/decision in place (via the
|
|
28
|
+
* node's own `.set()`, not remove-then-re-add), so any comment attached
|
|
29
|
+
* to that specific rule in the source YAML survives the edit. */
|
|
30
|
+
export declare function updateActauthRule(agentName: string, ruleName: string, updates: {
|
|
31
|
+
scope: string;
|
|
32
|
+
tool: string;
|
|
33
|
+
decision: Decision;
|
|
34
|
+
}): void;
|
|
35
|
+
export declare function removeActauthRule(agentName: string, ruleName: string): void;
|
|
36
|
+
export declare function setDefaultDecision(agentName: string, decision: Decision): void;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Lets an operator add, edit, or remove an actauth.yml rule (and set
|
|
2
|
+
// default_decision) from the Actauth tab in /agents/config, instead of
|
|
3
|
+
// hand-editing agents/<name>/actauth.yml directly. Same yaml Document
|
|
4
|
+
// API discipline gateway-tools.ts's appendActauthRules/
|
|
5
|
+
// removeAutoAddedActauthRules already established — parse → mutate the
|
|
6
|
+
// CST → toString, never parse-into-plain-object → re-stringify, since
|
|
7
|
+
// the latter silently drops every comment in a file real agents (see
|
|
8
|
+
// agents/customer-service/actauth.yml) use to document individual
|
|
9
|
+
// rules' reasoning.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately does not let a rule be renamed — `name` is this admin
|
|
12
|
+
// UI's identifier for a rule (the :ruleName URL segment), so an "update"
|
|
13
|
+
// only ever touches scope/tool/decision/when; renaming is a delete +
|
|
14
|
+
// re-add, same as any other keyed-by-name resource here.
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { parseDocument } from 'yaml';
|
|
18
|
+
import { agentDir } from '../core/gateway-tools.js';
|
|
19
|
+
export class ActauthRuleExistsError extends Error {
|
|
20
|
+
}
|
|
21
|
+
export class ActauthRuleNotFoundError extends Error {
|
|
22
|
+
}
|
|
23
|
+
function actauthPath(dir) {
|
|
24
|
+
return join(dir, 'actauth.yml');
|
|
25
|
+
}
|
|
26
|
+
function loadDoc(dir) {
|
|
27
|
+
const path = actauthPath(dir);
|
|
28
|
+
const doc = existsSync(path) ? parseDocument(readFileSync(path, 'utf8')) : parseDocument('default_decision: deny\nrules: []\n');
|
|
29
|
+
if (doc.get('rules') == null)
|
|
30
|
+
doc.set('rules', []);
|
|
31
|
+
return { path, doc };
|
|
32
|
+
}
|
|
33
|
+
function saveDoc(dir, path, doc) {
|
|
34
|
+
mkdirSync(dir, { recursive: true });
|
|
35
|
+
writeFileSync(path, doc.toString());
|
|
36
|
+
}
|
|
37
|
+
/** The admin page's read model for the Actauth tab — every rule as plain
|
|
38
|
+
* data, in file order. Unlike run-agent.ts's loadRules, this reads the
|
|
39
|
+
* raw per-agent scope (e.g. "default/production", a wildcard pair)
|
|
40
|
+
* rather than the 3-segment form actauth.RuleSet works with internally —
|
|
41
|
+
* the admin UI edits the same file an operator would hand-edit, so it
|
|
42
|
+
* should show exactly what's on disk, not a resolved/expanded form. */
|
|
43
|
+
export function readActauthConfig(agentName) {
|
|
44
|
+
const dir = agentDir(agentName);
|
|
45
|
+
const path = actauthPath(dir);
|
|
46
|
+
if (!existsSync(path))
|
|
47
|
+
return { defaultDecision: 'deny', rules: [] };
|
|
48
|
+
const doc = parseDocument(readFileSync(path, 'utf8'));
|
|
49
|
+
const defaultDecision = doc.get('default_decision') ?? 'ask';
|
|
50
|
+
const rules = doc.get('rules');
|
|
51
|
+
const rulesList = (rules?.items ?? []).map((rule) => ({
|
|
52
|
+
name: String(rule.get('name') ?? ''),
|
|
53
|
+
scope: String(rule.get('scope') ?? ''),
|
|
54
|
+
tool: String(rule.get('tool') ?? ''),
|
|
55
|
+
decision: rule.get('decision'),
|
|
56
|
+
}));
|
|
57
|
+
return { defaultDecision, rules: rulesList };
|
|
58
|
+
}
|
|
59
|
+
function findRuleIndex(rules, ruleName) {
|
|
60
|
+
return rules.items.findIndex((rule) => rule.get('name') === ruleName);
|
|
61
|
+
}
|
|
62
|
+
/** Appends a new rule. `rule.name` must be unique among this agent's
|
|
63
|
+
* existing rules — it's the only handle update/removeActauthRule below
|
|
64
|
+
* have to find it again, so a duplicate would make those ambiguous. */
|
|
65
|
+
export function addActauthRule(agentName, rule) {
|
|
66
|
+
const dir = agentDir(agentName);
|
|
67
|
+
const { path, doc } = loadDoc(dir);
|
|
68
|
+
const rules = doc.get('rules');
|
|
69
|
+
if (findRuleIndex(rules, rule.name) !== -1) {
|
|
70
|
+
throw new ActauthRuleExistsError(`A rule named '${rule.name}' already exists for '${agentName}' — pick a different name or remove it first.`);
|
|
71
|
+
}
|
|
72
|
+
rules.add(doc.createNode({ name: rule.name, scope: rule.scope, tool: rule.tool, decision: rule.decision }));
|
|
73
|
+
saveDoc(dir, path, doc);
|
|
74
|
+
}
|
|
75
|
+
/** Mutates an existing rule's scope/tool/decision in place (via the
|
|
76
|
+
* node's own `.set()`, not remove-then-re-add), so any comment attached
|
|
77
|
+
* to that specific rule in the source YAML survives the edit. */
|
|
78
|
+
export function updateActauthRule(agentName, ruleName, updates) {
|
|
79
|
+
const dir = agentDir(agentName);
|
|
80
|
+
const { path, doc } = loadDoc(dir);
|
|
81
|
+
const rules = doc.get('rules');
|
|
82
|
+
const index = findRuleIndex(rules, ruleName);
|
|
83
|
+
if (index === -1) {
|
|
84
|
+
throw new ActauthRuleNotFoundError(`No rule named '${ruleName}' for '${agentName}'.`);
|
|
85
|
+
}
|
|
86
|
+
const rule = rules.items[index];
|
|
87
|
+
rule.set('scope', updates.scope);
|
|
88
|
+
rule.set('tool', updates.tool);
|
|
89
|
+
rule.set('decision', updates.decision);
|
|
90
|
+
saveDoc(dir, path, doc);
|
|
91
|
+
}
|
|
92
|
+
export function removeActauthRule(agentName, ruleName) {
|
|
93
|
+
const dir = agentDir(agentName);
|
|
94
|
+
const { path, doc } = loadDoc(dir);
|
|
95
|
+
const rules = doc.get('rules');
|
|
96
|
+
const index = findRuleIndex(rules, ruleName);
|
|
97
|
+
if (index === -1) {
|
|
98
|
+
throw new ActauthRuleNotFoundError(`No rule named '${ruleName}' for '${agentName}'.`);
|
|
99
|
+
}
|
|
100
|
+
rules.items = rules.items.filter((_, i) => i !== index);
|
|
101
|
+
saveDoc(dir, path, doc);
|
|
102
|
+
}
|
|
103
|
+
export function setDefaultDecision(agentName, decision) {
|
|
104
|
+
const dir = agentDir(agentName);
|
|
105
|
+
const { path, doc } = loadDoc(dir);
|
|
106
|
+
doc.set('default_decision', decision);
|
|
107
|
+
saveDoc(dir, path, doc);
|
|
108
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare class AgentEditNotSupportedError extends Error {
|
|
2
|
+
}
|
|
3
|
+
export declare class AgentFileNotFoundError extends Error {
|
|
4
|
+
}
|
|
5
|
+
type Provider = 'anthropic' | 'openai' | 'deepseek';
|
|
6
|
+
export interface AgentEditableFields {
|
|
7
|
+
systemPrompt?: string;
|
|
8
|
+
model?: {
|
|
9
|
+
provider: Provider;
|
|
10
|
+
model?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface AgentEditResult {
|
|
14
|
+
systemPrompt?: string;
|
|
15
|
+
model?: {
|
|
16
|
+
provider: Provider;
|
|
17
|
+
model: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Surgically rewrites just the fields given in `fields` — see this
|
|
21
|
+
* file's own header comment for why, and for the "refuse rather than
|
|
22
|
+
* guess" rule this follows for anything that isn't a plain, recognizable
|
|
23
|
+
* shape. Returns the values actually written (model's own default
|
|
24
|
+
* resolution included), so the caller (adapters/http.ts's
|
|
25
|
+
* handleEditAgent) can apply the exact same values to the *live*
|
|
26
|
+
* registry entry — see core/agent-registry.ts's own updateAgent — without
|
|
27
|
+
* needing to re-derive them. A field omitted from `fields` entirely is
|
|
28
|
+
* left completely untouched, both on disk and in the return value. */
|
|
29
|
+
export declare function editAgentFile(agentName: string, fields: AgentEditableFields): AgentEditResult;
|
|
30
|
+
export {};
|