loopengine 0.0.10 → 0.0.16

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
+ }
@@ -1,5 +1,36 @@
1
- import type { Rule, Decision, Scope, Approver } from 'actauth';
1
+ import type { Rule, Decision, Approver } from 'actauth';
2
2
  import type { SafetyClassifier } from 'toollane';
3
+ /** Declares which real ModelCall an agent module wants built for it, so
4
+ * the module doesn't have to export its own `createModelCall` —
5
+ * `discoverAgents` synthesizes one instead (see `discover-agents.ts`),
6
+ * lazily and memoized, the same pattern
7
+ * `agents/customer-service/index.ts`'s own hand-written `createModelCall`
8
+ * used before this existed. Each provider's own `createXModelCall`
9
+ * factory (`model-calls/*.ts`) still exists and still works standalone —
10
+ * this is a convenience for the common case, not a replacement; still
11
+ * export your own `createModelCall` for anything this can't express (a
12
+ * canned/simulated `ModelCall` for a demo, a custom SDK client, a
13
+ * provider this doesn't list). `model` is required for `openai`/
14
+ * `deepseek` (no safe hardcoded default — a flagship model name changes
15
+ * too often to bake one in) but optional for `anthropic` (defaults to
16
+ * `'claude-sonnet-5'`), mirroring each factory's own options exactly. */
17
+ export type AgentModelConfig = {
18
+ provider: 'anthropic';
19
+ model?: string;
20
+ apiKey?: string;
21
+ maxTokens?: number;
22
+ } | {
23
+ provider: 'openai';
24
+ model: string;
25
+ apiKey?: string;
26
+ maxTokens?: number;
27
+ } | {
28
+ provider: 'deepseek';
29
+ model: string;
30
+ apiKey?: string;
31
+ maxTokens?: number;
32
+ baseURL?: string;
33
+ };
3
34
  export interface ToolSchema {
4
35
  name: string;
5
36
  description: string;
@@ -8,21 +39,105 @@ export interface ToolSchema {
8
39
  }
9
40
  export interface ToolDefinition extends ToolSchema {
10
41
  execute: (input: Record<string, unknown>) => Promise<unknown>;
42
+ /** Safe to run in ToolLane's parallel lane alongside other safe calls —
43
+ * true for a read-only tool with no side effects and no shared mutable
44
+ * state (a lookup, a search), false/omitted for anything that mutates
45
+ * something (a refund, an email, a write). Only consulted as a default
46
+ * when `AgentConfig.isSafeTool` isn't set — see that field's own doc
47
+ * comment for why a classifier function (call-level, agent-level) is
48
+ * the more powerful of the two and takes full precedence when given. */
49
+ safe?: boolean;
11
50
  }
12
51
  export interface AgentConfig {
13
52
  /** Also doubles as the ActAuth scope.agent segment. */
14
53
  name: string;
15
54
  systemPrompt: string;
16
- /** Hand-written tools. Default []. */
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;
64
+ /** See AgentModelConfig's own doc comment. Omit entirely and the module
65
+ * must export its own `createModelCall` instead — `discoverAgents`
66
+ * throws at startup on a module with neither. */
67
+ model?: AgentModelConfig;
68
+ /** Hand-written tools. An explicit array (including `[]`) is used as-is.
69
+ * Omit entirely and it defaults to importing
70
+ * `agents/<name>/tools/index.{ts,js}` and using its exported `tools` —
71
+ * this repo's own aggregation-file convention (see
72
+ * `agents/customer-service/tools/index.ts`), resolved next to
73
+ * `run-agent.ts` itself (not `process.cwd()`, unlike `skillsDirs`/
74
+ * `rules` — that file is real compiled code, not data, so it has to be
75
+ * found next to whichever build of `run-agent.ts` is actually running).
76
+ * A missing file there is just `[]`, not an error — same
77
+ * missing-is-fine treatment `skillsDirs` gets. An agent that needs to
78
+ * merge in tools from somewhere else too (e.g.
79
+ * `agents/file-agent/index.ts`'s Composio-sourced ones) can't rely on
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. */
17
88
  tools?: ToolDefinition[];
18
- /** ActAuth rules, e.g. { scopePattern: 'default/production/customer-service', tool: 'issue_refund', decision: 'ask' } */
19
- rules: Rule[];
20
- /** Decision when no rule matches. Default 'ask' — new tools are opt-in, not silently allowed. */
89
+ /** ActAuth rules — either inline (handy for tests and small/synthetic
90
+ * configs) or a path to an `actauth.yml` file (same shape as
91
+ * `examples/actauth.yml` in the actauth package: top-level
92
+ * `default_decision` + a `rules:` list with `scope`/`tool`/`decision`/
93
+ * optional `when`), resolved against `process.cwd()` the same way
94
+ * `skillsDirs` is. A real agent with more than a couple of rules should
95
+ * use the file form — see agents/customer-service/actauth.yml — since a
96
+ * permission story with `when` conditions and per-tenant/environment
97
+ * scoping reads better as data than as a TypeScript array literal.
98
+ * `defaultDecision` below only applies to the inline-array form; the
99
+ * file form carries its own `default_decision` and ignores it.
100
+ *
101
+ * Omit entirely and it defaults to `agents/<name>/actauth.yml` — same
102
+ * folder-form convention `skillsDirs` defaults to. Unlike `skillsDirs`,
103
+ * a missing file there doesn't silently mean "no rules apply" as if
104
+ * every tool were pre-approved — it falls back to an empty ruleset
105
+ * governed by `defaultDecision` (default `'deny'` here, stricter than
106
+ * the inline-array form's `'ask'` default: no file at all means this
107
+ * agent's permission story was never written, not just incomplete), so
108
+ * an agent with no `actauth.yml` yet refuses every tool outright,
109
+ * rather than either crashing or silently allowing/asking. */
110
+ rules?: Rule[] | string;
111
+ /** Decision when no rule matches, for the inline-array `rules` form only.
112
+ * Default 'ask' — new tools are opt-in, not silently allowed. */
21
113
  defaultDecision?: Decision;
22
114
  /** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
23
115
  approver?: Approver;
24
- scope?: Partial<Omit<Scope, 'agent'>>;
25
- /** SKILL.md directories this agent can discover and invoke. Omit if the agent has no skills. */
116
+ /** Resolves the ActAuth tenant for a request, from headers (never the
117
+ * body: tenant feeds permission decisions directly, so it has to come
118
+ * from something verified — an Authorization/API-key header checked
119
+ * against your own mapping — never a raw client-asserted body field
120
+ * that would let any caller claim to be any tenant and inherit that
121
+ * tenant's rules). Only `adapters/http.ts` can actually call this (it
122
+ * alone has a request's headers to call it with) — `run-agent.ts`
123
+ * itself never sees a request, so `runAgent()`'s standalone/CLI callers
124
+ * always get the `'default'` tenant, the same as omitting this field
125
+ * entirely. Returning `undefined` is a real auth failure
126
+ * (`adapters/http.ts` responds `401`), not "fall back to `'default'`"
127
+ * — if "no header at all" should resolve to `'default'` rather than a
128
+ * rejection, the resolver itself must return `'default'` explicitly.
129
+ *
130
+ * There's no equivalent `environment` field: environment is a
131
+ * deployment-wide setting (`LOOPENGINE_ENV`, default `'production'`),
132
+ * not something that varies by agent or by request. */
133
+ tenantFor?: (headers: Record<string, string | string[] | undefined>, body: Record<string, unknown>) => string | undefined;
134
+ /** SKILL.md directories this agent can discover and invoke, resolved
135
+ * against `process.cwd()` (not this config's own file location). Omit
136
+ * entirely and it defaults to `agents/<name>/skills` — this repo's own
137
+ * folder-form convention (see `agents/customer-service/skills/`) — which
138
+ * is harmless even if that folder doesn't exist (a flat-file agent with
139
+ * no skills at all just gets an empty index, not an error). Pass `[]`
140
+ * explicitly instead of omitting the field to opt out of that default. */
26
141
  skillsDirs?: string[];
27
142
  skillIndexBudgetTokens?: number;
28
143
  contextBudgetTokens?: number;
@@ -34,7 +149,13 @@ export interface AgentConfig {
34
149
  * RunAgentResult.stopReason set to 'max_turns' so a caller can tell the
35
150
  * difference from a normal finish. */
36
151
  maxTurns?: number;
37
- /** Which tools ToolLane may run in a parallel lane. Default: none (every tool runs solo). */
152
+ /** Which tools ToolLane may run in a parallel lane. Takes full
153
+ * precedence over each tool's own `ToolDefinition.safe` flag when set —
154
+ * it's strictly more powerful (a function of the whole call, not just
155
+ * the tool's static definition, so it can vary by agent or, if you
156
+ * thread args through, by the call's own input). Omit it to fall back
157
+ * to each called tool's own `safe` flag instead (looked up by name);
158
+ * omit both and every tool runs solo. */
38
159
  isSafeTool?: SafetyClassifier;
39
160
  /** How adapters/http.ts derives a session key from a request body — this
40
161
  * is business logic ("what counts as one conversation" is agent-
@@ -43,6 +164,15 @@ export interface AgentConfig {
43
164
  * adapter. Return undefined to signal "this body doesn't identify a
44
165
  * session" (the adapter responds 400). Omit entirely to use the
45
166
  * adapter's default — a client-supplied `sessionId` field, the same
46
- * shape adapters/cli.ts's --session flag already uses. */
167
+ * shape adapters/cli.ts's --session flag already uses.
168
+ *
169
+ * Body-only, unlike `tenantFor` above — a spoofed session key only lets
170
+ * someone read/continue the wrong *conversation*; it doesn't grant
171
+ * elevated tool permissions the way a spoofed tenant would. Real
172
+ * verified-identity session keys (deriving this from an auth header
173
+ * instead of a client-asserted body field like customerEmail) are a
174
+ * legitimate future need, but nothing in this repo has a concrete use
175
+ * for it yet — no sense widening this signature speculatively before
176
+ * there's a real consumer to shape it around. */
47
177
  sessionIdFor?: (body: Record<string, unknown>) => string | undefined;
48
178
  }
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,174 @@
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 { access, mkdir, writeFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
11
+ export class AgentNameError extends Error {
12
+ }
13
+ export class AgentExistsError extends Error {
14
+ }
15
+ export class AgentNotFoundError extends Error {
16
+ }
17
+ export function agentIndexTemplate(name) {
18
+ return `import type { AgentConfig } from 'loopengine'
19
+
20
+ export const config: AgentConfig = {
21
+ name: '${name}',
22
+ systemPrompt: 'You are ...',
23
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
24
+ }
25
+ `;
26
+ }
27
+ /** Same shape as agentIndexTemplate, plus `toolDescription` — the one
28
+ * field a subagent needs that a top-level agent doesn't. run-agent.ts's
29
+ * loadSubagentTools wraps this config with agentAsTool, which throws
30
+ * without a real toolDescription: it's what the parent agent's model
31
+ * reads to decide when to delegate here, and systemPrompt (instructions
32
+ * for this agent itself) isn't a substitute. See agent-as-tool.ts. */
33
+ export function subagentIndexTemplate(name) {
34
+ return `import type { AgentConfig } from 'loopengine'
35
+
36
+ export const config: AgentConfig = {
37
+ name: '${name}',
38
+ systemPrompt: 'You are ...',
39
+ toolDescription: 'Call this when ...',
40
+ model: { provider: 'anthropic', model: 'claude-sonnet-5' }, // reads ANTHROPIC_API_KEY
41
+ }
42
+ `;
43
+ }
44
+ /** Writes agents/<name>/index.ts under baseDir and returns its path.
45
+ * Only that one file — tools, rules, and skillsDirs are left to their
46
+ * smart defaults (see AgentConfig's own doc comments), the same as the
47
+ * README's own "simplest possible agent" example. */
48
+ export async function scaffoldAgent(baseDir, name) {
49
+ if (!NAME_PATTERN.test(name)) {
50
+ throw new AgentNameError(`Agent name must be lowercase, alphanumeric, hyphen-separated (e.g. "weather-agent") — got "${name}"`);
51
+ }
52
+ const dir = path.join(baseDir, 'agents', name);
53
+ const indexPath = path.join(dir, 'index.ts');
54
+ if (await pathExists(indexPath)) {
55
+ throw new AgentExistsError(`agents/${name}/index.ts already exists — pick a different name or edit it directly.`);
56
+ }
57
+ await mkdir(dir, { recursive: true });
58
+ await writeFile(indexPath, agentIndexTemplate(name));
59
+ return indexPath;
60
+ }
61
+ /** Resolves `parent` — a single agent name, or a `/`-joined path of
62
+ * names for nesting more than one level deep (e.g.
63
+ * "support-orchestrator/billing-agent" to scaffold a subagent under an
64
+ * *existing subagent*) — to the real directory that agent's index.ts
65
+ * lives in: agents/<seg0>/subagents/<seg1>/subagents/<seg2>/... Every
66
+ * segment gets the same lowercase-hyphen validation a plain agent name
67
+ * does; there's no separate "path" naming rule. */
68
+ function resolveParentDir(baseDir, parent) {
69
+ const segments = parent.split('/');
70
+ for (const segment of segments) {
71
+ if (!NAME_PATTERN.test(segment)) {
72
+ 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}"`);
73
+ }
74
+ }
75
+ return segments.slice(1).reduce((dir, segment) => path.join(dir, 'subagents', segment), path.join(baseDir, 'agents', segments[0]));
76
+ }
77
+ /** Writes <parentDir>/subagents/<name>/index.ts under baseDir and
78
+ * returns its path, where <parentDir> is `parent` resolved by
79
+ * resolveParentDir — the folder convention run-agent.ts's
80
+ * loadSubagentTools auto-discovers, so this subagent becomes one of
81
+ * `parent`'s tools with no further edits: no import, no
82
+ * AgentConfig.tools entry, nothing to register by hand. Requires
83
+ * `parent` to already exist (its own index.ts) — a subagent without a
84
+ * parent to nest under is just a regular top-level agent, which
85
+ * scaffoldAgent already covers. Nesting more than one level is just
86
+ * `parent` itself being a path — see resolveParentDir. */
87
+ export async function scaffoldSubagent(baseDir, parent, name) {
88
+ const parentDir = resolveParentDir(baseDir, parent);
89
+ if (!NAME_PATTERN.test(name)) {
90
+ throw new AgentNameError(`Subagent name must be lowercase, alphanumeric, hyphen-separated (e.g. "billing-agent") — got "${name}"`);
91
+ }
92
+ const parentIndexPath = path.join(parentDir, 'index.ts');
93
+ if (!(await pathExists(parentIndexPath))) {
94
+ throw new AgentNotFoundError(`'${parent}' doesn't exist yet (expected ${parentIndexPath}) — create it first.`);
95
+ }
96
+ const dir = path.join(parentDir, 'subagents', name);
97
+ const indexPath = path.join(dir, 'index.ts');
98
+ if (await pathExists(indexPath)) {
99
+ throw new AgentExistsError(`A subagent named '${name}' already exists under '${parent}' (${indexPath}) — pick a different name or edit it directly.`);
100
+ }
101
+ await mkdir(dir, { recursive: true });
102
+ await writeFile(indexPath, subagentIndexTemplate(name));
103
+ return indexPath;
104
+ }
105
+ async function pathExists(p) {
106
+ try {
107
+ await access(p);
108
+ return true;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ async function main() {
115
+ const [, , command, ...rest] = process.argv;
116
+ if (command === 'add-agent') {
117
+ const [name] = rest;
118
+ if (!name) {
119
+ console.error('Usage: loopengine add-agent <name>');
120
+ process.exitCode = 1;
121
+ return;
122
+ }
123
+ try {
124
+ await scaffoldAgent(process.cwd(), name);
125
+ }
126
+ catch (err) {
127
+ console.error(err instanceof Error ? err.message : String(err));
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ console.log(`Created agents/${name}/index.ts`);
132
+ console.log();
133
+ console.log('tools, rules, and skillsDirs are all omitted — they default to:');
134
+ console.log(` agents/${name}/tools/index.ts (no tools until you add one)`);
135
+ console.log(` agents/${name}/actauth.yml (denies every tool until you add one)`);
136
+ console.log(` agents/${name}/skills/ (no skills until you add one)`);
137
+ console.log();
138
+ console.log(`Run it: npx tsx adapters/cli.ts --agent ${name} "hello"`);
139
+ return;
140
+ }
141
+ if (command === 'add-subagent') {
142
+ const [parent, name] = rest;
143
+ if (!parent || !name) {
144
+ console.error('Usage: loopengine add-subagent <parent> <name>');
145
+ console.error(' <parent> can be a single agent name, or several joined by \'/\' to nest deeper (e.g. "support-orchestrator/billing-agent")');
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+ let indexPath;
150
+ try {
151
+ indexPath = await scaffoldSubagent(process.cwd(), parent, name);
152
+ }
153
+ catch (err) {
154
+ console.error(err instanceof Error ? err.message : String(err));
155
+ process.exitCode = 1;
156
+ return;
157
+ }
158
+ console.log(`Created ${path.relative(process.cwd(), indexPath)}`);
159
+ console.log();
160
+ console.log(`Auto-registered as a tool on '${parent}' — no import, no tools array edit needed.`);
161
+ console.log(`Fill in toolDescription: it's what '${parent}'s model reads to decide when to call '${name}'.`);
162
+ console.log();
163
+ console.log(`Run the top-level agent: npx tsx adapters/cli.ts --agent ${parent.split('/')[0]} "hello"`);
164
+ return;
165
+ }
166
+ console.error('Usage: loopengine add-agent <name>');
167
+ console.error(' loopengine add-subagent <parent> <name>');
168
+ process.exitCode = 1;
169
+ }
170
+ // Only run when executed directly — not when scaffoldAgent is imported
171
+ // for testing.
172
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
173
+ main();
174
+ }
@@ -1,16 +1,24 @@
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
- /** Scans `dir`'s direct files only (not subdirectories — put shared,
9
- * non-agent code in a subdirectory to keep it out of discovery, no
10
- * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
11
- * expected to export `config` and `createModelCall` — the same shape
12
- * every agents/*.ts file in this repo's own reference app already
13
- * exports. Throws, rather than skipping, on a file that doesn't match
14
- * that shape or a duplicate AgentConfig.name — both are almost always a
15
- * bug in the agent file, not something to silently ignore. */
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>;
17
+ /** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
18
+ * subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
19
+ * each expected to export `config`, and either its own `createModelCall`
20
+ * or a `config.model` for one to be synthesized from (see
21
+ * AgentModelConfig's own doc comment). Throws, rather than skipping, on a
22
+ * module that doesn't match that shape or a duplicate AgentConfig.name —
23
+ * both are almost always a bug, not something to silently ignore. */
16
24
  export declare function discoverAgents(dir: string): Promise<Map<string, AgentModule>>;
@@ -11,38 +11,99 @@
11
11
  // silently collide in ActAuth's rules today regardless of this helper;
12
12
  // surfacing that collision loudly here, at discovery time, is a real
13
13
  // safety property this adds, not just convenience.
14
- import { readdirSync } from 'node:fs';
15
- import { resolve } from 'node:path';
14
+ import { existsSync, readdirSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
16
  import { pathToFileURL } from 'node:url';
17
- function isAgentModule(mod) {
18
- return (typeof mod === 'object' &&
19
- mod !== null &&
20
- 'config' in mod &&
21
- 'createModelCall' in mod &&
22
- typeof mod.createModelCall === 'function');
17
+ function isRawAgentModule(mod) {
18
+ return typeof mod === 'object' && mod !== null && 'config' in mod;
23
19
  }
24
- /** Scans `dir`'s direct files only (not subdirectories — put shared,
25
- * non-agent code in a subdirectory to keep it out of discovery, no
26
- * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
27
- * expected to export `config` and `createModelCall` — the same shape
28
- * every agents/*.ts file in this repo's own reference app already
29
- * exports. Throws, rather than skipping, on a file that doesn't match
30
- * that shape or a duplicate AgentConfig.name — both are almost always a
31
- * bug in the agent file, not something to silently ignore. */
20
+ /** Builds a lazy, memoized createModelCall from AgentConfig.model, for a
21
+ * module that doesn't export its own — see that field's own doc comment.
22
+ * The provider's SDK module is imported here, eagerly, but that's cheap
23
+ * and safe with no API key at all: importing model-calls/*.ts only
24
+ * defines functions, it never constructs a client at module top level
25
+ * (see e.g. createAnthropicModelCall's own body) — so this can't fail
26
+ * discovery the way eagerly building the actual client would. The real
27
+ * client is only built the first time the returned function is actually
28
+ * called, and reused after that, the same "don't crash the whole server
29
+ * over one agent's missing API key, don't rebuild per call" reasoning
30
+ * agents/customer-service/index.ts's own hand-written createModelCall
31
+ * used before this existed. */
32
+ async function synthesizeCreateModelCall(model) {
33
+ let cached;
34
+ if (model.provider === 'anthropic') {
35
+ const { createAnthropicModelCall } = await import('./model-calls/anthropic-model-call.js');
36
+ return () => (cached ??= createAnthropicModelCall(model));
37
+ }
38
+ if (model.provider === 'openai') {
39
+ const { createOpenAIModelCall } = await import('./model-calls/openai-model-call.js');
40
+ return () => (cached ??= createOpenAIModelCall(model));
41
+ }
42
+ const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
43
+ return () => (cached ??= createDeepSeekModelCall(model));
44
+ }
45
+ /** Resolves one directory entry to an agent module's path, or undefined if
46
+ * this entry isn't one — a `.ts`/`.js` file directly (the common case), or
47
+ * a subdirectory containing an `index.ts`/`index.js` (the same "a
48
+ * directory can be a module" convention Node's own `require()` resolution
49
+ * already uses — no new mental model to learn). A subdirectory with
50
+ * neither is ordinary supporting code living alongside an agent's own
51
+ * folder (a `tools.ts` split out of a large agent, say), not an agent
52
+ * itself, and is silently skipped rather than treated as an error. */
53
+ function resolveModulePath(dir, entry) {
54
+ if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
55
+ return { path: resolve(dir, entry.name), label: entry.name };
56
+ }
57
+ if (entry.isDirectory()) {
58
+ for (const indexName of ['index.ts', 'index.js']) {
59
+ const indexPath = join(dir, entry.name, indexName);
60
+ if (existsSync(indexPath))
61
+ return { path: resolve(indexPath), label: `${entry.name}/${indexName}` };
62
+ }
63
+ }
64
+ return undefined;
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
+ }
88
+ /** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
89
+ * subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
90
+ * each expected to export `config`, and either its own `createModelCall`
91
+ * or a `config.model` for one to be synthesized from (see
92
+ * AgentModelConfig's own doc comment). Throws, rather than skipping, on a
93
+ * module that doesn't match that shape or a duplicate AgentConfig.name —
94
+ * both are almost always a bug, not something to silently ignore. */
32
95
  export async function discoverAgents(dir) {
33
96
  const entries = new Map();
34
- const files = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts'));
35
- for (const file of files) {
36
- const modulePath = resolve(dir, file.name);
37
- const mod = await import(pathToFileURL(modulePath).href);
38
- if (!isAgentModule(mod)) {
39
- throw new Error(`${file.name} in ${dir} does not export both 'config' and 'createModelCall' — every agent module must.`);
40
- }
41
- const name = mod.config.name;
97
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
98
+ const resolved = resolveModulePath(dir, dirent);
99
+ if (!resolved)
100
+ continue;
101
+ const agentModule = await loadAgentModule(resolved.path, `${resolved.label} in ${dir}`);
102
+ const name = agentModule.config.name;
42
103
  if (entries.has(name)) {
43
- throw new Error(`Duplicate agent name '${name}' — ${file.name} in ${dir} isn't the first file to declare AgentConfig.name '${name}'.`);
104
+ throw new Error(`Duplicate agent name '${name}' — ${resolved.label} in ${dir} isn't the first module to declare AgentConfig.name '${name}'.`);
44
105
  }
45
- entries.set(name, mod);
106
+ entries.set(name, agentModule);
46
107
  }
47
108
  return entries;
48
109
  }
package/dist/index.d.ts CHANGED
@@ -1,15 +1,16 @@
1
- export { runAgent } from './run-agent.js';
2
- export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
3
- export type { AgentConfig, 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';
7
7
  export type { Document, ScoredDocument } from './vector-index.js';
8
- export { createAnthropicModelCall } from './anthropic-model-call.js';
9
- export type { AnthropicModelCallOptions } from './anthropic-model-call.js';
10
- export { createOpenAIModelCall } from './openai-model-call.js';
11
- export type { OpenAIModelCallOptions } from './openai-model-call.js';
12
- export { createDeepSeekModelCall } from './deepseek-model-call.js';
13
- export type { DeepSeekModelCallOptions } from './deepseek-model-call.js';
14
- export { discoverAgents } from './discover-agents.js';
8
+ export { createAnthropicModelCall } from './model-calls/anthropic-model-call.js';
9
+ export type { AnthropicModelCallOptions } from './model-calls/anthropic-model-call.js';
10
+ export { createOpenAIModelCall } from './model-calls/openai-model-call.js';
11
+ export type { OpenAIModelCallOptions } from './model-calls/openai-model-call.js';
12
+ export { createDeepSeekModelCall } from './model-calls/deepseek-model-call.js';
13
+ export type { DeepSeekModelCallOptions } from './model-calls/deepseek-model-call.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
- export { createAnthropicModelCall } from './anthropic-model-call.js';
10
- export { createOpenAIModelCall } from './openai-model-call.js';
11
- export { createDeepSeekModelCall } from './deepseek-model-call.js';
12
- export { discoverAgents } from './discover-agents.js';
9
+ export { createAnthropicModelCall } from './model-calls/anthropic-model-call.js';
10
+ export { createOpenAIModelCall } from './model-calls/openai-model-call.js';
11
+ export { createDeepSeekModelCall } from './model-calls/deepseek-model-call.js';
12
+ export { discoverAgents, loadAgentModule } from './discover-agents.js';
13
+ export { agentAsTool } from './agent-as-tool.js';