loopengine 0.1.61 → 0.1.62

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,3 +1,4 @@
1
+ import type { EnvScope } from '../core/agent-env.js';
1
2
  export declare class AbilityManifestError extends Error {
2
3
  }
3
4
  export declare class AbilityVersionError extends Error {
@@ -26,16 +27,21 @@ export interface AbilityEnvDecl {
26
27
  * env-admin.ts's own serializeEnvValue doc comment). Omit for anything
27
28
  * genuinely single-line. */
28
29
  multiline?: boolean;
29
- /** True when this setting is meaningfully different per agent (e.g.
30
- * which Slack channel to post to) rather than one real shared
31
- * credential every installed copy should agree on (e.g. an API key
32
- * for the one external service they all call). The Admin UI's
33
- * Environment tab then also offers a per-agent override — a second,
34
- * separate var named `<AGENT_NAME>_<this name>` (env-admin.ts's own
35
- * agentScopedEnvVarName) — that an ability's own tool code can check
36
- * first, falling back to this bare name when the override isn't set.
37
- * Omit (the default, false) for anything that's actually fine shared
38
- * across every agent that installs this ability. */
30
+ /** How this var is shared across the agents that install this
31
+ * ability (see core/agent-env.ts's EnvScope), read by tools through
32
+ * ToolContext.env:
33
+ * - `shared` (default): one project-wide value — e.g. an API key for
34
+ * the one external service every installed copy calls.
35
+ * - `overridable`: an agent may set its own value (agents/<name>/.env),
36
+ * falling back to the shared one — e.g. which chat to post to.
37
+ * - `agent`: every agent must set its own; never falls back — e.g. a
38
+ * bot token, where silently sharing one would make two agents speak
39
+ * as the same bot.
40
+ * The Admin UI's Environment tab shows a shared row, a per-agent row,
41
+ * or both, to match. */
42
+ scope?: EnvScope;
43
+ /** @deprecated Use `scope: 'overridable'` — still accepted, and means
44
+ * exactly that when `scope` is unset. */
39
45
  perAgent?: boolean;
40
46
  }
41
47
  /** `name`/`version` deliberately aren't declared here — they're read off
@@ -289,8 +289,29 @@ export interface ToolSchema {
289
289
  /** JSON schema sent to the model so it knows how to call the tool. */
290
290
  input_schema: Record<string, unknown>;
291
291
  }
