loopengine 0.0.10 → 0.0.14
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 +334 -181
- package/dist/agent-config.d.ts +123 -9
- package/dist/discover-agents.d.ts +7 -8
- package/dist/discover-agents.js +73 -24
- package/dist/index.d.ts +7 -7
- package/dist/index.js +3 -3
- 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 +7 -0
- package/dist/run-agent.js +127 -8
- package/package.json +10 -9
- /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
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,89 @@ 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
|
+
/** See AgentModelConfig's own doc comment. Omit entirely and the module
|
|
56
|
+
* must export its own `createModelCall` instead — `discoverAgents`
|
|
57
|
+
* throws at startup on a module with neither. */
|
|
58
|
+
model?: AgentModelConfig;
|
|
59
|
+
/** Hand-written tools. An explicit array (including `[]`) is used as-is.
|
|
60
|
+
* Omit entirely and it defaults to importing
|
|
61
|
+
* `agents/<name>/tools/index.{ts,js}` and using its exported `tools` —
|
|
62
|
+
* this repo's own aggregation-file convention (see
|
|
63
|
+
* `agents/customer-service/tools/index.ts`), resolved next to
|
|
64
|
+
* `run-agent.ts` itself (not `process.cwd()`, unlike `skillsDirs`/
|
|
65
|
+
* `rules` — that file is real compiled code, not data, so it has to be
|
|
66
|
+
* found next to whichever build of `run-agent.ts` is actually running).
|
|
67
|
+
* A missing file there is just `[]`, not an error — same
|
|
68
|
+
* missing-is-fine treatment `skillsDirs` gets. An agent that needs to
|
|
69
|
+
* merge in tools from somewhere else too (e.g.
|
|
70
|
+
* `agents/file-agent/index.ts`'s Composio-sourced ones) can't rely on
|
|
71
|
+
* this default and still needs to set `tools` explicitly. */
|
|
17
72
|
tools?: ToolDefinition[];
|
|
18
|
-
/** ActAuth rules
|
|
19
|
-
|
|
20
|
-
|
|
73
|
+
/** ActAuth rules — either inline (handy for tests and small/synthetic
|
|
74
|
+
* configs) or a path to an `actauth.yml` file (same shape as
|
|
75
|
+
* `examples/actauth.yml` in the actauth package: top-level
|
|
76
|
+
* `default_decision` + a `rules:` list with `scope`/`tool`/`decision`/
|
|
77
|
+
* optional `when`), resolved against `process.cwd()` the same way
|
|
78
|
+
* `skillsDirs` is. A real agent with more than a couple of rules should
|
|
79
|
+
* use the file form — see agents/customer-service/actauth.yml — since a
|
|
80
|
+
* permission story with `when` conditions and per-tenant/environment
|
|
81
|
+
* scoping reads better as data than as a TypeScript array literal.
|
|
82
|
+
* `defaultDecision` below only applies to the inline-array form; the
|
|
83
|
+
* file form carries its own `default_decision` and ignores it.
|
|
84
|
+
*
|
|
85
|
+
* Omit entirely and it defaults to `agents/<name>/actauth.yml` — same
|
|
86
|
+
* folder-form convention `skillsDirs` defaults to. Unlike `skillsDirs`,
|
|
87
|
+
* a missing file there doesn't silently mean "no rules apply" as if
|
|
88
|
+
* every tool were pre-approved — it falls back to an empty ruleset
|
|
89
|
+
* governed by `defaultDecision` (default `'deny'` here, stricter than
|
|
90
|
+
* the inline-array form's `'ask'` default: no file at all means this
|
|
91
|
+
* agent's permission story was never written, not just incomplete), so
|
|
92
|
+
* an agent with no `actauth.yml` yet refuses every tool outright,
|
|
93
|
+
* rather than either crashing or silently allowing/asking. */
|
|
94
|
+
rules?: Rule[] | string;
|
|
95
|
+
/** Decision when no rule matches, for the inline-array `rules` form only.
|
|
96
|
+
* Default 'ask' — new tools are opt-in, not silently allowed. */
|
|
21
97
|
defaultDecision?: Decision;
|
|
22
98
|
/** Default ConsoleApprover (blocks on stdin) — pass e.g. a Slack-backed Approver for unattended agents. */
|
|
23
99
|
approver?: Approver;
|
|
24
|
-
|
|
25
|
-
|
|
100
|
+
/** Resolves the ActAuth tenant for a request, from headers (never the
|
|
101
|
+
* body: tenant feeds permission decisions directly, so it has to come
|
|
102
|
+
* from something verified — an Authorization/API-key header checked
|
|
103
|
+
* against your own mapping — never a raw client-asserted body field
|
|
104
|
+
* that would let any caller claim to be any tenant and inherit that
|
|
105
|
+
* tenant's rules). Only `adapters/http.ts` can actually call this (it
|
|
106
|
+
* alone has a request's headers to call it with) — `run-agent.ts`
|
|
107
|
+
* itself never sees a request, so `runAgent()`'s standalone/CLI callers
|
|
108
|
+
* always get the `'default'` tenant, the same as omitting this field
|
|
109
|
+
* entirely. Returning `undefined` is a real auth failure
|
|
110
|
+
* (`adapters/http.ts` responds `401`), not "fall back to `'default'`"
|
|
111
|
+
* — if "no header at all" should resolve to `'default'` rather than a
|
|
112
|
+
* rejection, the resolver itself must return `'default'` explicitly.
|
|
113
|
+
*
|
|
114
|
+
* There's no equivalent `environment` field: environment is a
|
|
115
|
+
* deployment-wide setting (`LOOPENGINE_ENV`, default `'production'`),
|
|
116
|
+
* not something that varies by agent or by request. */
|
|
117
|
+
tenantFor?: (headers: Record<string, string | string[] | undefined>, body: Record<string, unknown>) => string | undefined;
|
|
118
|
+
/** SKILL.md directories this agent can discover and invoke, resolved
|
|
119
|
+
* against `process.cwd()` (not this config's own file location). Omit
|
|
120
|
+
* entirely and it defaults to `agents/<name>/skills` — this repo's own
|
|
121
|
+
* folder-form convention (see `agents/customer-service/skills/`) — which
|
|
122
|
+
* is harmless even if that folder doesn't exist (a flat-file agent with
|
|
123
|
+
* no skills at all just gets an empty index, not an error). Pass `[]`
|
|
124
|
+
* explicitly instead of omitting the field to opt out of that default. */
|
|
26
125
|
skillsDirs?: string[];
|
|
27
126
|
skillIndexBudgetTokens?: number;
|
|
28
127
|
contextBudgetTokens?: number;
|
|
@@ -34,7 +133,13 @@ export interface AgentConfig {
|
|
|
34
133
|
* RunAgentResult.stopReason set to 'max_turns' so a caller can tell the
|
|
35
134
|
* difference from a normal finish. */
|
|
36
135
|
maxTurns?: number;
|
|
37
|
-
/** Which tools ToolLane may run in a parallel lane.
|
|
136
|
+
/** Which tools ToolLane may run in a parallel lane. Takes full
|
|
137
|
+
* precedence over each tool's own `ToolDefinition.safe` flag when set —
|
|
138
|
+
* it's strictly more powerful (a function of the whole call, not just
|
|
139
|
+
* the tool's static definition, so it can vary by agent or, if you
|
|
140
|
+
* thread args through, by the call's own input). Omit it to fall back
|
|
141
|
+
* to each called tool's own `safe` flag instead (looked up by name);
|
|
142
|
+
* omit both and every tool runs solo. */
|
|
38
143
|
isSafeTool?: SafetyClassifier;
|
|
39
144
|
/** How adapters/http.ts derives a session key from a request body — this
|
|
40
145
|
* is business logic ("what counts as one conversation" is agent-
|
|
@@ -43,6 +148,15 @@ export interface AgentConfig {
|
|
|
43
148
|
* adapter. Return undefined to signal "this body doesn't identify a
|
|
44
149
|
* session" (the adapter responds 400). Omit entirely to use the
|
|
45
150
|
* adapter's default — a client-supplied `sessionId` field, the same
|
|
46
|
-
* shape adapters/cli.ts's --session flag already uses.
|
|
151
|
+
* shape adapters/cli.ts's --session flag already uses.
|
|
152
|
+
*
|
|
153
|
+
* Body-only, unlike `tenantFor` above — a spoofed session key only lets
|
|
154
|
+
* someone read/continue the wrong *conversation*; it doesn't grant
|
|
155
|
+
* elevated tool permissions the way a spoofed tenant would. Real
|
|
156
|
+
* verified-identity session keys (deriving this from an auth header
|
|
157
|
+
* instead of a client-asserted body field like customerEmail) are a
|
|
158
|
+
* legitimate future need, but nothing in this repo has a concrete use
|
|
159
|
+
* for it yet — no sense widening this signature speculatively before
|
|
160
|
+
* there's a real consumer to shape it around. */
|
|
47
161
|
sessionIdFor?: (body: Record<string, unknown>) => string | undefined;
|
|
48
162
|
}
|
|
@@ -5,12 +5,11 @@ export interface AgentModule {
|
|
|
5
5
|
/** Factory, not a shared instance — a fresh ModelCall per request/session. */
|
|
6
6
|
createModelCall: () => ModelCall;
|
|
7
7
|
}
|
|
8
|
-
/** Scans `dir`
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* bug in the agent file, not something to silently ignore. */
|
|
8
|
+
/** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
|
|
9
|
+
* subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
|
|
10
|
+
* each expected to export `config`, and either its own `createModelCall`
|
|
11
|
+
* or a `config.model` for one to be synthesized from (see
|
|
12
|
+
* AgentModelConfig's own doc comment). Throws, rather than skipping, on a
|
|
13
|
+
* module that doesn't match that shape or a duplicate AgentConfig.name —
|
|
14
|
+
* both are almost always a bug, not something to silently ignore. */
|
|
16
15
|
export declare function discoverAgents(dir: string): Promise<Map<string, AgentModule>>;
|
package/dist/discover-agents.js
CHANGED
|
@@ -11,38 +11,87 @@
|
|
|
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
|
+
/** Scans `dir` for agent modules — direct `.ts`/`.js` files, or
|
|
67
|
+
* subdirectories with an `index.ts`/`index.js` (see resolveModulePath) —
|
|
68
|
+
* each expected to export `config`, and either its own `createModelCall`
|
|
69
|
+
* or a `config.model` for one to be synthesized from (see
|
|
70
|
+
* AgentModelConfig's own doc comment). Throws, rather than skipping, on a
|
|
71
|
+
* module that doesn't match that shape or a duplicate AgentConfig.name —
|
|
72
|
+
* both are almost always a bug, not something to silently ignore. */
|
|
32
73
|
export async function discoverAgents(dir) {
|
|
33
74
|
const entries = new Map();
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
75
|
+
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
|
|
76
|
+
const resolved = resolveModulePath(dir, dirent);
|
|
77
|
+
if (!resolved)
|
|
78
|
+
continue;
|
|
79
|
+
const mod = await import(pathToFileURL(resolved.path).href);
|
|
80
|
+
if (!isRawAgentModule(mod) || !mod.config) {
|
|
81
|
+
throw new Error(`${resolved.label} in ${dir} does not export 'config' — every agent module must.`);
|
|
82
|
+
}
|
|
83
|
+
let createModelCall = mod.createModelCall;
|
|
84
|
+
if (typeof createModelCall !== 'function') {
|
|
85
|
+
if (!mod.config.model) {
|
|
86
|
+
throw new Error(`${resolved.label} in ${dir} exports 'config' but neither 'createModelCall' nor 'config.model' — every agent module must have one or the other.`);
|
|
87
|
+
}
|
|
88
|
+
createModelCall = await synthesizeCreateModelCall(mod.config.model);
|
|
40
89
|
}
|
|
41
90
|
const name = mod.config.name;
|
|
42
91
|
if (entries.has(name)) {
|
|
43
|
-
throw new Error(`Duplicate agent name '${name}' — ${
|
|
92
|
+
throw new Error(`Duplicate agent name '${name}' — ${resolved.label} in ${dir} isn't the first module to declare AgentConfig.name '${name}'.`);
|
|
44
93
|
}
|
|
45
|
-
entries.set(name, mod);
|
|
94
|
+
entries.set(name, { config: mod.config, createModelCall });
|
|
46
95
|
}
|
|
47
96
|
return entries;
|
|
48
97
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
export { runAgent } from './run-agent.js';
|
|
2
2
|
export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from './run-agent.js';
|
|
3
|
-
export type { AgentConfig, ToolSchema, ToolDefinition } from './agent-config.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';
|
|
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
14
|
export { discoverAgents } from './discover-agents.js';
|
|
15
15
|
export type { AgentModule } from './discover-agents.js';
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
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';
|
|
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
12
|
export { discoverAgents } from './discover-agents.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,9 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// One of the real ModelCall implementations loopengine ships (see also
|
|
2
|
+
// openai-model-call.ts, deepseek-model-call.ts — the latter is what
|
|
3
|
+
// agents/customer-service/index.ts actually uses). file-agent.ts and
|
|
4
|
+
// rag-agent.ts still use a canned, turn-counting fake so the whole loop
|
|
5
|
+
// is runnable with no API key — swap that fake for
|
|
6
|
+
// createAnthropicModelCall(...) and nothing in run-agent.ts, the
|
|
7
|
+
// adapters, or any AgentConfig changes. ModelCall is the only seam this
|
|
8
|
+
// needed.
|
|
7
9
|
//
|
|
8
10
|
// loopengine's own Message type mirrors Anthropic's native shape closely
|
|
9
11
|
// on purpose: `content` is either a plain string or a ModelContentBlock[]
|
|
@@ -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
|
@@ -38,6 +38,13 @@ export type ModelCall = (messages: Message[], system: string, tools: ToolSchema[
|
|
|
38
38
|
export interface RunAgentOptions {
|
|
39
39
|
/** Emitted at each loop step for callers that want visibility (logging, UI, tests). Default: no-op. */
|
|
40
40
|
onEvent?: (event: string, detail: unknown) => void;
|
|
41
|
+
/** The ActAuth tenant this call runs as — feeds the Gate's scope, so it
|
|
42
|
+
* governs which rules apply. runAgent() never sees a request, so it
|
|
43
|
+
* can't call AgentConfig.tenantFor itself; adapters/http.ts resolves it
|
|
44
|
+
* per request and passes the result here. Default `'default'`, same as
|
|
45
|
+
* omitting it — every standalone/CLI caller (which never has a request
|
|
46
|
+
* to resolve tenantFor from) gets that default automatically. */
|
|
47
|
+
tenant?: string;
|
|
41
48
|
}
|
|
42
49
|
export interface RunAgentResult {
|
|
43
50
|
/** Final assistant text for this turn. */
|
package/dist/run-agent.js
CHANGED
|
@@ -3,11 +3,27 @@
|
|
|
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';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
6
10
|
import { Gate, RuleSet, ConsoleApprover } from 'actauth';
|
|
7
11
|
import { SkillGarden } from 'skillgarden';
|
|
8
12
|
import { ContextClipper } from 'contextclip';
|
|
9
13
|
import { ToolLane } from 'toollane';
|
|
10
14
|
import { Reflow } from 'reflowkit';
|
|
15
|
+
// Resolved relative to *this file's own location* (via import.meta.url),
|
|
16
|
+
// not process.cwd() — the same reasoning agent-registry.ts's own
|
|
17
|
+
// agentsDir uses. In dev (tsx), this file and agents/ are both source
|
|
18
|
+
// .ts, side by side. In the built dist/, this file is dist/run-agent.js
|
|
19
|
+
// and its sibling agents/ is dist/agents/**/*.js (tsc-compiled) — cwd is
|
|
20
|
+
// irrelevant to either case, only "next to this module" is reliably
|
|
21
|
+
// right in both. skillsDirs/rules don't need this: actauth.yml/SKILL.md
|
|
22
|
+
// are plain data copied verbatim into the image (see Dockerfile), so
|
|
23
|
+
// they exist at the same cwd-relative path either way; a tools/index.ts
|
|
24
|
+
// is real code that only exists compiled, at a different relative
|
|
25
|
+
// location, once dist/ is what's actually running.
|
|
26
|
+
const agentsRootDir = join(dirname(fileURLToPath(import.meta.url)), 'agents');
|
|
11
27
|
/** ContextClip only ever needs a flat string per message to estimate
|
|
12
28
|
* token usage — it doesn't need to understand tool_use/tool_result
|
|
13
29
|
* structure, so this is a one-way, read-only projection, never something
|
|
@@ -29,20 +45,125 @@ function flattenForContextClip(message) {
|
|
|
29
45
|
.join('\n');
|
|
30
46
|
return { role: message.role, content: text };
|
|
31
47
|
}
|
|
48
|
+
/** Parses a loopengine actauth.yml — same shape as examples/actauth.yml
|
|
49
|
+
* in the actauth package, except each rule's `scope` only needs
|
|
50
|
+
* tenant/environment (a wildcard/wildcard, wildcard/staging, or
|
|
51
|
+
* acme-corp/production pair, say) — the agent segment is appended
|
|
52
|
+
* automatically from AgentConfig.name, then handed to actauth's own
|
|
53
|
+
* RuleSet.fromRaw (not fromYamlFile, which
|
|
54
|
+
* expects the full 3-segment scope already baked into the file). A
|
|
55
|
+
* per-agent actauth.yml (this repo's own convention: one file, one
|
|
56
|
+
* agent, path derived from the agent's own name — see
|
|
57
|
+
* agents/customer-service/actauth.yml) can only ever describe rules for
|
|
58
|
+
* that one agent anyway, so repeating its name in every single rule is
|
|
59
|
+
* exactly the kind of boilerplate skillsDirs/rules/tools' own defaults
|
|
60
|
+
* already avoid elsewhere in this file. */
|
|
61
|
+
function loadYamlRules(path, agentName) {
|
|
62
|
+
const raw = (parseYaml(readFileSync(path, 'utf8')) ?? {});
|
|
63
|
+
// A rule already ending in /<agentName> — the old, full 3-segment habit,
|
|
64
|
+
// or examples/actauth.yml copied verbatim — is left alone rather than
|
|
65
|
+
// getting a second, invalid segment appended; only the new 2-segment
|
|
66
|
+
// (tenant/environment) form actually needs the suffix.
|
|
67
|
+
const rules = (raw.rules ?? []).map((r) => ({
|
|
68
|
+
...r,
|
|
69
|
+
scope: r.scope.endsWith(`/${agentName}`) ? r.scope : `${r.scope}/${agentName}`,
|
|
70
|
+
}));
|
|
71
|
+
return RuleSet.fromRaw({ default_decision: raw.default_decision, rules });
|
|
72
|
+
}
|
|
73
|
+
/** Resolves AgentConfig.rules the same way skillsDirs resolves its own
|
|
74
|
+
* default: an inline array is used as-is (full 3-segment scopePattern,
|
|
75
|
+
* unchanged — see loadYamlRules for why only the YAML form gets the
|
|
76
|
+
* agent segment auto-appended); omitted entirely defaults to
|
|
77
|
+
* `agents/<name>/actauth.yml`, this repo's own folder-form convention.
|
|
78
|
+
* Unlike skillsDirs, a missing *default* file falls back to an empty
|
|
79
|
+
* ruleset rather than propagating the raw ENOENT — permissions failing
|
|
80
|
+
* shouldn't crash the whole call. That fallback defaults to `'deny'`, not
|
|
81
|
+
* the inline-array form's `'ask'`: no `actauth.yml` at all means this
|
|
82
|
+
* agent's permission story was never actually written, which is a
|
|
83
|
+
* stricter unknown than "written, but this one tool has no rule" — 'ask'
|
|
84
|
+
* still runs an Approver that could auto-approve, while 'deny' refuses
|
|
85
|
+
* outright until real rules exist. `defaultDecision`, if set, still wins
|
|
86
|
+
* over that. An *explicitly* given path that doesn't exist is a real
|
|
87
|
+
* configuration bug, though, and still throws — only the silent, implicit
|
|
88
|
+
* default gets this forgiveness. */
|
|
89
|
+
function loadRules(config) {
|
|
90
|
+
if (Array.isArray(config.rules))
|
|
91
|
+
return new RuleSet(config.rules, config.defaultDecision ?? 'ask');
|
|
92
|
+
const usingDefaultPath = config.rules === undefined;
|
|
93
|
+
const path = config.rules ?? `agents/${config.name}/actauth.yml`;
|
|
94
|
+
try {
|
|
95
|
+
return loadYamlRules(path, config.name);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (usingDefaultPath && err.code === 'ENOENT') {
|
|
99
|
+
return new RuleSet([], config.defaultDecision ?? 'deny');
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Resolves AgentConfig.tools the same way loadRules resolves its own
|
|
105
|
+
* default: an explicit array (including `[]`) is used as-is; omitted
|
|
106
|
+
* entirely defaults to importing `agents/<name>/tools/index.{ts,js}`
|
|
107
|
+
* (this repo's own aggregation-file convention — see
|
|
108
|
+
* `agents/customer-service/tools/index.ts`) and using its exported
|
|
109
|
+
* `tools`. A missing file there is not an error — a flat-file agent with
|
|
110
|
+
* no tools folder at all (or one merging in dynamically-loaded tools, like
|
|
111
|
+
* `agents/file-agent/index.ts`'s Composio ones, so it can't just rely on
|
|
112
|
+
* this default) just gets `[]`, same as if it had explicitly set that. A
|
|
113
|
+
* module that *does* exist but throws while importing, or doesn't export
|
|
114
|
+
* `tools` at all, is a real bug and is not swallowed. */
|
|
115
|
+
async function loadDefaultTools(config) {
|
|
116
|
+
const toolsDir = join(agentsRootDir, config.name, 'tools');
|
|
117
|
+
for (const indexName of ['index.ts', 'index.js']) {
|
|
118
|
+
const indexPath = join(toolsDir, indexName);
|
|
119
|
+
if (!existsSync(indexPath))
|
|
120
|
+
continue;
|
|
121
|
+
const mod = (await import(pathToFileURL(indexPath).href));
|
|
122
|
+
if (!Array.isArray(mod.tools)) {
|
|
123
|
+
throw new Error(`${indexPath} does not export a 'tools' array — every agents/<name>/tools/index file must.`);
|
|
124
|
+
}
|
|
125
|
+
return mod.tools;
|
|
126
|
+
}
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
32
129
|
export async function runAgent(config, modelCall, userMessage, history = [], options = {}) {
|
|
33
130
|
const log = options.onEvent ?? (() => { });
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
131
|
+
// tenant: resolved by the caller (adapters/http.ts calls
|
|
132
|
+
// AgentConfig.tenantFor with the request's headers/body; every other
|
|
133
|
+
// caller — adapters/cli.ts, a standalone script — has no request to
|
|
134
|
+
// resolve it from, so it never passes this option and gets 'default').
|
|
135
|
+
// environment: a deployment-wide setting,
|
|
136
|
+
// not a per-agent or per-request one — always LOOPENGINE_ENV, same
|
|
137
|
+
// everywhere this process runs, never something an AgentConfig defines.
|
|
138
|
+
const scope = { tenant: options.tenant ?? 'default', environment: process.env.LOOPENGINE_ENV ?? 'production', agent: config.name };
|
|
139
|
+
// Optional on AgentConfig — an agent can have zero tools. Explicit
|
|
140
|
+
// (including `[]`) is used as-is; omitted entirely defaults to
|
|
141
|
+
// importing agents/<name>/tools/index.{ts,js} — see loadDefaultTools's
|
|
142
|
+
// own doc comment for the full reasoning and the cases that can't use it.
|
|
143
|
+
const tools = config.tools ?? (await loadDefaultTools(config));
|
|
144
|
+
// Omitted entirely (undefined): default to this agent's own
|
|
145
|
+
// agents/<name>/skills — the folder-form convention every agent in
|
|
146
|
+
// this repo already follows (see agent-config.ts's skillsDirs doc
|
|
147
|
+
// comment). An explicit `[]` is a real opt-out, not "unset," and skips
|
|
148
|
+
// the default — SkillGarden's own missing-directory handling
|
|
149
|
+
// (discoverSkillFiles walks best-effort, no throw) is what makes
|
|
150
|
+
// pointing this at a folder that doesn't exist (a flat-file agent with
|
|
151
|
+
// no skills at all) harmless: an empty index, not an error.
|
|
152
|
+
const skillsDirs = config.skillsDirs ?? [`agents/${config.name}/skills`];
|
|
153
|
+
const skillGarden = skillsDirs.length ? new SkillGarden({ dirs: skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 }) : null;
|
|
38
154
|
const skillIndex = skillGarden?.buildIndex().included ?? [];
|
|
39
155
|
// Also the tail-preservation window recover() below relies on — kept as
|
|
40
156
|
// one named constant so the two can never drift out of sync.
|
|
41
157
|
const tailMessages = 4;
|
|
42
158
|
const contextClip = new ContextClipper({ budgetTokens: config.contextBudgetTokens ?? 8000, tailMessages });
|
|
43
|
-
const rules =
|
|
159
|
+
const rules = loadRules(config);
|
|
44
160
|
const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
|
|
45
|
-
|
|
161
|
+
// No explicit isSafeTool: fall back to each called tool's own `safe`
|
|
162
|
+
// flag (looked up by name) rather than defaulting every tool to unsafe
|
|
163
|
+
// outright — see ToolDefinition.safe's own doc comment for why this is
|
|
164
|
+
// the less powerful of the two (name-only, no per-call nuance).
|
|
165
|
+
const isSafeTool = config.isSafeTool ?? ((call) => tools.some((t) => t.name === call.name && t.safe === true));
|
|
166
|
+
const toolLane = new ToolLane({ isSafe: isSafeTool });
|
|
46
167
|
let messages = [...history, { role: 'user', content: userMessage }];
|
|
47
168
|
// Tracked in parallel with `messages`, but never rewritten wholesale by
|
|
48
169
|
// recovery the way `messages` is — this is the actual, authoritative
|
|
@@ -93,8 +214,6 @@ export async function runAgent(config, modelCall, userMessage, history = [], opt
|
|
|
93
214
|
return recovered;
|
|
94
215
|
},
|
|
95
216
|
});
|
|
96
|
-
// Optional on AgentConfig — an agent can have zero tools.
|
|
97
|
-
const tools = config.tools ?? [];
|
|
98
217
|
const toolsByName = new Map(tools.map((t) => [t.name, t]));
|
|
99
218
|
const toolSchemas = tools.map(({ name, description, input_schema }) => ({
|
|
100
219
|
name,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"dist/session-store.d.ts",
|
|
23
23
|
"dist/vector-index.js",
|
|
24
24
|
"dist/vector-index.d.ts",
|
|
25
|
-
"dist/anthropic-model-call.js",
|
|
26
|
-
"dist/anthropic-model-call.d.ts",
|
|
27
|
-
"dist/openai-model-call.js",
|
|
28
|
-
"dist/openai-model-call.d.ts",
|
|
29
|
-
"dist/deepseek-model-call.js",
|
|
30
|
-
"dist/deepseek-model-call.d.ts",
|
|
25
|
+
"dist/model-calls/anthropic-model-call.js",
|
|
26
|
+
"dist/model-calls/anthropic-model-call.d.ts",
|
|
27
|
+
"dist/model-calls/openai-model-call.js",
|
|
28
|
+
"dist/model-calls/openai-model-call.d.ts",
|
|
29
|
+
"dist/model-calls/deepseek-model-call.js",
|
|
30
|
+
"dist/model-calls/deepseek-model-call.d.ts",
|
|
31
31
|
"dist/discover-agents.js",
|
|
32
32
|
"dist/discover-agents.d.ts",
|
|
33
33
|
"LICENSE"
|
|
@@ -47,8 +47,9 @@
|
|
|
47
47
|
"openai": "^7.4.0",
|
|
48
48
|
"reflowkit": "^0.0.1",
|
|
49
49
|
"sessionknit": "^0.0.2",
|
|
50
|
-
"skillgarden": "^0.0.
|
|
51
|
-
"toollane": "^0.0.1"
|
|
50
|
+
"skillgarden": "^0.0.5",
|
|
51
|
+
"toollane": "^0.0.1",
|
|
52
|
+
"yaml": "^2.6.0"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@types/node": "^26.2.0",
|
|
File without changes
|
|
File without changes
|