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.
- package/README.md +306 -423
- package/dist/agent-as-tool.d.ts +7 -0
- package/dist/agent-as-tool.js +32 -0
- package/dist/agent-config.d.ts +139 -9
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +174 -0
- package/dist/discover-agents.d.ts +18 -10
- package/dist/discover-agents.js +87 -26
- package/dist/index.d.ts +11 -10
- package/dist/index.js +6 -5
- package/dist/{anthropic-model-call.d.ts → model-calls/anthropic-model-call.d.ts} +1 -1
- package/dist/{anthropic-model-call.js → model-calls/anthropic-model-call.js} +8 -6
- package/dist/{deepseek-model-call.d.ts → model-calls/deepseek-model-call.d.ts} +1 -1
- package/dist/{openai-model-call.d.ts → model-calls/openai-model-call.d.ts} +1 -1
- package/dist/run-agent.d.ts +31 -1
- package/dist/run-agent.js +219 -8
- package/dist/session-store.d.ts +1 -1
- package/package.json +21 -10
- /package/dist/{deepseek-model-call.js → model-calls/deepseek-model-call.js} +0 -0
- /package/dist/{openai-model-call.js → model-calls/openai-model-call.js} +0 -0
|
@@ -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
|
@@ -1,5 +1,36 @@
|
|
|
1
|
-
import type { Rule, Decision,
|
|
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
|
-
/**
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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.
|
|
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 '
|
|
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
|
-
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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>>;
|
package/dist/discover-agents.js
CHANGED
|
@@ -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
|
|
18
|
-
return
|
|
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
|
-
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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}' — ${
|
|
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,
|
|
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 '
|
|
2
|
-
export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from '
|
|
3
|
-
export type { AgentConfig, 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';
|
|
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 '
|
|
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';
|