292
+ /** An agent's view of its environment — see core/agent-env.ts's
293
+ * createAgentEnv for the exact lookup order (this agent's own
294
+ * agents/<name>/.env first, then the project-wide value, unless the
295
+ * declaring ability marked the var `scope: 'agent'`). */
296
+ export interface AgentEnv {
297
+ get(name: string): string | undefined;
298
+ /** Same as `get`, but throws a clear "not set for agent X" error
299
+ * instead of returning undefined. */
300
+ require(name: string): string;
301
+ }
302
+ /** Passed as `execute`'s second argument on every call runAgent (and a
303
+ * durable approval's resume) makes — so a tool shared across several
304
+ * agents, typically one an ability installed, can tell which agent it's
305
+ * running for and read that agent's own settings via `env`, instead of
306
+ * working it out from its own file path or a prefixed env var name. */
307
+ export interface ToolContext {
308
+ agentName: string;
309
+ tenant: string;
310
+ sessionId?: string;
311
+ env: AgentEnv;
312
+ }
292
313
  export interface ToolDefinition extends ToolSchema {
293
- execute: (input: Record<string, unknown>) => Promise<unknown>;
314
+ execute: (input: Record<string, unknown>, ctx: ToolContext) => Promise<unknown>;
294
315
  /** Safe to run in ToolLane's parallel lane alongside other safe calls —
295
316
  * true for a read-only tool with no side effects and no shared mutable
296
317
  * state (a lookup, a search), false/omitted for anything that mutates
@@ -0,0 +1,41 @@
1
+ import type { AgentConfig, AgentEnv } from '#core/agent-config.js';
2
+ /** How a declared ability env var (AbilityEnvDecl.scope) is shared
3
+ * across the agents that install the ability:
4
+ * - `shared`: one project-wide value, from the project .env only.
5
+ * - `overridable`: an agent may set its own value; falls back to the
6
+ * shared one when it doesn't.
7
+ * - `agent`: every agent must set its own; never falls back, so an agent
8
+ * that hasn't set it gets a clear error instead of silently borrowing
9
+ * another agent's value (e.g. a bot token, which decides *who* the
10
+ * agent speaks as). */
11
+ export type EnvScope = 'shared' | 'overridable' | 'agent';
12
+ /** `perAgent: true` predates `scope` and means exactly `overridable` —
13
+ * still accepted so abilities published before `scope` existed keep
14
+ * working unchanged. */
15
+ export declare function envScopeOf(decl: {
16
+ scope?: EnvScope;
17
+ perAgent?: boolean;
18
+ }): EnvScope;
19
+ /** The pre-`scope` per-agent override name — `<AGENT_NAME>_<varName>`,
20
+ * `agentName` upper-cased with anything outside `[A-Z0-9_]` turned into
21
+ * `_`, prefixed with one more `_` if that would start with a digit.
22
+ * Still read as a fallback by createAgentEnv (an agent's own .env wins
23
+ * over it) so a project already configured this way keeps working. */
24
+ export declare function agentScopedEnvVarName(agentName: string, varName: string): string;
25
+ export declare function agentEnvFilePath(dir: string): string;
26
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
27
+ * web/env-admin.ts's serializeEnvValue writes reads back exactly the way
28
+ * it does from the project .env. Read fresh on every call, not cached —
29
+ * it's a few lines, read at most a handful of times per tool call, and
30
+ * this way an edit (from the Admin UI or by hand) is seen immediately. */
31
+ export declare function readAgentEnvFile(dir: string): Record<string, string>;
32
+ export declare function setAgentDir(config: AgentConfig, dir: string): void;
33
+ export declare function agentDirFor(config: AgentConfig): string;
34
+ /** Lookup order for `get(name)`:
35
+ * 1. `name` in this agent's own `<dir>/.env`
36
+ * 2. the legacy prefixed project var (agentScopedEnvVarName)
37
+ * 3. `process.env[name]` — skipped when an installed ability declared
38
+ * `name` with `scope: 'agent'`
39
+ * An undeclared name (a hand-written tool's own var) goes through the
40
+ * same order, so it can be overridden per agent too. */
41
+ export declare function createAgentEnv(agentName: string, dir: string): AgentEnv;
@@ -0,0 +1,100 @@
1
+ // Per-agent environment for tools (ToolContext.env — see
2
+ // core/agent-config.ts). One project shares a single .env/process.env,
3
+ // but several agents can install the same ability and each need their
4
+ // own value for some of its settings (a Telegram bot token, a default
5
+ // chat ID). Rather than every ability's tool code deriving a prefixed
6
+ // name (`<AGENT>_<VAR>`) itself, the runtime resolves it here, from an
7
+ // agent-private agents/<name>/.env file layered over the project's own.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { parseEnv } from 'node:util';
11
+ import { agentDir } from './gateway-tools.js';
12
+ /** `perAgent: true` predates `scope` and means exactly `overridable` —
13
+ * still accepted so abilities published before `scope` existed keep
14
+ * working unchanged. */
15
+ export function envScopeOf(decl) {
16
+ return decl.scope ?? (decl.perAgent ? 'overridable' : 'shared');
17
+ }
18
+ /** The pre-`scope` per-agent override name — `<AGENT_NAME>_<varName>`,
19
+ * `agentName` upper-cased with anything outside `[A-Z0-9_]` turned into
20
+ * `_`, prefixed with one more `_` if that would start with a digit.
21
+ * Still read as a fallback by createAgentEnv (an agent's own .env wins
22
+ * over it) so a project already configured this way keeps working. */
23
+ export function agentScopedEnvVarName(agentName, varName) {
24
+ const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
25
+ return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
26
+ }
27
+ export function agentEnvFilePath(dir) {
28
+ return join(dir, '.env');
29
+ }
30
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
31
+ * web/env-admin.ts's serializeEnvValue writes reads back exactly the way
32
+ * it does from the project .env. Read fresh on every call, not cached —
33
+ * it's a few lines, read at most a handful of times per tool call, and
34
+ * this way an edit (from the Admin UI or by hand) is seen immediately. */
35
+ export function readAgentEnvFile(dir) {
36
+ const path = agentEnvFilePath(dir);
37
+ if (!existsSync(path))
38
+ return {};
39
+ return parseEnv(readFileSync(path, 'utf8'));
40
+ }
41
+ /** Every env var name an ability installed in `dir` declared as
42
+ * `scope: 'agent'` — the ones createAgentEnv must never fall back to the
43
+ * project-wide value for. */
44
+ function agentOnlyVarNames(dir) {
45
+ const path = join(dir, '.loopengine-abilities.json');
46
+ const names = new Set();
47
+ if (!existsSync(path))
48
+ return names;
49
+ const provenance = JSON.parse(readFileSync(path, 'utf8'));
50
+ for (const record of Object.values(provenance)) {
51
+ for (const decl of record.env ?? []) {
52
+ if (envScopeOf(decl) === 'agent')
53
+ names.add(decl.name);
54
+ }
55
+ }
56
+ return names;
57
+ }
58
+ // A subagent's config only carries its bare name, not the nested
59
+ // agents/<parent>/subagents/<child>/ folder it was loaded from (see
60
+ // run-agent.ts's resolveSubagentConfig) — that function records the
61
+ // real folder here so its tools read *its* .env, not a same-named
62
+ // top-level agent's. Keyed by object identity: agentAsTool hands the
63
+ // exact same config object on to runAgent.
64
+ const agentDirs = new WeakMap();
65
+ export function setAgentDir(config, dir) {
66
+ agentDirs.set(config, dir);
67
+ }
68
+ export function agentDirFor(config) {
69
+ return agentDirs.get(config) ?? agentDir(config.name);
70
+ }
71
+ /** Lookup order for `get(name)`:
72
+ * 1. `name` in this agent's own `<dir>/.env`
73
+ * 2. the legacy prefixed project var (agentScopedEnvVarName)
74
+ * 3. `process.env[name]` — skipped when an installed ability declared
75
+ * `name` with `scope: 'agent'`
76
+ * An undeclared name (a hand-written tool's own var) goes through the
77
+ * same order, so it can be overridden per agent too. */
78
+ export function createAgentEnv(agentName, dir) {
79
+ function get(name) {
80
+ const own = readAgentEnvFile(dir)[name];
81
+ if (own !== undefined)
82
+ return own;
83
+ const legacy = process.env[agentScopedEnvVarName(agentName, name)];
84
+ if (legacy !== undefined)
85
+ return legacy;
86
+ if (agentOnlyVarNames(dir).has(name))
87
+ return undefined;
88
+ return process.env[name];
89
+ }
90
+ return {
91
+ get,
92
+ require(name) {
93
+ const value = get(name);
94
+ if (value === undefined) {
95
+ throw new Error(`${name} is not set for agent '${agentName}' — set it in the Admin UI's Environment tab, or in ${agentEnvFilePath(dir)}.`);
96
+ }
97
+ return value;
98
+ },
99
+ };
100
+ }
@@ -35,7 +35,11 @@ export type GatewayToolEntry = ComposioGatewayToolEntry;
35
35
  * module's own registry read/writes do, rather than re-deriving it (e.g.
36
36
  * from process.cwd()) and risking the two drifting apart. */
