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.
@@ -1,5 +1,36 @@
1
- import type { Rule, Decision, Scope, Approver } from 'actauth';
1
+ import type { Rule, Decision, Approver } from 'actauth';
2
2
  import type { SafetyClassifier } from 'toollane';
3
+ /** Declares which real ModelCall an agent module wants built for it, so
4
+ * the module doesn't have to export its own `createModelCall` —
5
+ * `discoverAgents` synthesizes one instead (see `discover-agents.ts`),
6
+ * lazily and memoized, the same pattern
7
+ * `agents/customer-service/index.ts`'s own hand-written `createModelCall`
8
+ * used before this existed. Each provider's own `createXModelCall`
9
+ * factory (`model-calls/*.ts`) still exists and still works standalone —
10
+ * this is a convenience for the common case, not a replacement; still
11
+ * export your own `createModelCall` for anything this can't express (a
12
+ * canned/simulated `ModelCall` for a demo, a custom SDK client, a
13
+ * provider this doesn't list). `model` is required for `openai`/
14
+ * `deepseek` (no safe hardcoded default — a flagship model name changes
15
+ * too often to bake one in) but optional for `anthropic` (defaults to
16
+ * `'claude-sonnet-5'`), mirroring each factory's own options exactly. */
17
+ export type AgentModelConfig = {
18
+ provider: 'anthropic';
19
+ model?: string;
20
+ apiKey?: string;
21
+ maxTokens?: number;
22
+ } | {
23
+ provider: 'openai';
24
+ model: string;
25
+ apiKey?: string;
26
+ maxTokens?: number;
27
+ } | {
28
+ provider: 'deepseek';
29
+ model: string;
30
+ apiKey?: string;
31
+ maxTokens?: number;
32
+ baseURL?: string;
33
+ };
3
34
  export interface ToolSchema {
4
35
  name: string;
5
36
  description: string;
@@ -8,21 +39,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
- /** Hand-written tools. Default []. */
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, e.g. { scopePattern: 'default/production/customer-service', tool: 'issue_refund', decision: 'ask' } */
19
- rules: Rule[];
20
- /** Decision when no rule matches. Default 'ask' — new tools are opt-in, not silently allowed. */
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
- scope?: Partial<Omit<Scope, 'agent'>>;
25
- /** SKILL.md directories this agent can discover and invoke. Omit if the agent has no skills. */
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. Default: none (every tool runs solo). */
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`'s direct files only (not subdirectories — put shared,
9
- * non-agent code in a subdirectory to keep it out of discovery, no
10
- * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
11
- * expected to export `config` and `createModelCall` — the same shape
12
- * every agents/*.ts file in this repo's own reference app already
13
- * exports. Throws, rather than skipping, on a file that doesn't match
14
- * that shape or a duplicate AgentConfig.name — both are almost always a
15
- * bug in the agent file, not something to silently ignore. */
8
+ /** 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>>;
@@ -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 isAgentModule(mod) {
18
- return (typeof mod === 'object' &&
19
- mod !== null &&
20
- 'config' in mod &&
21
- 'createModelCall' in mod &&
22
- typeof mod.createModelCall === 'function');
17
+ function isRawAgentModule(mod) {
18
+ return typeof mod === 'object' && mod !== null && 'config' in mod;
23
19
  }
24
- /** Scans `dir`'s direct files only (not subdirectories — put shared,
25
- * non-agent code in a subdirectory to keep it out of discovery, no
26
- * separate exclude-list mechanism needed) for `.ts`/`.js` modules, each
27
- * expected to export `config` and `createModelCall` — the same shape
28
- * every agents/*.ts file in this repo's own reference app already
29
- * exports. Throws, rather than skipping, on a file that doesn't match
30
- * that shape or a duplicate AgentConfig.name — both are almost always a
31
- * bug in the agent file, not something to silently ignore. */
20
+ /** Builds a lazy, memoized createModelCall from AgentConfig.model, for a
21
+ * module that doesn't export its own — see that field's own doc comment.
22
+ * The provider's SDK module is imported here, eagerly, but that's cheap
23
+ * and safe with no API key at all: importing model-calls/*.ts only
24
+ * defines functions, it never constructs a client at module top level
25
+ * (see e.g. createAnthropicModelCall's own body) — so this can't fail
26
+ * discovery the way eagerly building the actual client would. The real
27
+ * client is only built the first time the returned function is actually
28
+ * called, and reused after that, the same "don't crash the whole server
29
+ * over one agent's missing API key, don't rebuild per call" reasoning
30
+ * agents/customer-service/index.ts's own hand-written createModelCall
31
+ * used before this existed. */
32
+ async function synthesizeCreateModelCall(model) {
33
+ let cached;
34
+ if (model.provider === 'anthropic') {
35
+ const { createAnthropicModelCall } = await import('./model-calls/anthropic-model-call.js');
36
+ return () => (cached ??= createAnthropicModelCall(model));
37
+ }
38
+ if (model.provider === 'openai') {
39
+ const { createOpenAIModelCall } = await import('./model-calls/openai-model-call.js');
40
+ return () => (cached ??= createOpenAIModelCall(model));
41
+ }
42
+ const { createDeepSeekModelCall } = await import('./model-calls/deepseek-model-call.js');
43
+ return () => (cached ??= createDeepSeekModelCall(model));
44
+ }
45
+ /** Resolves one directory entry to an agent module's path, or undefined if
46
+ * this entry isn't one — a `.ts`/`.js` file directly (the common case), or
47
+ * a subdirectory containing an `index.ts`/`index.js` (the same "a
48
+ * directory can be a module" convention Node's own `require()` resolution
49
+ * already uses — no new mental model to learn). A subdirectory with
50
+ * neither is ordinary supporting code living alongside an agent's own
51
+ * folder (a `tools.ts` split out of a large agent, say), not an agent
52
+ * itself, and is silently skipped rather than treated as an error. */
53
+ function resolveModulePath(dir, entry) {
54
+ if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
55
+ return { path: resolve(dir, entry.name), label: entry.name };
56
+ }
57
+ if (entry.isDirectory()) {
58
+ for (const indexName of ['index.ts', 'index.js']) {
59
+ const indexPath = join(dir, entry.name, indexName);
60
+ if (existsSync(indexPath))
61
+ return { path: resolve(indexPath), label: `${entry.name}/${indexName}` };
62
+ }
63
+ }
64
+ return undefined;
65
+ }
66
+ /** 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 files = readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts'));
35
- for (const file of files) {
36
- const modulePath = resolve(dir, file.name);
37
- const mod = await import(pathToFileURL(modulePath).href);
38
- if (!isAgentModule(mod)) {
39
- throw new Error(`${file.name} in ${dir} does not export both 'config' and 'createModelCall' — every agent module must.`);
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}' — ${file.name} in ${dir} isn't the first file to declare AgentConfig.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 './run-agent.js';
2
+ import type { ModelCall } from '../run-agent.js';
3
3
  export interface AnthropicModelCallOptions {
4
4
  /** Defaults to the ANTHROPIC_API_KEY env var, same as the SDK itself. */
5
5
  apiKey?: string;
@@ -1,9 +1,11 @@
1
- // The one real ModelCall implementation loopengine ships. Every example
2
- // agent (file-agent.ts, customer-service-agent.ts, rag-agent.ts) still
3
- // uses a canned, turn-counting fake so the whole loop is runnable with no
4
- // API key — swap that fake for createAnthropicModelCall(...) and nothing
5
- // in run-agent.ts, the adapters, or any AgentConfig changes. ModelCall is
6
- // the only seam this needed.
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 { ModelCall } from './run-agent.js';
2
+ import type { ModelCall } from '../run-agent.js';
3
3
  export interface DeepSeekModelCallOptions {
4
4
  /** Defaults to the DEEPSEEK_API_KEY env var. */
5
5
  apiKey?: string;
@@ -1,5 +1,5 @@
1
1
  import OpenAI from 'openai';
2
- import type { Message, ModelCall, ModelResponse } from './run-agent.js';
2
+ import type { Message, ModelCall, ModelResponse } from '../run-agent.js';
3
3
  type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
4
4
  export interface OpenAIModelCallOptions {
5
5
  /** Defaults to the OPENAI_API_KEY env var, same as the SDK itself. */
@@ -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
- const scope = { tenant: 'default', environment: 'production', ...config.scope, agent: config.name };
35
- const skillGarden = config.skillsDirs?.length
36
- ? new SkillGarden({ dirs: config.skillsDirs, indexBudgetTokens: config.skillIndexBudgetTokens ?? 200 })
37
- : null;
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 = new RuleSet(config.rules, config.defaultDecision ?? 'ask');
159
+ const rules = loadRules(config);
44
160
  const gate = new Gate(rules, config.approver ?? new ConsoleApprover());
45
- const toolLane = new ToolLane({ isSafe: config.isSafeTool ?? (() => false) });
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.10",
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.4",
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",