loopengine 0.0.14 → 0.0.17

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.
@@ -0,0 +1,7 @@
1
+ import type { AgentConfig, ToolDefinition } from '#agent-config.js';
2
+ import { type ModelCall } from '#run-agent.js';
3
+ /** `createModelCall` is a factory, not a shared instance — the same
4
+ * `AgentModule.createModelCall` contract discover-agents.ts already
5
+ * uses, so each call to the returned tool gets its own ModelCall rather
6
+ * than sharing one across concurrent invocations. */
7
+ export declare function agentAsTool(config: AgentConfig, createModelCall: () => ModelCall): ToolDefinition;
@@ -0,0 +1,32 @@
1
+ import { runAgent } from '#run-agent.js';
2
+ /** `createModelCall` is a factory, not a shared instance — the same
3
+ * `AgentModule.createModelCall` contract discover-agents.ts already
4
+ * uses, so each call to the returned tool gets its own ModelCall rather
5
+ * than sharing one across concurrent invocations. */
6
+ export function agentAsTool(config, createModelCall) {
7
+ if (!config.toolDescription) {
8
+ throw new Error(`agentAsTool('${config.name}') requires AgentConfig.toolDescription — see its own doc comment. ` +
9
+ `systemPrompt is instructions for '${config.name}' itself, not a description a caller can use to decide when to invoke it.`);
10
+ }
11
+ return {
12
+ name: config.name,
13
+ description: config.toolDescription,
14
+ input_schema: {
15
+ type: 'object',
16
+ properties: {
17
+ request: { type: 'string', description: 'What to ask this agent to do.' },
18
+ },
19
+ required: ['request'],
20
+ },
21
+ // Deliberately no `safe: true` here: this agent may itself call
22
+ // unsafe tools, and agentAsTool has no way to know that from the
23
+ // outside — declaring it safe would be a claim about side effects
24
+ // this function isn't in a position to make. Callers that know their
25
+ // specific subagent is read-only can still set `safe: true` on the
26
+ // returned object themselves.
27
+ execute: async (input) => {
28
+ const result = await runAgent(config, createModelCall(), String(input.request));
29
+ return result.text;
30
+ },
31
+ };
32
+ }
@@ -52,6 +52,15 @@ export interface AgentConfig {
52
52
  /** Also doubles as the ActAuth scope.agent segment. */
53
53
  name: string;
54
54
  systemPrompt: string;
55
+ /** Required only when this agent is wrapped with `agentAsTool` (see
56
+ * agent-as-tool.ts) — the ToolDefinition.description shown to a
57
+ * *parent* agent's model, so it knows when to delegate here.
58
+ * `systemPrompt` isn't a substitute: it's instructions for this agent
59
+ * itself, not a pitch to a caller deciding whether to invoke it.
60
+ * `agentAsTool` throws if this is missing. Irrelevant for an agent
61
+ * that's only ever run directly (via runAgent/CLI/HTTP), so it's
62
+ * optional here rather than required on every AgentConfig. */
63
+ toolDescription?: string;
55
64
  /** See AgentModelConfig's own doc comment. Omit entirely and the module
56
65
  * must export its own `createModelCall` instead — `discoverAgents`
57
66
  * throws at startup on a module with neither. */
@@ -68,7 +77,14 @@ export interface AgentConfig {
68
77
  * missing-is-fine treatment `skillsDirs` gets. An agent that needs to
69
78
  * merge in tools from somewhere else too (e.g.
70
79
  * `agents/file-agent/index.ts`'s Composio-sourced ones) can't rely on
71
- * this default and still needs to set `tools` explicitly. */
80
+ * this default and still needs to set `tools` explicitly.
81
+ *
82
+ * Unlike this field, `agents/<name>/subagents/*` (see
83
+ * agent-as-tool.ts) is *always* merged in on top of whatever `tools`
84
+ * resolves to, explicit array or default alike — subagents are a
85
+ * distinct concern (delegation) from hand-written tools (integration
86
+ * code), so setting `tools` explicitly doesn't opt an agent out of its
87
+ * own subagents/ folder the way it opts out of the tools/ default. */
72
88
  tools?: ToolDefinition[];
73
89
  /** ActAuth rules — either inline (handy for tests and small/synthetic
74
90
  * configs) or a path to an `actauth.yml` file (same shape as
package/dist/cli.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ export declare class AgentNameError extends Error {
3
+ }
4
+ export declare class AgentExistsError extends Error {
5
+ }
6
+ export declare class AgentNotFoundError extends Error {
7
+ }
8
+ export declare function agentIndexTemplate(name: string): string;
9
+ /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
10
+ * field a subagent needs that a top-level agent doesn't. run-agent.ts's
11
+ * loadSubagentTools wraps this config with agentAsTool, which throws
12
+ * without a real toolDescription: it's what the parent agent's model
13
+ * reads to decide when to delegate here, and systemPrompt (instructions
14
+ * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
15
+ export declare function subagentIndexTemplate(name: string): string;
16
+ /** Writes agents/<name>/index.ts under baseDir and returns its path.
17
+ * Only that one file — tools, rules, and skillsDirs are left to their
18
+ * smart defaults (see AgentConfig's own doc comments), the same as the
19
+ * README's own "simplest possible agent" example. */
20
+ export declare function scaffoldAgent(baseDir: string, name: string): Promise<string>;
21
+ /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
22
+ * returns its path, where <parentDir> is `parent` resolved by
23
+ * resolveParentDir — the folder convention run-agent.ts's
24
+ * loadSubagentTools auto-discovers, so this subagent becomes one of
25
+ * `parent`'s tools with no further edits: no import, no
26
+ * AgentConfig.tools entry, nothing to register by hand. Requires
27
+ * `parent` to already exist (its own index.ts) — a subagent without a
28
+ * parent to nest under is just a regular top-level agent, which
29
+ * scaffoldAgent already covers. Nesting more than one level is just
30
+ * `parent` itself being a path — see resolveParentDir. */
31
+ export declare function scaffoldSubagent(baseDir: string, parent: string, name: string): Promise<string>;
package/dist/cli.js ADDED
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ // The `loopengine` bin — distinct from adapters/cli.ts, which runs an
3
+ // agent, not scaffolds one. Generates the agents/<name>/index.ts
4
+ // boilerplate the folder convention expects (see agent-config.ts's own
5
+ // doc comments, or the README's "Define your first agent"), so using
6
+ // that convention doesn't mean memorizing its shape and hand-writing it
7
+ // every time.
8
+ import { realpathSync } from 'node:fs';
9
+ import { access, mkdir, writeFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
13
+ export class AgentNameError extends Error {
14
+ }
15
+ export class AgentExistsError extends Error {
16
+ }
17
+ export class AgentNotFoundError extends Error {
18
+ }
19
+ export function agentIndexTemplate(name) {
20
+ return `import type { AgentConfig } from 'loopengine'
21
+
22
+ export const config: AgentConfig = {
23
+ name: '${name}',
24
+ systemPrompt: 'You are ...',
25
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
26
+ }
27
+ `;
28
+ }
29
+ /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
30
+ * field a subagent needs that a top-level agent doesn't. run-agent.ts's
31
+ * loadSubagentTools wraps this config with agentAsTool, which throws
32
+ * without a real toolDescription: it's what the parent agent's model
33
+ * reads to decide when to delegate here, and systemPrompt (instructions
34
+ * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
35
+ export function subagentIndexTemplate(name) {
36
+ return `import type { AgentConfig } from 'loopengine'
37
+
38
+ export const config: AgentConfig = {
39
+ name: '${name}',
40
+ systemPrompt: 'You are ...',
41
+ toolDescription: 'Call this when ...',
42
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
43
+ }
44
+ `;
45
+ }
46
+ /** Writes agents/<name>/index.ts under baseDir and returns its path.
47
+ * Only that one file — tools, rules, and skillsDirs are left to their
48
+ * smart defaults (see AgentConfig's own doc comments), the same as the
49
+ * README's own "simplest possible agent" example. */
50
+ export async function scaffoldAgent(baseDir, name) {
51
+ if (!NAME_PATTERN.test(name)) {
52
+ throw new AgentNameError(`Agent name must be lowercase, alphanumeric, hyphen-separated (e.g. "weather-agent") — got "${name}"`);
53
+ }
54
+ const dir = path.join(baseDir, 'agents', name);
55
+ const indexPath = path.join(dir, 'index.ts');
56
+ if (await pathExists(indexPath)) {
57
+ throw new AgentExistsError(`agents/${name}/index.ts already exists — pick a different name or edit it directly.`);
58
+ }
59
+ await mkdir(dir, { recursive: true });
60
+ await writeFile(indexPath, agentIndexTemplate(name));
61
+ return indexPath;
62
+ }
63
+ /** Resolves `parent` — a single agent name, or a `/`-joined path of
64
+ * names for nesting more than one level deep (e.g.
65
+ * "support-orchestrator/billing-agent" to scaffold a subagent under an
66
+ * *existing subagent*) — to the real directory that agent's index.ts
67
+ * lives in: agents/<seg0>/subagents/<seg1>/subagents/<seg2>/... Every
68
+ * segment gets the same lowercase-hyphen validation a plain agent name
69
+ * does; there's no separate "path" naming rule. */
70
+ function resolveParentDir(baseDir, parent) {
71
+ const segments = parent.split('/');
72
+ for (const segment of segments) {
73
+ if (!NAME_PATTERN.test(segment)) {
74
+ throw new AgentNameError(`Parent must be a lowercase, alphanumeric, hyphen-separated agent name, or several joined by '/' for nesting (e.g. "support-orchestrator/billing-agent") — got "${parent}"`);
75
+ }
76
+ }
77
+ return segments.slice(1).reduce((dir, segment) => path.join(dir, 'subagents', segment), path.join(baseDir, 'agents', segments[0]));
78
+ }
79
+ /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
80
+ * returns its path, where <parentDir> is `parent` resolved by
81
+ * resolveParentDir — the folder convention run-agent.ts's
82
+ * loadSubagentTools auto-discovers, so this subagent becomes one of
83
+ * `parent`'s tools with no further edits: no import, no
84
+ * AgentConfig.tools entry, nothing to register by hand. Requires
85
+ * `parent` to already exist (its own index.ts) — a subagent without a
86
+ * parent to nest under is just a regular top-level agent, which
87
+ * scaffoldAgent already covers. Nesting more than one level is just
88
+ * `parent` itself being a path — see resolveParentDir. */
89
+ export async function scaffoldSubagent(baseDir, parent, name) {
90
+ const parentDir = resolveParentDir(baseDir, parent);
91
+ if (!NAME_PATTERN.test(name)) {
92
+ throw new AgentNameError(`Subagent name must be lowercase, alphanumeric, hyphen-separated (e.g. "billing-agent") — got "${name}"`);
93
+ }
94
+ const parentIndexPath = path.join(parentDir, 'index.ts');
95
+ if (!(await pathExists(parentIndexPath))) {
96
+ throw new AgentNotFoundError(`'${parent}' doesn't exist yet (expected ${parentIndexPath}) — create it first.`);
97
+ }
98
+ const dir = path.join(parentDir, 'subagents', name);
99
+ const indexPath = path.join(dir, 'index.ts');
100
+ if (await pathExists(indexPath)) {
101
+ throw new AgentExistsError(`A subagent named '${name}' already exists under '${parent}' (${indexPath}) — pick a different name or edit it directly.`);
102
+ }
103
+ await mkdir(dir, { recursive: true });
104
+ await writeFile(indexPath, subagentIndexTemplate(name));
105
+ return indexPath;
106
+ }
107
+ async function pathExists(p) {
108
+ try {
109
+ await access(p);
110
+ return true;
111
+ }
112
+ catch {
113
+ return false;
114
+ }
115
+ }
116
+ async function main() {
117
+ const [, , command, ...rest] = process.argv;
118
+ if (command === 'add-agent') {
119
+ const [name] = rest;
120
+ if (!name) {
121
+ console.error('Usage: loopengine add-agent <name>');
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ try {
126
+ await scaffoldAgent(process.cwd(), name);
127
+ }
128
+ catch (err) {
129
+ console.error(err instanceof Error ? err.message : String(err));
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ console.log(`Created agents/${name}/index.ts`);
134
+ console.log();
135
+ console.log('tools, rules, and skillsDirs are all omitted — they default to:');
136
+ console.log(` agents/${name}/tools/index.ts (no tools until you add one)`);
137
+ console.log(` agents/${name}/actauth.yml (denies every tool until you add one)`);
138
+ console.log(` agents/${name}/skills/ (no skills until you add one)`);
139
+ console.log();
140
+ console.log(`Run it: npx tsx adapters/cli.ts --agent ${name} "hello"`);
141
+ return;
142
+ }
143
+ if (command === 'add-subagent') {
144
+ const [parent, name] = rest;
145
+ if (!parent || !name) {
146
+ console.error('Usage: loopengine add-subagent <parent> <name>');
147
+ console.error(' <parent> can be a single agent name, or several joined by \'/\' to nest deeper (e.g. "support-orchestrator/billing-agent")');
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ let indexPath;
152
+ try {
153
+ indexPath = await scaffoldSubagent(process.cwd(), parent, name);
154
+ }
155
+ catch (err) {
156
+ console.error(err instanceof Error ? err.message : String(err));
157
+ process.exitCode = 1;
158
+ return;
159
+ }
160
+ console.log(`Created ${path.relative(process.cwd(), indexPath)}`);
161
+ console.log();
162
+ console.log(`Auto-registered as a tool on '${parent}' — no import, no tools array edit needed.`);
163
+ console.log(`Fill in toolDescription: it's what '${parent}'s model reads to decide when to call '${name}'.`);
164
+ console.log();
165
+ console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
166
+ return;
167
+ }
168
+ console.error('Usage: loopengine add-agent <name>');
169
+ console.error(' loopengine add-subagent <parent> <name>');
170
+ process.exitCode = 1;
171
+ }
172
+ // A package-manager shim (npx, node_modules/.bin) invokes this file
173
+ // through a symlink, not its real path — process.argv[1] is the
174
+ // symlink's path, while Node resolves import.meta.url to the real path
175
+ // when loading an ES module. Comparing the raw argv[1] against the
176
+ // resolved module URL fails in exactly that case and main() silently
177
+ // never runs — realpath argv[1] first so both sides are resolved. (Same
178
+ // bug, same fix, as create-loopengine's own cli.ts — see its git history
179
+ // for the live symlink repro, and skillgarden's CLI before that.)
180
+ function isMain() {
181
+ if (!process.argv[1])
182
+ return false;
183
+ try {
184
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ if (isMain())
191
+ main();
@@ -1,10 +1,19 @@
1
- import type { AgentConfig } from './agent-config.js';
2
- import type { ModelCall } from './run-agent.js';
1
+ import type { AgentConfig } from '#agent-config.js';
2
+ import type { ModelCall } from '#run-agent.js';
3
3
  export interface AgentModule {
4
4
  config: AgentConfig;
5
5
  /** Factory, not a shared instance — a fresh ModelCall per request/session. */
6
6
  createModelCall: () => ModelCall;
7
7
  }
8
+ /** Imports one agent module at `path` and resolves it to an AgentModule —
9
+ * the single-file version of what discoverAgents does over a whole
10
+ * directory, factored out so run-agent.ts's subagent loader (see
11
+ * agent-as-tool.ts / loadSubagentTools) can load one `subagents/<name>/
12
+ * index.ts` at a time without duplicating this resolution logic.
13
+ * `label` is only used in error messages — discoverAgents passes
14
+ * `'<file> in <dir>'`, callers loading a single known path can just pass
15
+ * that path. */
16
+ export declare function loadAgentModule(path: string, label: string): Promise<AgentModule>;
8
17
  /** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
9
18
  * subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
10
19
  * each expected to export `config`, and either its own `createModelCall`
@@ -63,6 +63,28 @@ function resolveModulePath(dir, entry) {
63
63
  }
64
64
  return undefined;
65
65
  }
66
+ /** Imports one agent module at `path` and resolves it to an AgentModule —
67
+ * the single-file version of what discoverAgents does over a whole
68
+ * directory, factored out so run-agent.ts's subagent loader (see
69
+ * agent-as-tool.ts / loadSubagentTools) can load one `subagents/<name>/
70
+ * index.ts` at a time without duplicating this resolution logic.
71
+ * `label` is only used in error messages — discoverAgents passes
72
+ * `'<file> in <dir>'`, callers loading a single known path can just pass
73
+ * that path. */
74
+ export async function loadAgentModule(path, label) {
75
+ const mod = await import(pathToFileURL(path).href);
76
+ if (!isRawAgentModule(mod) || !mod.config) {
77
+ throw new Error(`${label} does not export 'config' — every agent module must.`);
78
+ }
79
+ let createModelCall = mod.createModelCall;
80
+ if (typeof createModelCall !== 'function') {
81
+ if (!mod.config.model) {
82
+ throw new Error(`${label} exports 'config' but neither 'createModelCall' nor 'config.model' — every agent module must have one or the other.`);
83
+ }
84
+ createModelCall = await synthesizeCreateModelCall(mod.config.model);
85
+ }
86
+ return { config: mod.config, createModelCall };
87
+ }
66
88
  /** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
67
89
  * subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
68
90
  * each expected to export `config`, and either its own `createModelCall`
@@ -76,22 +98,12 @@ export async function discoverAgents(dir) {
76
98
  const resolved = resolveModulePath(dir, dirent);
77
99
  if (!resolved)
78
100
  continue;
79
- const mod = await import(pathToFileURL(resolved.path).href);
80
- if (!isRawAgentModule(mod) || !mod.config) {
81
- throw new Error(`${resolved.label} in ${dir} does not export 'config' — every agent module must.`);
82
- }
83
- let createModelCall = mod.createModelCall;
84
- if (typeof createModelCall !== 'function') {
85
- if (!mod.config.model) {
86
- throw new Error(`${resolved.label} in ${dir} exports 'config' but neither 'createModelCall' nor 'config.model' — every agent module must have one or the other.`);
87
- }
88
- createModelCall = await synthesizeCreateModelCall(mod.config.model);
89
- }
90
- const name = mod.config.name;
101
+ const agentModule = await loadAgentModule(resolved.path, `${resolved.label} in ${dir}`);
102
+ const name = agentModule.config.name;
91
103
  if (entries.has(name)) {
92
104
  throw new Error(`Duplicate agent name '${name}' — ${resolved.label} in ${dir} isn't the first module to declare AgentConfig.name '${name}'.`);
93
105
  }
94
- entries.set(name, { config: mod.config, createModelCall });
106
+ entries.set(name, agentModule);
95
107
  }
96
108
  return entries;
97
109
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { runAgent } from './run-agent.js';
2
- export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
3
- export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition } from './agent-config.js';
1
+ export { runAgent } from '#run-agent.js';
2
+ export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from '#run-agent.js';
3
+ export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition } from '#agent-config.js';
4
4
  export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
5
5
  export type { SessionStore, SessionResult, RedisSessionStoreOptions } from './session-store.js';
6
6
  export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
@@ -11,5 +11,6 @@ export { createOpenAIModelCall } from './model-calls/openai-model-call.js';
11
11
  export type { OpenAIModelCallOptions } from './model-calls/openai-model-call.js';
12
12
  export { createDeepSeekModelCall } from './model-calls/deepseek-model-call.js';
13
13
  export type { DeepSeekModelCallOptions } from './model-calls/deepseek-model-call.js';
14
- export { discoverAgents } from './discover-agents.js';
14
+ export { discoverAgents, loadAgentModule } from './discover-agents.js';
15
15
  export type { AgentModule } from './discover-agents.js';
16
+ export { agentAsTool } from './agent-as-tool.js';
package/dist/index.js CHANGED
@@ -3,10 +3,11 @@
3
3
  // `adapters/*.ts`, and the Dockerfile are this repo's own reference app
4
4
  // built on top of this surface, not part of it — see the README section
5
5
  // "Using loopengine as a library" for the boundary and why.
6
- export { runAgent } from './run-agent.js';
6
+ export { runAgent } from '#run-agent.js';
7
7
  export { FileSessionStore, RedisSessionStore, createSessionStore } from './session-store.js';
8
8
  export { VectorIndex, embed, cosineSimilarity } from './vector-index.js';
9
9
  export { createAnthropicModelCall } from './model-calls/anthropic-model-call.js';
10
10
  export { createOpenAIModelCall } from './model-calls/openai-model-call.js';
11
11
  export { createDeepSeekModelCall } from './model-calls/deepseek-model-call.js';
12
- export { discoverAgents } from './discover-agents.js';
12
+ export { discoverAgents, loadAgentModule } from './discover-agents.js';
13
+ export { agentAsTool } from './agent-as-tool.js';
@@ -1,5 +1,5 @@
1
1
  import Anthropic from '@anthropic-ai/sdk';
2
- import type { ModelCall } from '../run-agent.js';
2
+ import type { ModelCall } from '#run-agent.js';
3
3
  export interface AnthropicModelCallOptions {
4
4
  /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { ModelCall } from '../run-agent.js';
2
+ import type { ModelCall } from '#run-agent.js';
3
3
  export interface DeepSeekModelCallOptions {
4
4
  /** Defaults to the DEEPSEEK_API_KEY env var. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Message, ModelCall, ModelResponse } from '../run-agent.js';
2
+ import type { Message, ModelCall, ModelResponse } from '#run-agent.js';
3
3
  type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
4
4
  export interface OpenAIModelCallOptions {
5
5
  /** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
@@ -1,4 +1,5 @@
1
- import type { AgentConfig, ToolSchema } from './agent-config.js';
1
+ import { RuleSet } from 'actauth';
2
+ import type { AgentConfig, ToolDefinition, ToolSchema } from '#agent-config.js';
2
3
  export interface ModelContentBlock {
3
4
  type: string;
4
5
  /** text blocks. */
@@ -66,4 +67,26 @@ export interface RunAgentResult {
66
67
  * not something the model said. Absent on a normal finish. */
67
68
  stopReason?: 'max_turns';
68
69
  }
70
+ /** Resolves AgentConfig.rules the same way skillsDirs resolves its own
71
+ * default: an inline array is used as-is (full 3-segment scopePattern,
72
+ * unchanged — see loadYamlRules for why only the YAML form gets the
73
+ * agent segment auto-appended); omitted entirely defaults to
74
+ * `agents/<name>/actauth.yml`, this repo's own folder-form convention.
75
+ * Unlike skillsDirs, a missing *default* file falls back to an empty
76
+ * ruleset rather than propagating the raw ENOENT — permissions failing
77
+ * shouldn't crash the whole call. That fallback defaults to `'deny'`, not
78
+ * the inline-array form's `'ask'`: no `actauth.yml` at all means this
79
+ * agent's permission story was never actually written, which is a
80
+ * stricter unknown than "written, but this one tool has no rule" — 'ask'
81
+ * still runs an Approver that could auto-approve, while 'deny' refuses
82
+ * outright until real rules exist. `defaultDecision`, if set, still wins
83
+ * over that. An *explicitly* given path that doesn't exist is a real
84
+ * configuration bug, though, and still throws — only the silent, implicit
85
+ * default gets this forgiveness.
86
+ *
87
+ * Exported (alongside loadDefaultTools below) so adapters/http.ts's
88
+ * GET /agents/:name/config route can show the same rules runAgent() would
89
+ * actually enforce, not a re-derived guess at them. */
90
+ export declare function loadRules(config: AgentConfig): RuleSet;
91
+ export declare function loadDefaultTools(config: AgentConfig): Promise<ToolDefinition[]>;
69
92
  export declare function runAgent(config: AgentConfig, modelCall: ModelCall, userMessage: string, history?: Message[], options?: RunAgentOptions): Promise<RunAgentResult>;
package/dist/run-agent.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // differ. runAgent itself does no I/O and holds no state between calls:
4
4
  // callers own conversation history, which is what lets the same function
5
5
  // serve a one-shot CLI invocation and a long-lived chat session.
6
- import { existsSync, readFileSync } from 'node:fs';
6
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
  import { parse as parseYaml } from 'yaml';
@@ -12,6 +12,8 @@ import { SkillGarden } from 'skillgarden';
12
12
  import { ContextClipper } from 'contextclip';
13
13
  import { ToolLane } from 'toollane';
14
14
  import { Reflow } from 'reflowkit';
15
+ import { loadAgentModule } from './discover-agents.js';
16
+ import { agentAsTool } from './agent-as-tool.js';
15
17
  // Resolved relative to *this file's own location* (via import.meta.url),
16
18
  // not process.cwd() — the same reasoning agent-registry.ts's own
17
19
  // agentsDir uses. In dev (tsx), this file and agents/ are both source
@@ -85,8 +87,12 @@ function loadYamlRules(path, agentName) {
85
87
  * outright until real rules exist. `defaultDecision`, if set, still wins
86
88
  * over that. An *explicitly* given path that doesn't exist is a real
87
89
  * configuration bug, though, and still throws — only the silent, implicit
88
- * default gets this forgiveness. */
89
- function loadRules(config) {
90
+ * default gets this forgiveness.
91
+ *
92
+ * Exported (alongside loadDefaultTools below) so adapters/http.ts's
93
+ * GET /agents/:name/config route can show the same rules runAgent() would
94
+ * actually enforce, not a re-derived guess at them. */
95
+ export function loadRules(config) {
90
96
  if (Array.isArray(config.rules))
91
97
  return new RuleSet(config.rules, config.defaultDecision ?? 'ask');
92
98
  const usingDefaultPath = config.rules === undefined;
@@ -112,8 +118,7 @@ function loadRules(config) {
112
118
  * this default) just gets `[]`, same as if it had explicitly set that. A
113
119
  * module that *does* exist but throws while importing, or doesn't export
114
120
  * `tools` at all, is a real bug and is not swallowed. */
115
- async function loadDefaultTools(config) {
116
- const toolsDir = join(agentsRootDir, config.name, 'tools');
121
+ async function loadToolsFromDir(toolsDir) {
117
122
  for (const indexName of ['index.ts', 'index.js']) {
118
123
  const indexPath = join(toolsDir, indexName);
119
124
  if (!existsSync(indexPath))
@@ -126,6 +131,90 @@ async function loadDefaultTools(config) {
126
131
  }
127
132
  return [];
128
133
  }
134
+ export async function loadDefaultTools(config) {
135
+ return loadToolsFromDir(join(agentsRootDir, config.name, 'tools'));
136
+ }
137
+ /** Every other folder-form default in this file (loadDefaultTools,
138
+ * loadRules, the skillsDirs default below) resolves against
139
+ * `agents/<name>/` — correct for a top-level agent, where that's exactly
140
+ * where its module lives, but wrong for a subagent: its `config` only
141
+ * carries its own bare name (e.g. 'billing-agent'), not the nested folder
142
+ * loadSubagentTools actually found it in
143
+ * (`agents/support-orchestrator/subagents/billing-agent/`). Left alone,
144
+ * an omitted `tools`/`rules`/`skillsDirs` on a subagent would silently
145
+ * resolve against `agents/billing-agent/...` instead — empty, if no such
146
+ * top-level folder exists, or worse, some *unrelated* top-level agent's
147
+ * tools/rules/skills, if one happens to share the same name.
148
+ *
149
+ * This patches exactly those three fields — only when the subagent's own
150
+ * config left them unset — to resolve against `dir`, its real folder,
151
+ * before it's ever handed to agentAsTool/runAgent. An explicit value on
152
+ * the subagent's own config (its author opted out on purpose) is always
153
+ * left untouched. `rules` gets the same "missing file is fine, empty
154
+ * deny-everything ruleset" fallback loadRules's own default path gets —
155
+ * computed here instead of left to loadRules, since leaving `rules`
156
+ * unset would have it fall through to loadRules's *own* (wrong,
157
+ * name-based) default path instead of this one. */
158
+ async function resolveSubagentConfig(config, dir) {
159
+ const resolved = { ...config };
160
+ if (resolved.tools === undefined) {
161
+ resolved.tools = await loadToolsFromDir(join(dir, 'tools'));
162
+ }
163
+ if (resolved.rules === undefined) {
164
+ const rulesPath = join(dir, 'actauth.yml');
165
+ if (existsSync(rulesPath)) {
166
+ resolved.rules = rulesPath;
167
+ }
168
+ else {
169
+ resolved.rules = [];
170
+ resolved.defaultDecision = resolved.defaultDecision ?? 'deny';
171
+ }
172
+ }
173
+ if (resolved.skillsDirs === undefined) {
174
+ resolved.skillsDirs = [join(dir, 'skills')];
175
+ }
176
+ return resolved;
177
+ }
178
+ /** Auto-loads agents/<name>/subagents/<child>/index.{ts,js} — each one a
179
+ * full AgentConfig, wrapped with agentAsTool and merged into `config`'s
180
+ * own tools, no import or AgentConfig.tools edit required. Unlike
181
+ * loadDefaultTools, this always runs regardless of whether `config.tools`
182
+ * was left to its own default or set explicitly — see AgentConfig.tools's
183
+ * own doc comment for why subagents are a distinct concern from
184
+ * hand-written tools. A missing subagents/ folder is just `[]`, same
185
+ * missing-is-fine treatment tools/ and skills/ get; a subdirectory with
186
+ * neither index.ts nor index.js is skipped, same as resolveModulePath's
187
+ * own handling in discover-agents.ts.
188
+ *
189
+ * Each subagent is loaded via loadAgentModule — the same per-module
190
+ * resolution discoverAgents itself uses — then patched by
191
+ * resolveSubagentConfig (see its own doc comment for why) before being
192
+ * wrapped. Its own `config` goes through this exact same function again
193
+ * the next time *it* runs (inside agentAsTool's execute, via runAgent).
194
+ * That's what makes nesting (a subagent with its own subagents/ folder)
195
+ * work with no extra recursion code: it's just this function being
196
+ * called again, one level down. A folder can't be its own ancestor, so
197
+ * this can't cycle the way a hand-wired agentAsTool(getEntry(...)) call
198
+ * elsewhere could. */
199
+ async function loadSubagentTools(config) {
200
+ const subagentsDir = join(agentsRootDir, config.name, 'subagents');
201
+ if (!existsSync(subagentsDir))
202
+ return [];
203
+ const tools = [];
204
+ for (const dirent of readdirSync(subagentsDir, { withFileTypes: true })) {
205
+ if (!dirent.isDirectory())
206
+ continue;
207
+ const dir = join(subagentsDir, dirent.name);
208
+ const indexPath = ['index.ts', 'index.js'].map((n) => join(dir, n)).find((p) => existsSync(p));
209
+ if (!indexPath)
210
+ continue;
211
+ const label = `agents/${config.name}/subagents/${dirent.name}/index`;
212
+ const subagent = await loadAgentModule(indexPath, label);
213
+ const subagentConfig = await resolveSubagentConfig(subagent.config, dir);
214
+ tools.push(agentAsTool(subagentConfig, subagent.createModelCall));
215
+ }
216
+ return tools;
217
+ }
129
218
  export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
130
219
  const log = options.onEvent ?? (() => { });
131
220
  // tenant: resolved by the caller (adapters/http.ts calls
@@ -140,7 +229,10 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
140
229
  // (including `[]`) is used as-is; omitted entirely defaults to
141
230
  // importing agents/<name>/tools/index.{ts,js} — see loadDefaultTools's
142
231
  // own doc comment for the full reasoning and the cases that can't use it.
143
- const tools = config.tools ?? (await loadDefaultTools(config));
232
+ // agents/<name>/subagents/* is merged in on top either way — see
233
+ // loadSubagentTools's own doc comment for why that one isn't gated by
234
+ // whether `tools` was explicit.
235
+ const tools = [...(config.tools ?? (await loadDefaultTools(config))), ...(await loadSubagentTools(config))];
144
236
  // Omitted entirely (undefined): default to this agent's own
145
237
  // agents/<name>/skills — the folder-form convention every agent in
146
238
  // this repo already follows (see agent-config.ts's skillsDirs doc
@@ -1,6 +1,6 @@
1
1
  import { Redis } from 'ioredis';
2
2
  import { type Storage } from 'sessionknit';
3
- import type { Message } from './run-agent.js';
3
+ import type { Message } from '#run-agent.js';
4
4
  export interface SessionResult<T> {
5
5
  /** Exactly what this turn added — durably appended as-is, in order.
6
6
  * This has to come from the caller explicitly (runAgent's own