37
37
  export declare function agentDir(agentName: string): string;
38
- /** agentDir's own inverse, for a tool file that needs to know which
38
+ /** @deprecated A tool can read `ctx.agentName` (ToolContext, passed as
39
+ * execute's second argument) instead — which, unlike this, is also
40
+ * right for a subagent's tools.
41
+ *
42
+ * agentDir's own inverse, for a tool file that needs to know which
39
43
  * agent it was installed under without an env var to tell it — an
40
44
  * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
45
  * add-ability) calls this with its own `import.meta.url`, and gets
@@ -35,7 +35,11 @@ function gatewayToolsPath(dir) {
35
35
  export function agentDir(agentName) {
36
36
  return join(agentsRootDir, agentName);
37
37
  }
38
- /** agentDir's own inverse, for a tool file that needs to know which
38
+ /** @deprecated A tool can read `ctx.agentName` (ToolContext, passed as
39
+ * execute's second argument) instead — which, unlike this, is also
40
+ * right for a subagent's tools.
41
+ *
42
+ * agentDir's own inverse, for a tool file that needs to know which
39
43
  * agent it was installed under without an env var to tell it — an
40
44
  * ability's own tool file (copied to agents/<name>/tools/<file>.ts by
41
45
  * add-ability) calls this with its own `import.meta.url`, and gets
@@ -14,6 +14,7 @@ import { Compactor } from './compaction.js';
14
14
  import { ToolLane } from './toollane.js';
15
15
  import { Recovery } from './recovery.js';
16
16
  import { collectKnownUrls, correctToolUseInput, recordKnownUrlsFromResult } from './known-urls.js';
17
+ import { agentDirFor, createAgentEnv, setAgentDir } from './agent-env.js';
17
18
  import { loadAgentModule } from './discover-agents.js';
18
19
  import { agentAsTool } from './agent-as-tool.js';
19
20
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
@@ -223,6 +224,7 @@ async function resolveSubagentConfig(config, dir) {
223
224
  if (resolved.skillsDirs === undefined) {
224
225
  resolved.skillsDirs = [join(dir, 'skills')];
225
226
  }
227
+ setAgentDir(resolved, dir);
226
228
  return resolved;
227
229
  }
228
230
  /** Auto-loads agents/<name>/subagents/<child>/index.{ts,js} — each one a
@@ -443,6 +445,12 @@ async function buildTurnContext(config, modelCall, options) {
443
445
  askUserTool,
444
446
  questionHandler,
445
447
  httpNotifier,
448
+ toolContext: {
449
+ agentName: config.name,
450
+ tenant: scope.tenant,
451
+ sessionId: options.sessionId,
452
+ env: createAgentEnv(config.name, agentDirFor(config)),
453
+ },
446
454
  };
447
455
  }
448
456
  /** The turn's actual ReAct loop — shared by runAgent (a fresh turn, its
@@ -454,7 +462,7 @@ async function buildTurnContext(config, modelCall, options) {
454
462
  * fresh in each case) but mutated here via pushMessage, same as the
455
463
  * single function this was split out of always did. */
456
464
  async function runLoop(ctx, messages, newMessages, starterMessage) {
457
- const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler } = ctx;
465
+ const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler, toolContext } = ctx;
458
466
  function pushMessage(message) {
459
467
  messages.push(message);
460
468
  newMessages.push(message);
@@ -636,7 +644,7 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
636
644
  approvedCalls.push({
637
645
  id: block.id,
638
646
  name: block.name,
639
- execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
647
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}, toolContext),
640
648
  });
641
649
  continue;
642
650
  }
@@ -657,7 +665,7 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
657
665
  approvedCalls.push({
658
666
  id: block.id,
659
667
  name: block.name,
660
- execute: () => toolsByName.get(block.name).execute(block.input ?? {}),
668
+ execute: () => toolsByName.get(block.name).execute(block.input ?? {}, toolContext),
661
669
  });
662
670
  }
