loopengine 0.1.60 → 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.
- package/dist/bin/ability-manager.d.ts +16 -10
- package/dist/core/agent-config.d.ts +22 -1
- package/dist/core/agent-env.d.ts +41 -0
- package/dist/core/agent-env.js +100 -0
- package/dist/core/gateway-tools.d.ts +5 -1
- package/dist/core/gateway-tools.js +5 -1
- package/dist/core/run-agent.js +11 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +3 -2
- package/dist/web/agents-config-page.js +76 -7
- package/dist/web/env-admin.d.ts +40 -26
- package/dist/web/env-admin.js +106 -64
- package/package.json +3 -1
|
@@ -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
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
package/dist/core/run-agent.js
CHANGED
|
@@ -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,11 +56,11 @@ 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';
|
|
62
|
-
export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
|
|
63
|
+
export { installAbility, upgradeAbility, removeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
|
|
63
64
|
export type { InstalledAbilityRecord, AbilityEnvDecl, UpgradeFileResult } from './bin/ability-manager.js';
|
|
64
65
|
export { searchPublicAbilities, fetchLatestAbilityVersion } from './web/abilities-admin.js';
|
|
65
66
|
export type { PublicAbilitySearchResult } from './web/abilities-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,9 +51,9 @@ 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
|
-
export { installAbility, upgradeAbility, listInstalledAbilities, backfillDependencies, AbilityManifestError, AbilityVersionError, AbilityCollisionError, AbilityAlreadyInstalledError, AbilityNotInstalledError, } from './bin/ability-manager.js';
|
|
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';
|
|
57
58
|
export { describeModelProviders, describeGateways } from './web/global-config.js';
|
|
58
59
|
export { createTrackedApprover, listApprovals, decideApproval, findApproval } from './web/web-approver.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
|
-
? '
|
|
1185
|
+
? (v.slot === 'agent' && v.scope === 'overridable'
|
|
1186
|
+
? '<span class="hint">not set — 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'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'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
|
-
|
|
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');
|
|
@@ -1379,6 +1394,7 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1379
1394
|
if (deps.length) {
|
|
1380
1395
|
actionHtml += ' <button type="button" class="ability-install-deps-btn" data-name="' + escapeHtml(a.name) + '" title="npm install ' + escapeHtml(deps.join(' ')) + '">Install deps</button>';
|
|
1381
1396
|
}
|
|
1397
|
+
actionHtml += ' <button type="button" class="ability-uninstall-btn" data-name="' + escapeHtml(a.name) + '">Uninstall</button>';
|
|
1382
1398
|
return '<tr>' +
|
|
1383
1399
|
'<td><code>' + escapeHtml(a.name) + '</code></td>' +
|
|
1384
1400
|
'<td>' + escapeHtml(a.version) + '</td>' +
|
|
@@ -1544,6 +1560,49 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1544
1560
|
});
|
|
1545
1561
|
}
|
|
1546
1562
|
|
|
1563
|
+
// Deletes an ability's tool/skill files and actauth rules
|
|
1564
|
+
// (handleAbilityUninstallDelete / bin/ability-manager.ts's own
|
|
1565
|
+
// removeAbility). A file hand-modified since install is left in place
|
|
1566
|
+
// and reported back as refused rather than silently deleted — the
|
|
1567
|
+
// confirm() below offers to retry with force once the operator has
|
|
1568
|
+
// seen exactly which files those are, same two-step the CLI itself
|
|
1569
|
+
// takes with --force. Always reloads the tab afterward: a partial
|
|
1570
|
+
// uninstall (some files refused) still changes what is installed,
|
|
1571
|
+
// it just does not remove the ability entirely from provenance.
|
|
1572
|
+
function uninstallAbility(name, abilityName, statusEl, btn, force) {
|
|
1573
|
+
if (btn) { btn.disabled = true; }
|
|
1574
|
+
statusEl.className = 'hint';
|
|
1575
|
+
statusEl.textContent = 'Uninstalling ' + abilityName + '...';
|
|
1576
|
+
var url = '/agents/' + encodeURIComponent(name) + '/abilities/' + encodeURIComponent(abilityName) + (force ? '?force=true' : '');
|
|
1577
|
+
fetch(url, { method: 'DELETE' })
|
|
1578
|
+
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); })
|
|
1579
|
+
.then(function (result) {
|
|
1580
|
+
if (!result.ok) throw new Error(result.body.error || 'request failed');
|
|
1581
|
+
var removed = result.body.removed || [];
|
|
1582
|
+
var refused = result.body.refused || [];
|
|
1583
|
+
if (refused.length && !force) {
|
|
1584
|
+
var retry = confirm(
|
|
1585
|
+
abilityName + ': ' + refused.length + ' file(s) were hand-modified since install and left in place (' + refused.join(', ') + '). Remove them anyway?',
|
|
1586
|
+
);
|
|
1587
|
+
if (retry) {
|
|
1588
|
+
uninstallAbility(name, abilityName, statusEl, btn, true);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
statusEl.className = refused.length ? 'error' : 'hint';
|
|
1593
|
+
statusEl.textContent = removed.length
|
|
1594
|
+
? 'Removed ' + removed.join(', ') + '.' + (refused.length ? ' Left in place (hand-modified): ' + refused.join(', ') + '.' : '')
|
|
1595
|
+
: 'Nothing removed.';
|
|
1596
|
+
if (btn) { btn.disabled = false; }
|
|
1597
|
+
loadAbilitiesTab(name);
|
|
1598
|
+
})
|
|
1599
|
+
.catch(function (err) {
|
|
1600
|
+
statusEl.className = 'error';
|
|
1601
|
+
statusEl.textContent = 'Could not uninstall: ' + err.message;
|
|
1602
|
+
if (btn) { btn.disabled = false; }
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1547
1606
|
// Shared by the initial (empty-query) load right after the tab opens
|
|
1548
1607
|
// and the search form's own submit — the empty-query case is just
|
|
1549
1608
|
// "list every published ability" (the keyword filter alone, no extra
|
|
@@ -1593,6 +1652,16 @@ export const agentsConfigPageHtml = `<!doctype html>
|
|
|
1593
1652
|
});
|
|
1594
1653
|
}
|
|
1595
1654
|
|
|
1655
|
+
var uninstallBtns = content.querySelectorAll('.ability-uninstall-btn');
|
|
1656
|
+
for (var r = 0; r < uninstallBtns.length; r++) {
|
|
1657
|
+
uninstallBtns[r].addEventListener('click', function (ev) {
|
|
1658
|
+
var btn = ev.currentTarget;
|
|
1659
|
+
var abilityName = btn.dataset.name;
|
|
1660
|
+
if (!confirm('Uninstall ' + abilityName + '? This deletes its tool and skill files and actauth rules from this agent.')) return;
|
|
1661
|
+
uninstallAbility(name, abilityName, upgradeStatusEl, btn, false);
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1596
1665
|
var searchForm = content.querySelector('#abilitySearchForm');
|
|
1597
1666
|
var resultsEl = content.querySelector('#abilitySearchResults');
|
|
1598
1667
|
if (searchForm && resultsEl) {
|
package/dist/web/env-admin.d.ts
CHANGED
|
@@ -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
|
|
29
|
-
*
|
|
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
|
|
49
|
-
*
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
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;
|
package/dist/web/env-admin.js
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
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
|
|
42
|
-
*
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
178
|
-
|
|
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.
|
|
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",
|