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.
@@ -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`), resolved next to
299
- * `run-agent.ts` itself (not `process.cwd()`, unlike `skillsDirs`/
300
- * `rules` — that file is real compiled code, not data, so it has to be
301
- * found next to whichever build of `run-agent.ts` is actually running).
302
- * A missing file there is just `[]`, not an error — same
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 { dirname, join } from 'node:path';
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
- // Same "one level up from this file, not process.cwd()" reasoning
19
- // run-agent.ts's own agentsRootDir uses (this file lives in core/
20
- // alongside it, with agents/ one level up at repo root either way) — real
21
- // code (an actauth.yml write, a gateway-tools.yml write) needs to land in
22
- // the same place a built dist/ would look for it, not wherever the
23
- // process happened to be launched from.
24
- const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
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
  }
@@ -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(join(agentsRootDir, config.name, 'tools'));
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 = join(agentsRootDir, config.name, 'subagents');
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(agentsRootDir, config.name))),
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 {};