663
671
  else if (decision.decision === 'pending') {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,8 @@ export { runAgent } from '#core/run-agent.js';
2
2
  export { storageSigners } from '#core/storage-signers/index.js';
3
3
  export type { SignRequest, StorageSigner } from '#core/storage-signers/types.js';
4
4
  export type { Message, ModelCall, ModelContentBlock, ModelResponse, RunAgentOptions, RunAgentResult, } from '#core/run-agent.js';
5
- export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition } from '#core/agent-config.js';
5
+ export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition, ToolContext, AgentEnv } from '#core/agent-config.js';
6
+ export { createAgentEnv, agentDirFor, envScopeOf, type EnvScope } from '#core/agent-env.js';
6
7
  export type * from './core/loop-events.js';
7
8
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
8
9
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
@@ -55,7 +56,7 @@ export { globalConfigPageHtml } from './web/global-config-page.js';
55
56
  export { playgroundHtml } from './web/playground.js';
56
57
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
57
58
  export type { SkillContent } from './web/skills-admin.js';
58
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
59
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgentEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
59
60
  export type { DeclaredEnvVar } from './web/env-admin.js';
60
61
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
61
62
  export type { ActauthRuleInput, ActauthConfigView } from './web/actauth-admin.js';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // "Using loopengine as a library" for the boundary and why.
6
6
  export { runAgent } from '#core/run-agent.js';
7
7
  export { storageSigners } from '#core/storage-signers/index.js';
8
+ export { createAgentEnv, agentDirFor, envScopeOf } from '#core/agent-env.js';
8
9
  // The framework-agnostic browser client for adapters/http.ts's own wire
9
10
  // protocol (see client.ts's own header comment) — this, not a deep
10
11
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -50,7 +51,7 @@ export { agentsListPageHtml } from './web/agents-list-page.js';
50
51
  export { globalConfigPageHtml } from './web/global-config-page.js';
51
52
  export { playgroundHtml } from './web/playground.js';
52
53
  export { readSkill, writeSkill, deleteSkill, SkillInvalidIdError, SkillNotFoundError } from './web/skills-admin.js';
53
- export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
54
+ export { listDeclaredEnvVars, setEnvVar, unsetEnvVar, setAgentEnvVar, unsetAgentEnvVar, agentScopedEnvVarName, EnvVarNameError } from './web/env-admin.js';
54
55
  export { readActauthConfig, addActauthRule, updateActauthRule, removeActauthRule, setDefaultDecision, ActauthRuleExistsError, ActauthRuleNotFoundError, } from './web/actauth-admin.js';
55
56
  export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
56
57
  export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
@@ -1177,11 +1177,23 @@ export const agentsConfigPageHtml = `<!doctype html>
1177
1177
  // of a bare "set", since the whole point of looking here is usually
1178
1178
  // to check *what* something is currently configured to, not just
1179
1179
  // whether it has a value.
1180
+ // An agent row (v.slot === 'agent' — this agent's own value, see
1181
+ // env-admin.ts's DeclaredEnvVar.slot) for an overridable var isn't a
1182
+ // problem when unset: it just falls back to the shared row. One for
1183
+ // an agent-scoped var has no fallback, so unset really is missing.
1180
1184
  var statusHtml = !v.set
1181
- ? '<span class="error">not set</span>'
1185
+ ? (v.slot === 'agent' && v.scope === 'overridable'
1186
+ ? '<span class="hint">not set &mdash; uses shared value</span>'
1187
+ : '<span class="error">not set</span>')
1182
1188
  : v.secret
1183
1189
  ? '<span class="hint">set</span>'
1184
1190
  : '<code>' + escapeHtml(v.value || '') + '</code>';
1191
+ if (v.set && v.source === 'legacy') {
1192
+ statusHtml += ' <span class="hint" title="Read from the older prefixed project variable. Saving a value here moves it into this agent&#39;s own .env.">(from ' + escapeHtml(v.legacyName || '') + ')</span>';
1193
+ }
1194
+ var slotNote = v.slot === 'agent'
1195
+ ? ' <span class="hint">' + (v.scope === 'agent' ? 'this agent only' : 'this agent&#39;s override') + '</span>'
1196
+ : (v.scope === 'overridable' ? ' <span class="hint">shared</span>' : '');
1185
1197
  // A var with a closed set of valid values (AbilityEnvDecl.options,
1186
1198
  // e.g. a provider switch) gets a <select> instead of a free-text
1187
1199
  // field, so there's nothing to mistype — the same "value" form field
@@ -1208,13 +1220,13 @@ export const agentsConfigPageHtml = `<!doctype html>
1208
1220
  // them, on a freshly installed ability — see renderEnvConfigHtml's
1209
1221
  // own comment on why "not set" isn't itself treated as a problem).
1210
1222
  var removeHtml = v.set
1211
- ? ' <button type="button" class="env-var-remove" data-name="' + escapeHtml(v.name) + '">Remove</button>'
1223
+ ? ' <button type="button" class="env-var-remove" data-name="' + escapeHtml(v.name) + '" data-slot="' + escapeHtml(v.slot) + '">Remove</button>'
1212
1224
  : '';
1213
1225
  return '<tr>' +
1214
- '<td><code>' + escapeHtml(v.name) + '</code>' + sharedNote + '</td>' +
1226
+ '<td><code>' + escapeHtml(v.name) + '</code>' + slotNote + sharedNote + '</td>' +
1215
1227
  '<td style="max-width:320px">' + escapeHtml(v.description || '') + '</td>' +
