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.
- package/README.md +303 -573
- package/dist/agent-as-tool.d.ts +7 -0
- package/dist/agent-as-tool.js +32 -0
- package/dist/agent-config.d.ts +17 -1
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +191 -0
- package/dist/discover-agents.d.ts +11 -2
- package/dist/discover-agents.js +25 -13
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -2
- package/dist/model-calls/anthropic-model-call.d.ts +1 -1
- package/dist/model-calls/deepseek-model-call.d.ts +1 -1
- package/dist/model-calls/openai-model-call.d.ts +1 -1
- package/dist/run-agent.d.ts +24 -1
- package/dist/run-agent.js +98 -6
- package/dist/session-store.d.ts +1 -1
- package/package.json +12 -2
|
@@ -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
|
+
}
|
package/dist/agent-config.d.ts
CHANGED
|
@@ -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 '
|
|
2
|
-
import type { ModelCall } from '
|
|
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`
|
package/dist/discover-agents.js
CHANGED
|
@@ -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
|
|
80
|
-
|
|
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,
|
|
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 '
|
|
2
|
-
export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from '
|
|
3
|
-
export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition } from '
|
|
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 '
|
|
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 '
|
|
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 { Message, ModelCall, ModelResponse } from '
|
|
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. */
|
package/dist/run-agent.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
package/dist/session-store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
2
|
import { type Storage } from 'sessionknit';
|
|
3
|
-
import type { Message } from '
|
|
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
|