1216
1228
  '<td>' + statusHtml + '</td>' +
1217
- '<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '">' +
1229
+ '<td><form class="add-source env-var-form" data-name="' + escapeHtml(v.name) + '" data-slot="' + escapeHtml(v.slot) + '">' +
1218
1230
  fieldHtml +
1219
1231
  '<button type="submit">Save</button>' +
1220
1232
  '</form>' + removeHtml + '</td>' +
@@ -1280,10 +1292,11 @@ export const agentsConfigPageHtml = `<!doctype html>
1280
1292
  ev.preventDefault();
1281
1293
  var form = ev.currentTarget;
1282
1294
  var varName = form.getAttribute('data-name');
1295
+ var slot = form.getAttribute('data-slot');
1283
1296
  var value = new FormData(form).get('value');
1284
1297
  var submitBtn = form.querySelector('button[type="submit"]');
1285
1298
  submitBtn.disabled = true;
1286
- fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName), {
1299
+ fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName) + '?slot=' + encodeURIComponent(slot), {
1287
1300
  method: 'PUT',
1288
1301
  headers: { 'content-type': 'application/json' },
1289
1302
  body: JSON.stringify({ value: value }),
@@ -1304,13 +1317,15 @@ export const agentsConfigPageHtml = `<!doctype html>
1304
1317
  removeButtons[j].addEventListener('click', function (ev) {
1305
1318
  var btn = ev.currentTarget;
1306
1319
  var varName = btn.getAttribute('data-name');
1320
+ var slot = btn.getAttribute('data-slot');
1307
1321
  // A destructive action (clearing a configured value, possibly a
1308
1322
  // secret nothing else has a copy of) gets the one confirm() this
1309
1323
  // whole tab uses — every other action here is a plain upsert,
1310
1324
  // nothing else to lose by clicking it again.
1311
- if (!confirm('Remove ' + varName + '? Any tool/ability relying on it falls back to its own default (if it has one) or starts failing until it is set again.')) return;
1325
+ var what = slot === 'agent' ? 'the agent-specific value of ' + varName : varName;
1326
+ if (!confirm('Remove ' + what + '? Any tool/ability relying on it falls back to its own default (if it has one) or starts failing until it is set again.')) return;
1312
1327
  btn.disabled = true;
1313
- fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName), { method: 'DELETE' })
1328
+ fetch('/agents/' + encodeURIComponent(name) + '/env/' + encodeURIComponent(varName) + '?slot=' + encodeURIComponent(slot), { method: 'DELETE' })
1314
1329
  .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
1315
1330
  .then(function (result) {
1316
1331
  if (!result.ok) throw new Error(result.body.error || 'request failed');
@@ -1,8 +1,23 @@
1
+ import { agentScopedEnvVarName, type EnvScope } from '../core/agent-env.js';
2
+ export { agentScopedEnvVarName };
1
3
  export declare class EnvVarNameError extends Error {
2
4
  }
3
5
  export interface DeclaredEnvVar {
4
6
  name: string;
5
7
  description?: string;
8
+ /** Which value this row reads and writes: `shared` = the project
9
+ * .env, `agent` = this agent's own agents/<name>/.env. A var declared
10
+ * `overridable` gets one row of each; `agent` only the agent row. */
11
+ slot: 'shared' | 'agent';
12
+ /** The scope the declaring ability gave this var (see
13
+ * core/agent-env.ts's EnvScope) — the strictest one when more than
14
+ * one ability declares it. */
15
+ scope: EnvScope;
16
+ /** Agent rows only, when set: `agent` = from agents/<name>/.env,
17
+ * `legacy` = from the older prefixed project var named `legacyName`
18
+ * (see agentScopedEnvVarName), still honored until replaced. */
19
+ source?: 'agent' | 'legacy';
20
+ legacyName?: string;
6
21
  secret: boolean;
7
22
  /** Every ability that declares this name, in install order — usually
8
23
  * one, but see listDeclaredEnvVars's own doc comment for why this is
@@ -25,8 +40,10 @@ export interface DeclaredEnvVar {
25
40
  multiline?: boolean;
26
41
  }
27
42
  /** Every env var any ability installed for `agentName` declared it
28
- * needs, merged by name. There's one .env per *project*, not per agent
29
- * or per ability, so this can't actually resolve a genuine cross-ability
43
+ * needs, merged by name — one row per slot it can be set in (see
44
+ * DeclaredEnvVar.slot): a `shared` row for the project .env, an `agent`
45
+ * row for this agent's own .env when the var's scope allows one. There's
46
+ * one shared .env per *project*, not per ability, so this can't actually resolve a genuine cross-ability
30
47
  * name collision (two abilities declaring, say, "API_KEY" for two
31
48
  * unrelated services still both read whichever single value ends up
32
49
  * set — there is no per-ability slot to give them) — unlike a tool or
@@ -45,27 +62,15 @@ export interface DeclaredEnvVar {
45
62
  * safer default, since treating a real secret as non-secret because a
46
63
  * different ability's own declaration happened to be checked first
47
64
  * would be the one direction genuinely worth avoiding. `set` is read
48
- * live off `process.env`, not cached, so it reflects whatever the last
49
- * `setEnvVar` call — or a plain restart picking up `.env` — actually
50
- * did. */
51
- /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
52
- * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
53
- * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
54
- * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
55
- * already requires. Guards against a result starting with a digit (a
56
- * from-digits agent name, or an agent name that's entirely punctuation)
57
- * the same way that pattern would otherwise reject — by prefixing one
58
- * more `_` — though in practice agent names are never actually bare
59
- * digits. */
60
- export declare function agentScopedEnvVarName(agentName: string, varName: string): string;
65
+ * live (process.env for a shared row, the agent's own .env file for an
66
+ * agent row), not cached. */
61
67
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
62
- /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
63
- * every other line (comments, blank lines, unrelated keys) — and
64
- * applies it to *this* running process immediately via `process.env`,
65
- * so a newly-installed ability's tools work without a restart. Callers
66
- * (the PUT route in adapters/http.ts) are responsible for refusing to
67
- * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
68
- * function itself has no notion of HTTP auth, it just writes. */
68
+ /** Upserts `NAME=VALUE` into the project's `.env` file and applies it to
69
+ * *this* running process immediately via `process.env`, so a
70
+ * newly-installed ability's tools work without a restart. Callers (the
71
+ * PUT route in adapters/http.ts) are responsible for refusing to call
72
+ * this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this function
73
+ * itself has no notion of HTTP auth, it just writes. */
69
74
  export declare function setEnvVar(name: string, value: string): void;
70
75
  /** Removes NAME= entirely from the project's .env file (not just
71
76
  * blanking its value — an empty string is itself a real, distinct
@@ -74,8 +79,17 @@ export declare function setEnvVar(name: string, value: string): void;
74
79
  * *this* running process's own process.env immediately, the same
75
80
  * "no restart needed" promise setEnvVar already makes. A no-op,
76
81
  * not an error, when the var was never set in .env to begin with —
77
- * same reasoning the DELETE route calling this treats a missing name
78
- * as success rather than a 404: the end state ("not set") is identical
79
- * either way, and the caller has no way to distinguish "I removed it"
80
- * from "it was already gone" that would actually matter to them. */
82
+ * the end state ("not set") is identical either way. */
81
83
  export declare function unsetEnvVar(name: string): void;
84
+ /** Upserts `NAME=VALUE` into `agentName`'s own agents/<name>/.env — read
85
+ * by that agent's tools through ToolContext.env (core/agent-env.ts),
86
+ * which reads the file fresh on every lookup, so no restart is needed.
87
+ * Deliberately never copied into process.env: that's shared by every
88
+ * agent in the process, which is exactly what a per-agent value must
89
+ * not be. Same auth responsibility for callers as setEnvVar. */
90
+ export declare function setAgentEnvVar(agentName: string, name: string, value: string): void;
91
+ /** Removes NAME= from `agentName`'s own .env — and the legacy prefixed
92
+ * project var for it too (see agentScopedEnvVarName), since createAgentEnv
93
+ * still falls back to that one, and "Remove" on this agent's row should
94
+ * leave the agent with no value of its own either way. */
95
+ export declare function unsetAgentEnvVar(agentName: string, name: string): void;
@@ -9,6 +9,8 @@
9
9
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { agentDir } from '../core/gateway-tools.js';
12
+ import { agentEnvFilePath, agentScopedEnvVarName, envScopeOf, readAgentEnvFile } from '../core/agent-env.js';
13
+ export { agentScopedEnvVarName };
12
14
  export class EnvVarNameError extends Error {
13
15
  }
14
16
  // Same shape web/http-tool-admin.ts's own ENV_VAR_PATTERN already
@@ -17,9 +19,12 @@ const ENV_VAR_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
17
19
  function provenancePath(agentName) {
18
20
  return join(agentDir(agentName), '.loopengine-abilities.json');
19
21
  }
22
+ const SCOPE_STRICTNESS = { shared: 0, overridable: 1, agent: 2 };
20
23
  /** Every env var any ability installed for `agentName` declared it
21
- * needs, merged by name. There's one .env per *project*, not per agent
22
- * or per ability, so this can't actually resolve a genuine cross-ability
24
+ * needs, merged by name — one row per slot it can be set in (see
25
+ * DeclaredEnvVar.slot): a `shared` row for the project .env, an `agent`
26
+ * row for this agent's own .env when the var's scope allows one. There's
27
+ * one shared .env per *project*, not per ability, so this can't actually resolve a genuine cross-ability
23
28
  * name collision (two abilities declaring, say, "API_KEY" for two
24
29
  * unrelated services still both read whichever single value ends up
25
30
  * set — there is no per-ability slot to give them) — unlike a tool or
@@ -38,41 +43,53 @@ function provenancePath(agentName) {
38
43
  * safer default, since treating a real secret as non-secret because a
39
44
  * different ability's own declaration happened to be checked first
40
45
  * would be the one direction genuinely worth avoiding. `set` is read
41
- * live off `process.env`, not cached, so it reflects whatever the last
42
- * `setEnvVar` call — or a plain restart picking up `.env` — actually
43
- * did. */
44
- /** `varName`'s own per-agent override name (see AbilityEnvDecl.perAgent)
45
- * — `<AGENT_NAME>_<varName>`, with `agentName` upper-cased and anything
46
- * that isn't `[A-Z0-9_]` (a hyphen, most likely — agent directory names
47
- * are kebab-case) turned into `_`, same alphabet ENV_VAR_NAME_PATTERN
48
- * already requires. Guards against a result starting with a digit (a
49
- * from-digits agent name, or an agent name that's entirely punctuation)
50
- * the same way that pattern would otherwise reject — by prefixing one
51
- * more `_` — though in practice agent names are never actually bare
52
- * digits. */
53
- export function agentScopedEnvVarName(agentName, varName) {
54
- const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
55
- return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
56
- }
46
+ * live (process.env for a shared row, the agent's own .env file for an
47
+ * agent row), not cached. */
57
48
  export function listDeclaredEnvVars(agentName) {
58
49
  const path = provenancePath(agentName);
59
50
  if (!existsSync(path))
60
51
  return [];
61
52
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
62
- const byName = new Map();
63
- function upsert(name, abilityName, decl, description) {
64
- const existing = byName.get(name);
53
+ const agentValues = readAgentEnvFile(agentDir(agentName));
54
+ const rows = new Map();
55
+ function upsert(slot, abilityName, decl, description) {
56
+ const scope = envScopeOf(decl);
57
+ const key = `${slot}:${decl.name}`;
58
+ const existing = rows.get(key);
65
59
  if (existing) {
66
60
  existing.abilityNames.push(abilityName);
67
61
  existing.secret = existing.secret || decl.secret === true;
68
62
  if (existing.secret)
69
63
  existing.value = undefined;
64
+ if (SCOPE_STRICTNESS[scope] > SCOPE_STRICTNESS[existing.scope])
65
+ existing.scope = scope;
70
66
  return;
71
67
  }
72
- const rawValue = process.env[name];
73
- byName.set(name, {
74
- name,
68
+ let rawValue;
69
+ let source;
70
+ let legacyName;
71
+ if (slot === 'shared') {
72
+ rawValue = process.env[decl.name];
73
+ }
74
+ else if (agentValues[decl.name] !== undefined) {
75
+ rawValue = agentValues[decl.name];
76
+ source = 'agent';
77
+ }
78
+ else {
79
+ const legacy = agentScopedEnvVarName(agentName, decl.name);
80
+ rawValue = process.env[legacy];
81
+ if (rawValue !== undefined) {
82
+ source = 'legacy';
83
+ legacyName = legacy;
84
+ }
85
+ }
86
+ rows.set(key, {
87
+ name: decl.name,
75
88
  description,
89
+ slot,
90
+ scope,
91
+ source,
92
+ legacyName,
76
93
  secret: decl.secret === true,
77
94
  abilityNames: [abilityName],
78
95
  set: rawValue !== undefined,
@@ -83,22 +100,25 @@ export function listDeclaredEnvVars(agentName) {
83
100
  }
84
101
  for (const [abilityName, record] of Object.entries(provenance)) {
85
102
  for (const decl of record.env) {
86
- upsert(decl.name, abilityName, decl, decl.description);
87
- // A perAgent var also gets its own separate, independently
88
- // set/unset row for this one agent's override — see
89
- // AbilityEnvDecl.perAgent's own doc comment for why this is a
90
- // second row, not a replacement for the shared one above (an
91
- // ability's own tool code checks the override first and falls
92
- // back to the shared value, so both need to stay independently
93
- // visible and settable here).
94
- if (decl.perAgent) {
95
- const scopedName = agentScopedEnvVarName(agentName, decl.name);
96
- const overrideDescription = `Overrides ${decl.name} for this agent only — if unset, falls back to the shared value above.${decl.description ? ` ${decl.description}` : ''}`;
97
- upsert(scopedName, abilityName, decl, overrideDescription);
103
+ const scope = envScopeOf(decl);
104
+ if (scope !== 'agent')
105
+ upsert('shared', abilityName, decl, decl.description);
106
+ if (scope === 'overridable') {
107
+ upsert('agent', abilityName, decl, `This agent's own value — if unset, falls back to the shared value.${decl.description ? ` ${decl.description}` : ''}`);
108
+ }
109
+ else if (scope === 'agent') {
110
+ upsert('agent', abilityName, decl, `Set per agent — never shared with or borrowed from other agents.${decl.description ? ` ${decl.description}` : ''}`);
98
111
  }
99
112
  }
100
113
  }
101
- return [...byName.values()];
114
+ // A name that's `agent`-scoped by any declaring ability is never read
115
+ // from the shared slot (core/agent-env.ts's createAgentEnv), so a
116
+ // shared row for it would only mislead.
117
+ for (const [key, row] of rows) {
118
+ if (row.slot === 'shared' && rows.get(`agent:${row.name}`)?.scope === 'agent')
119
+ rows.delete(key);
120
+ }
121
+ return [...rows.values()];
102
122
  }
103
123
  // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
104
124
  // --env-file-if-exists=.env (Node's own --env-file resolves relative to
@@ -132,18 +152,14 @@ function serializeEnvValue(value) {
132
152
  return `'${value}'`;
133
153
  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
134
154
  }
135
- /** Upserts `NAME=VALUE` into the project's `.env` file — preserving
136
- * every other line (comments, blank lines, unrelated keys) — and
137
- * applies it to *this* running process immediately via `process.env`,
138
- * so a newly-installed ability's tools work without a restart. Callers
139
- * (the PUT route in adapters/http.ts) are responsible for refusing to
140
- * call this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this
141
- * function itself has no notion of HTTP auth, it just writes. */
142
- export function setEnvVar(name, value) {
155
+ function assertEnvVarName(name) {
143
156
  if (!ENV_VAR_NAME_PATTERN.test(name)) {
144
157
  throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
145
158
  }
146
- const path = envFilePath();
159
+ }
160
+ /** Upserts `NAME=VALUE` into the .env file at `path`, preserving every
161
+ * other line (comments, blank lines, unrelated keys). */
162
+ function upsertEnvFileLine(path, name, value) {
147
163
  const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
148
164
  const newLine = `${name}=${serializeEnvValue(value)}`;
149
165
  const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
@@ -160,6 +176,28 @@ export function setEnvVar(name, value) {
160
176
  lines[existingIndex] = newLine;
161
177
  }
162
178
  writeFileSync(path, lines.join('\n') + '\n');
179
+ }
180
+ /** Removes NAME= from the .env file at `path`, if present. Only
181
+ * rewrites the file when something actually changed — a no-op removal
182
+ * shouldn't still touch the file's own mtime/trailing-newline shape for
183
+ * no reason. */
184
+ function removeEnvFileLine(path, name) {
185
+ if (!existsSync(path))
186
+ return;
187
+ const lines = readFileSync(path, 'utf8').split('\n');
188
+ const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
189
+ if (filtered.length !== lines.length)
190
+ writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
191
+ }
192
+ /** Upserts `NAME=VALUE` into the project's `.env` file and applies it to
193
+ * *this* running process immediately via `process.env`, so a
194
+ * newly-installed ability's tools work without a restart. Callers (the
195
+ * PUT route in adapters/http.ts) are responsible for refusing to call
196
+ * this at all when `LOOPENGINE_ADMIN_AUTH` isn't set — this function
197
+ * itself has no notion of HTTP auth, it just writes. */
198
+ export function setEnvVar(name, value) {
199
+ assertEnvVarName(name);
200
+ upsertEnvFileLine(envFilePath(), name, value);
163
201
  process.env[name] = value;
164
202
  }
165
203
  /** Removes NAME= entirely from the project's .env file (not just
@@ -169,24 +207,28 @@ export function setEnvVar(name, value) {
169
207
  * *this* running process's own process.env immediately, the same
170
208
  * "no restart needed" promise setEnvVar already makes. A no-op,
171
209
  * not an error, when the var was never set in .env to begin with —
172
- * same reasoning the DELETE route calling this treats a missing name
173
- * as success rather than a 404: the end state ("not set") is identical
174
- * either way, and the caller has no way to distinguish "I removed it"
175
- * from "it was already gone" that would actually matter to them. */
210
+ * the end state ("not set") is identical either way. */
176
211
  export function unsetEnvVar(name) {
177
- if (!ENV_VAR_NAME_PATTERN.test(name)) {
178
- throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
179
- }
180
- const path = envFilePath();
181
- if (existsSync(path)) {
182
- const lines = readFileSync(path, 'utf8').split('\n');
183
- const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
184
- // Only rewrites the file when something actually changed — a
185
- // no-op removal shouldn't still touch the file's own mtime/trailing-
186
- // newline shape for no reason. Matches setEnvVar's own exactly-one-
187
- // trailing-newline convention when it does.
188
- if (filtered.length !== lines.length)
189
- writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
190
- }
212
+ assertEnvVarName(name);
213
+ removeEnvFileLine(envFilePath(), name);
191
214
  delete process.env[name];
192
215
  }
216
+ /** Upserts `NAME=VALUE` into `agentName`'s own agents/<name>/.env — read
217
+ * by that agent's tools through ToolContext.env (core/agent-env.ts),
218
+ * which reads the file fresh on every lookup, so no restart is needed.
219
+ * Deliberately never copied into process.env: that's shared by every
220
+ * agent in the process, which is exactly what a per-agent value must
221
+ * not be. Same auth responsibility for callers as setEnvVar. */
222
+ export function setAgentEnvVar(agentName, name, value) {
223
+ assertEnvVarName(name);
224
+ upsertEnvFileLine(agentEnvFilePath(agentDir(agentName)), name, value);
225
+ }
226
+ /** Removes NAME= from `agentName`'s own .env — and the legacy prefixed
227
+ * project var for it too (see agentScopedEnvVarName), since createAgentEnv
228
+ * still falls back to that one, and "Remove" on this agent's row should
229
+ * leave the agent with no value of its own either way. */
230
+ export function unsetAgentEnvVar(agentName, name) {
231
+ assertEnvVarName(name);
232
+ removeEnvFileLine(agentEnvFilePath(agentDir(agentName)), name);
233
+ unsetEnvVar(agentScopedEnvVarName(agentName, name));
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -36,6 +36,8 @@
36
36
  "dist/core/client.d.ts",
37
37
  "dist/core/agent-config.js",
38
38
  "dist/core/agent-config.d.ts",
39
+ "dist/core/agent-env.js",
40
+ "dist/core/agent-env.d.ts",
39
41
  "dist/core/session-store.js",
40
42
  "dist/core/session-store.d.ts",
41
43
  "dist/core/vector-index.js",