loopengine 0.1.62 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -332,3 +332,20 @@ docker compose up --build
332
332
  Builds the HTTP adapter into a container and starts it alongside Redis.
333
333
  For production, push the image to any container platform with `REDIS_URL`
334
334
  and your model/tool API keys set as environment variables.
335
+
336
+ ### Hosting behind a platform
337
+
338
+ These settings are for running LoopEngine inside a hosting platform
339
+ (one workspace per user, behind a router that has already authenticated
340
+ them). All are off by default; a self-hosted install that doesn't set
341
+ them behaves exactly as described above.
342
+
343
+ | Setting | What it does |
344
+ | --- | --- |
345
+ | `LOOPENGINE_TRUSTED_PROXY_SECRET` | Every request must carry an `X-LoopEngine-Identity` header signed with this secret (`signProxyIdentity` creates one; unsigned requests get 401). `LOOPENGINE_ADMIN_AUTH`, if also set, still works for direct access. Also enables `GET /internal/activity`: in-flight turns, open streams and idle time, so the host never stops a server mid-turn. |
346
+ | `LOOPENGINE_EXIT_ON_CODE_CHANGE=1` | After an ability install, upgrade, uninstall or dependency install through the Admin UI, the server drains and exits so the platform restarts it with the new code loaded. Only set this when something restarts the process. |
347
+ | `LOOPENGINE_USAGE_LOG` | `stdout` (one JSON line per model call) or a file path (JSON lines) — tokens per call with agent, tenant, session and model. Or call `setUsageSink()` in code. |
348
+ | `setSecretStore()` | In code, at startup: keep env values set through the Admin UI somewhere other than `.env` files (a vault, a secret manager). The default `FileSecretStore` is today's behaviour. |
349
+
350
+ `deploy/workspace/Dockerfile` is a runtime image for this: it serves a
351
+ scaffolded project mounted at `/workspace` instead of baking agents in.
@@ -22,17 +22,11 @@ export declare function envScopeOf(decl: {
22
22
  * Still read as a fallback by createAgentEnv (an agent's own .env wins
23
23
  * over it) so a project already configured this way keeps working. */
24
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
25
  export declare function setAgentDir(config: AgentConfig, dir: string): void;
33
26
  export declare function agentDirFor(config: AgentConfig): string;
34
27
  /** Lookup order for `get(name)`:
35
- * 1. `name` in this agent's own `<dir>/.env`
28
+ * 1. `name` among this agent's own values (the secret store's
29
+ * readAgentValues — `<dir>/.env` by default)
36
30
  * 2. the legacy prefixed project var (agentScopedEnvVarName)
37
31
  * 3. `process.env[name]` — skipped when an installed ability declared
38
32
  * `name` with `scope: 'agent'`
@@ -3,12 +3,13 @@
3
3
  // but several agents can install the same ability and each need their
4
4
  // own value for some of its settings (a Telegram bot token, a default
5
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.
6
+ // name (`<AGENT>_<VAR>`) itself, the runtime resolves it here, from the
7
+ // agent's own values (agents/<name>/.env by default — see
8
+ // core/secret-store.ts) layered over the project's own.
8
9
  import { existsSync, readFileSync } from 'node:fs';
9
10
  import { join } from 'node:path';
10
- import { parseEnv } from 'node:util';
11
11
  import { agentDir } from './gateway-tools.js';
12
+ import { agentEnvFilePath, getSecretStore } from './secret-store.js';
12
13
  /** `perAgent: true` predates `scope` and means exactly `overridable` —
13
14
  * still accepted so abilities published before `scope` existed keep
14
15
  * working unchanged. */
@@ -24,20 +25,6 @@ export function agentScopedEnvVarName(agentName, varName) {
24
25
  const prefix = agentName.toUpperCase().replace(/[^A-Z0-9_]/g, '_');
25
26
  return `${/^[0-9]/.test(prefix) ? '_' : ''}${prefix}_${varName}`;
26
27
  }
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
28
  /** Every env var name an ability installed in `dir` declared as
42
29
  * `scope: 'agent'` — the ones createAgentEnv must never fall back to the
43
30
  * project-wide value for. */
@@ -69,7 +56,8 @@ export function agentDirFor(config) {
69
56
  return agentDirs.get(config) ?? agentDir(config.name);
70
57
  }
71
58
  /** Lookup order for `get(name)`:
72
- * 1. `name` in this agent's own `<dir>/.env`
59
+ * 1. `name` among this agent's own values (the secret store's
60
+ * readAgentValues — `<dir>/.env` by default)
73
61
  * 2. the legacy prefixed project var (agentScopedEnvVarName)
74
62
  * 3. `process.env[name]` — skipped when an installed ability declared
75
63
  * `name` with `scope: 'agent'`
@@ -77,7 +65,7 @@ export function agentDirFor(config) {
77
65
  * same order, so it can be overridden per agent too. */
78
66
  export function createAgentEnv(agentName, dir) {
79
67
  function get(name) {
80
- const own = readAgentEnvFile(dir)[name];
68
+ const own = getSecretStore().readAgentValues(agentName, dir)[name];
81
69
  if (own !== undefined)
82
70
  return own;
83
71
  const legacy = process.env[agentScopedEnvVarName(agentName, name)];
@@ -58,6 +58,7 @@ export function createAnthropicModelCall(options = {}) {
58
58
  }
59
59
  return { type: block.type };
60
60
  });
61
- return { stop_reason: response.stop_reason ?? 'end_turn', content };
61
+ const usage = response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined;
62
+ return { stop_reason: response.stop_reason ?? 'end_turn', content, usage };
62
63
  };
63
64
  }
@@ -48,6 +48,7 @@ export function createDeepSeekModelCall(options) {
48
48
  }))
49
49
  : undefined,
50
50
  });
51
- return toModelResponse(response.choices[0]);
51
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
52
+ return { ...toModelResponse(response.choices[0]), usage };
52
53
  };
53
54
  }
@@ -48,6 +48,7 @@ export function createGeminiModelCall(options) {
48
48
  }))
49
49
  : undefined,
50
50
  });
51
- return toModelResponse(response.choices[0]);
51
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
52
+ return { ...toModelResponse(response.choices[0]), usage };
52
53
  };
53
54
  }
@@ -41,6 +41,7 @@ export function createGlmModelCall(options) {
41
41
  }))
42
42
  : undefined,
43
43
  });
44
- return toModelResponse(response.choices[0]);
44
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
45
+ return { ...toModelResponse(response.choices[0]), usage };
45
46
  };
46
47
  }
@@ -54,6 +54,7 @@ export function createKimiModelCall(options) {
54
54
  }))
55
55
  : undefined,
56
56
  });
57
- return toModelResponse(response.choices[0]);
57
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
58
+ return { ...toModelResponse(response.choices[0]), usage };
58
59
  };
59
60
  }
@@ -122,6 +122,7 @@ export function createOpenAIModelCall(options) {
122
122
  : undefined,
123
123
  reasoning_effort: options.reasoningEffort,
124
124
  });
125
- return toModelResponse(response.choices[0]);
125
+ const usage = response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined;
126
+ return { ...toModelResponse(response.choices[0]), usage };
126
127
  };
127
128
  }
@@ -44,6 +44,12 @@ export interface ModelContentBlock {
44
44
  export interface ModelResponse {
45
45
  stop_reason: string;
46
46
  content: ModelContentBlock[];
47
+ /** Tokens this call used, when the provider reports them — recorded
48
+ * per call by core/usage.ts if a usage sink is configured. */
49
+ usage?: {
50
+ input_tokens: number;
51
+ output_tokens: number;
52
+ };
47
53
  }
48
54
  /** loopengine's own conversation-message type — a superset of
49
55
  * budget.ts's own `{role, content: string}`: `content` can also be a
@@ -15,6 +15,7 @@ import { ToolLane } from './toollane.js';
15
15
  import { Recovery } from './recovery.js';
16
16
  import { collectKnownUrls, correctToolUseInput, recordKnownUrlsFromResult } from './known-urls.js';
17
17
  import { agentDirFor, createAgentEnv, setAgentDir } from './agent-env.js';
18
+ import { recordUsage } from './usage.js';
18
19
  import { loadAgentModule } from './discover-agents.js';
19
20
  import { agentAsTool } from './agent-as-tool.js';
20
21
  import { loadGatewayToolsFromDir } from './gateway-tools.js';
@@ -445,6 +446,7 @@ async function buildTurnContext(config, modelCall, options) {
445
446
  askUserTool,
446
447
  questionHandler,
447
448
  httpNotifier,
449
+ modelLabel: { provider: config.model?.provider, model: config.model?.model },
448
450
  toolContext: {
449
451
  agentName: config.name,
450
452
  tenant: scope.tenant,
@@ -462,7 +464,7 @@ async function buildTurnContext(config, modelCall, options) {
462
464
  * fresh in each case) but mutated here via pushMessage, same as the
463
465
  * single function this was split out of always did. */
464
466
  async function runLoop(ctx, messages, newMessages, starterMessage) {
465
- const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler, toolContext } = ctx;
467
+ const { modelCall, log, scope, sessionId, skillGarden, toolsByName, systemToolInstances, toolSchemas, systemPrompt, budgetTracker, compactor, gate, toolLane, maxTurns, tailMessages, askUserTool, questionHandler, toolContext, modelLabel } = ctx;
466
468
  function pushMessage(message) {
467
469
  messages.push(message);
468
470
  newMessages.push(message);
@@ -533,7 +535,23 @@ async function runLoop(ctx, messages, newMessages, starterMessage) {
533
535
  // and there's no second type yet to justify a second, more generic
534
536
  // event just to aggregate it (see PromptCompactionEvent's own doc
535
537
  // comment for the removed 'recovery:summary').
536
- const { value: response } = await recovery.call((msgs) => modelCall(msgs, systemPrompt, toolSchemas), messages);
538
+ // Recorded inside the retried call, not after it, so a retry that
539
+ // spent tokens and then failed is still counted.
540
+ const { value: response } = await recovery.call(async (msgs) => {
541
+ const result = await modelCall(msgs, systemPrompt, toolSchemas);
542
+ if (result.usage) {
543
+ recordUsage({
544
+ at: new Date().toISOString(),
545
+ agent: toolContext.agentName,
546
+ tenant: toolContext.tenant,
547
+ sessionId: toolContext.sessionId,
548
+ ...modelLabel,
549
+ inputTokens: result.usage.input_tokens,
550
+ outputTokens: result.usage.output_tokens,
551
+ });
552
+ }
553
+ return result;
554
+ }, messages);
537
555
  // The model's full response — text and tool_use blocks alike, with
538
556
  // real ids — becomes this turn's assistant message verbatim. Every
539
557
  // tool_use block emitted here gets a matching tool_result pushed
@@ -0,0 +1,32 @@
1
+ export interface SecretStore {
2
+ /** Every value set for this one agent (its own, not the shared ones).
3
+ * Synchronous because ToolContext.env.get is — a store backed by a
4
+ * remote service should keep an in-memory copy and return that. */
5
+ readAgentValues(agentName: string, dir: string): Record<string, string>;
6
+ /** Persist a project-wide value. The caller (web/env-admin.ts) applies
7
+ * it to process.env afterwards, so a store doesn't need to. */
8
+ setShared(name: string, value: string): void | Promise<void>;
9
+ unsetShared(name: string): void | Promise<void>;
10
+ setAgent(agentName: string, dir: string, name: string, value: string): void | Promise<void>;
11
+ unsetAgent(agentName: string, dir: string, name: string): void | Promise<void>;
12
+ }
13
+ export declare function agentEnvFilePath(dir: string): string;
14
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
15
+ * serializeEnvValue writes reads back exactly the way it does from the
16
+ * project .env. Read fresh on every call, not cached — it's a few lines,
17
+ * read at most a handful of times per tool call, and this way an edit
18
+ * (from the Admin UI or by hand) is seen immediately. */
19
+ export declare function readAgentEnvFile(dir: string): Record<string, string>;
20
+ /** The project's .env for shared values, agents/<name>/.env for each
21
+ * agent's own — the default, and what every self-hosted install uses. */
22
+ export declare class FileSecretStore implements SecretStore {
23
+ readAgentValues(_agentName: string, dir: string): Record<string, string>;
24
+ setShared(name: string, value: string): void;
25
+ unsetShared(name: string): void;
26
+ setAgent(_agentName: string, dir: string, name: string, value: string): void;
27
+ unsetAgent(_agentName: string, dir: string, name: string): void;
28
+ }
29
+ /** Replaces the store for this whole process — call once at startup,
30
+ * before the server handles requests. */
31
+ export declare function setSecretStore(next: SecretStore): void;
32
+ export declare function getSecretStore(): SecretStore;
@@ -0,0 +1,116 @@
1
+ // Where env var values set through the Admin UI (web/env-admin.ts) are
2
+ // persisted, and where an agent's own values (ToolContext.env — see
3
+ // core/agent-env.ts) are read from. The default, FileSecretStore, is the
4
+ // project's .env plus each agent's agents/<name>/.env — exactly what a
5
+ // self-hosted install has always used. A host that keeps secrets
6
+ // somewhere else (a hosted control plane's vault, Vault, a cloud secret
7
+ // manager) swaps it in once at startup with setSecretStore().
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { parseEnv } from 'node:util';
11
+ export function agentEnvFilePath(dir) {
12
+ return join(dir, '.env');
13
+ }
14
+ /** Parsed with Node's own --env-file parser (util.parseEnv), so a value
15
+ * serializeEnvValue writes reads back exactly the way it does from the
16
+ * project .env. Read fresh on every call, not cached — it's a few lines,
17
+ * read at most a handful of times per tool call, and this way an edit
18
+ * (from the Admin UI or by hand) is seen immediately. */
19
+ export function readAgentEnvFile(dir) {
20
+ const path = agentEnvFilePath(dir);
21
+ if (!existsSync(path))
22
+ return {};
23
+ return parseEnv(readFileSync(path, 'utf8'));
24
+ }
25
+ // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
26
+ // --env-file-if-exists=.env (Node's own --env-file resolves relative to
27
+ // cwd too) — not core/agent-registry.ts's projectDir(), which resolves
28
+ // relative to *that compiled file's own location* and would point at
29
+ // node_modules/loopengine/dist/ in a real scaffolded project, not the
30
+ // project's own root where .env actually lives.
31
+ function projectEnvFilePath() {
32
+ return join(process.cwd(), '.env');
33
+ }
34
+ // Node's own --env-file parser (what bin/cli.ts's runTsx already passes
35
+ // as --env-file-if-exists=.env) needs a value containing whitespace, '#',
36
+ // or a quote character quoted, so it reads back as one value instead of
37
+ // being reinterpreted as a comment or truncated at the first space.
38
+ // Single-quoting wins over double — confirmed live against Node's actual
39
+ // parser that a single-quoted value reads back fully literally, with no
40
+ // backslash-escape processing inside it at all: a real embedded newline,
41
+ // a literal double-quote, or a literal backslash (a JSON secret like
42
+ // GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
43
+ // untouched. Double-quoting a value with an *escaped* embedded quote does
44
+ // NOT round-trip — confirmed live that Node's parser has no support for
45
+ // \" as an escaped literal quote inside a double-quoted value; it just
46
+ // ends the value there instead (`X="a\"b"` reads back as only `a\`,
47
+ // silently dropping `b"` entirely). The double-quote fallback below is
48
+ // only reached when the value itself contains a literal single quote —
49
+ // the one character single-quoting can't represent at all.
50
+ function serializeEnvValue(value) {
51
+ if (!/[\s#"'\\]/.test(value) && value !== '')
52
+ return value;
53
+ if (!value.includes("'"))
54
+ return `'${value}'`;
55
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
56
+ }
57
+ /** Upserts `NAME=VALUE` into the .env file at `path`, preserving every
58
+ * other line (comments, blank lines, unrelated keys). */
59
+ function upsertEnvFileLine(path, name, value) {
60
+ const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
61
+ const newLine = `${name}=${serializeEnvValue(value)}`;
62
+ const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
63
+ if (existingIndex === -1) {
64
+ // Drop a single trailing blank line (from the file's own final
65
+ // newline splitting into an empty last element) before appending,
66
+ // so this doesn't accumulate a growing gap of blank lines across
67
+ // repeated calls.
68
+ if (lines.length > 0 && lines[lines.length - 1] === '')
69
+ lines.pop();
70
+ lines.push(newLine);
71
+ }
72
+ else {
73
+ lines[existingIndex] = newLine;
74
+ }
75
+ writeFileSync(path, lines.join('\n') + '\n');
76
+ }
77
+ /** Removes NAME= from the .env file at `path`, if present. Only
78
+ * rewrites the file when something actually changed — a no-op removal
79
+ * shouldn't still touch the file's own mtime/trailing-newline shape for
80
+ * no reason. */
81
+ function removeEnvFileLine(path, name) {
82
+ if (!existsSync(path))
83
+ return;
84
+ const lines = readFileSync(path, 'utf8').split('\n');
85
+ const filtered = lines.filter((line) => !line.startsWith(`${name}=`));
86
+ if (filtered.length !== lines.length)
87
+ writeFileSync(path, filtered.join('\n').replace(/\n*$/, '\n'));
88
+ }
89
+ /** The project's .env for shared values, agents/<name>/.env for each
90
+ * agent's own — the default, and what every self-hosted install uses. */
91
+ export class FileSecretStore {
92
+ readAgentValues(_agentName, dir) {
93
+ return readAgentEnvFile(dir);
94
+ }
95
+ setShared(name, value) {
96
+ upsertEnvFileLine(projectEnvFilePath(), name, value);
97
+ }
98
+ unsetShared(name) {
99
+ removeEnvFileLine(projectEnvFilePath(), name);
100
+ }
101
+ setAgent(_agentName, dir, name, value) {
102
+ upsertEnvFileLine(agentEnvFilePath(dir), name, value);
103
+ }
104
+ unsetAgent(_agentName, dir, name) {
105
+ removeEnvFileLine(agentEnvFilePath(dir), name);
106
+ }
107
+ }
108
+ let store = new FileSecretStore();
109
+ /** Replaces the store for this whole process — call once at startup,
110
+ * before the server handles requests. */
111
+ export function setSecretStore(next) {
112
+ store = next;
113
+ }
114
+ export function getSecretStore() {
115
+ return store;
116
+ }
@@ -0,0 +1,15 @@
1
+ export declare const TRUSTED_PROXY_IDENTITY_HEADER = "x-loopengine-identity";
2
+ export interface ProxyIdentity {
3
+ /** The authenticated user (or `system` for the control plane itself). */
4
+ sub: string;
5
+ workspace?: string;
6
+ role: string;
7
+ /** Expiry, Unix seconds. */
8
+ exp: number;
9
+ }
10
+ /** What the proxy puts in X-LoopEngine-Identity — exported so a router
11
+ * (and tests) produce exactly the format verifyProxyIdentity accepts. */
12
+ export declare function signProxyIdentity(identity: ProxyIdentity, secret: string): string;
13
+ /** The identity, or undefined for a missing, malformed, wrongly-signed
14
+ * or expired header. */
15
+ export declare function verifyProxyIdentity(header: string | string[] | undefined, secret: string, nowMs?: number): ProxyIdentity | undefined;
@@ -0,0 +1,47 @@
1
+ // Auth for a server that only ever receives requests through a trusted
2
+ // proxy (a hosting router that has already authenticated the user) —
3
+ // enabled by LOOPENGINE_TRUSTED_PROXY_SECRET, off otherwise. The proxy
4
+ // sends who the caller is in an X-LoopEngine-Identity header, signed with
5
+ // that shared secret, so this server can trust it without seeing the
6
+ // user's own credentials.
7
+ //
8
+ // Header format: `<payload>.<signature>`, both base64url — payload is the
9
+ // JSON identity, signature is HMAC-SHA256(secret, payload). `exp` (Unix
10
+ // seconds) keeps a captured header from being replayed indefinitely.
11
+ import { createHmac, timingSafeEqual } from 'node:crypto';
12
+ export const TRUSTED_PROXY_IDENTITY_HEADER = 'x-loopengine-identity';
13
+ function sign(payload, secret) {
14
+ return createHmac('sha256', secret).update(payload).digest('base64url');
15
+ }
16
+ /** What the proxy puts in X-LoopEngine-Identity — exported so a router
17
+ * (and tests) produce exactly the format verifyProxyIdentity accepts. */
18
+ export function signProxyIdentity(identity, secret) {
19
+ const payload = Buffer.from(JSON.stringify(identity)).toString('base64url');
20
+ return `${payload}.${sign(payload, secret)}`;
21
+ }
22
+ /** The identity, or undefined for a missing, malformed, wrongly-signed
23
+ * or expired header. */
24
+ export function verifyProxyIdentity(header, secret, nowMs = Date.now()) {
25
+ if (typeof header !== 'string')
26
+ return undefined;
27
+ const dot = header.indexOf('.');
28
+ if (dot <= 0)
29
+ return undefined;
30
+ const payload = header.slice(0, dot);
31
+ const provided = Buffer.from(header.slice(dot + 1));
32
+ const expected = Buffer.from(sign(payload, secret));
33
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected))
34
+ return undefined;
35
+ let identity;
36
+ try {
37
+ identity = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ if (typeof identity.sub !== 'string' || typeof identity.role !== 'string' || typeof identity.exp !== 'number')
43
+ return undefined;
44
+ if (identity.exp * 1000 <= nowMs)
45
+ return undefined;
46
+ return identity;
47
+ }
@@ -0,0 +1,18 @@
1
+ export interface UsageEvent {
2
+ at: string;
3
+ agent: string;
4
+ tenant: string;
5
+ sessionId?: string;
6
+ /** From AgentConfig.model — absent for an agent with its own
7
+ * createModelCall. */
8
+ provider?: string;
9
+ model?: string;
10
+ inputTokens: number;
11
+ outputTokens: number;
12
+ }
13
+ export type UsageSink = (event: UsageEvent) => void;
14
+ /** Replaces the sink for this whole process; undefined turns usage
15
+ * recording off. */
16
+ export declare function setUsageSink(next: UsageSink | undefined): void;
17
+ /** Never throws — a broken sink must not fail the agent's turn. */
18
+ export declare function recordUsage(event: UsageEvent): void;
@@ -0,0 +1,32 @@
1
+ // Token usage per model call — off unless a sink is configured, so a
2
+ // self-hosted install that doesn't ask for it sees no change at all.
3
+ // Configure with LOOPENGINE_USAGE_LOG (`stdout` for one JSON line per
4
+ // call on stdout, or a file path to append JSON lines to), or in code
5
+ // with setUsageSink() — a host billing for model usage, or anyone
6
+ // wanting a per-agent token count.
7
+ import { appendFileSync } from 'node:fs';
8
+ function sinkFromEnv() {
9
+ const target = process.env.LOOPENGINE_USAGE_LOG;
10
+ if (!target)
11
+ return undefined;
12
+ if (target === 'stdout')
13
+ return (event) => console.log(JSON.stringify({ type: 'loopengine:usage', ...event }));
14
+ return (event) => appendFileSync(target, JSON.stringify(event) + '\n');
15
+ }
16
+ let sink = sinkFromEnv();
17
+ /** Replaces the sink for this whole process; undefined turns usage
18
+ * recording off. */
19
+ export function setUsageSink(next) {
20
+ sink = next;
21
+ }
22
+ /** Never throws — a broken sink must not fail the agent's turn. */
23
+ export function recordUsage(event) {
24
+ if (!sink)
25
+ return;
26
+ try {
27
+ sink(event);
28
+ }
29
+ catch (err) {
30
+ console.error('[loopengine] usage sink failed:', err);
31
+ }
32
+ }
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ 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
5
  export type { AgentConfig, AgentModelConfig, ToolSchema, ToolDefinition, ToolContext, AgentEnv } from '#core/agent-config.js';
6
6
  export { createAgentEnv, agentDirFor, envScopeOf, type EnvScope } from '#core/agent-env.js';
7
+ export { FileSecretStore, setSecretStore, getSecretStore, type SecretStore } from '#core/secret-store.js';
8
+ export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER, type ProxyIdentity } from '#core/trusted-proxy.js';
9
+ export { setUsageSink, recordUsage, type UsageEvent, type UsageSink } from '#core/usage.js';
7
10
  export type * from './core/loop-events.js';
8
11
  export { streamMessage, streamMessageWithCallbacks, sendMessage, approveCall, denyCall, answerQuestion, getSessionHistory, } from './core/client.js';
9
12
  export type { RequestOptions, SendMessageResult, PendingApprovalResult, PendingQuestionResult, PendingResult, MessageResult, StreamMessageCallbacks, } from './core/client.js';
package/dist/index.js CHANGED
@@ -6,6 +6,9 @@
6
6
  export { runAgent } from '#core/run-agent.js';
7
7
  export { storageSigners } from '#core/storage-signers/index.js';
8
8
  export { createAgentEnv, agentDirFor, envScopeOf } from '#core/agent-env.js';
9
+ export { FileSecretStore, setSecretStore, getSecretStore } from '#core/secret-store.js';
10
+ export { signProxyIdentity, verifyProxyIdentity, TRUSTED_PROXY_IDENTITY_HEADER } from '#core/trusted-proxy.js';
11
+ export { setUsageSink, recordUsage } from '#core/usage.js';
9
12
  // The framework-agnostic browser client for adapters/http.ts's own wire
10
13
  // protocol (see client.ts's own header comment) — this, not a deep
11
14
  // subpath import, is how a consumer's React/Vue/etc. chat UI is meant to
@@ -65,31 +65,32 @@ export interface DeclaredEnvVar {
65
65
  * live (process.env for a shared row, the agent's own .env file for an
66
66
  * agent row), not cached. */
67
67
  export declare function listDeclaredEnvVars(agentName: string): DeclaredEnvVar[];
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. */
74
- export declare function setEnvVar(name: string, value: string): void;
75
- /** Removes NAME= entirely from the project's .env file (not just
76
- * blanking its value — an empty string is itself a real, distinct
77
- * value some vars would treat differently from "unset," e.g. falling
78
- * through to a default only when truly absent) and clears it from
79
- * *this* running process's own process.env immediately, the same
80
- * "no restart needed" promise setEnvVar already makes. A no-op,
81
- * not an error, when the var was never set in .env to begin with —
82
- * the end state ("not set") is identical either way. */
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.
68
+ /** Persists a project-wide value through the secret store (the project's
69
+ * `.env` by default — see core/secret-store.ts) and applies it to *this*
70
+ * running process immediately via `process.env`, so a newly-installed
71
+ * ability's tools work without a restart. Callers (the PUT route in
72
+ * adapters/http.ts) are responsible for refusing to call this at all
73
+ * when the server has no auth configured — this function itself has no
74
+ * notion of HTTP auth, it just writes. */
75
+ export declare function setEnvVar(name: string, value: string): Promise<void>;
76
+ /** Removes a project-wide value entirely (not just blanking it — an
77
+ * empty string is itself a real, distinct value some vars would treat
78
+ * differently from "unset," e.g. falling through to a default only when
79
+ * truly absent) and clears it from *this* running process's own
80
+ * process.env immediately, the same "no restart needed" promise
81
+ * setEnvVar already makes. A no-op, not an error, when the var was
82
+ * never set to begin with — the end state ("not set") is identical
83
+ * either way. */
84
+ export declare function unsetEnvVar(name: string): Promise<void>;
85
+ /** Persists `agentName`'s own value (agents/<name>/.env by default) —
86
+ * read by that agent's tools through ToolContext.env (core/agent-env.ts),
87
+ * which reads it fresh on every lookup, so no restart is needed.
87
88
  * Deliberately never copied into process.env: that's shared by every
88
89
  * agent in the process, which is exactly what a per-agent value must
89
90
  * 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;
91
+ export declare function setAgentEnvVar(agentName: string, name: string, value: string): Promise<void>;
92
+ /** Removes `agentName`'s own value — and the legacy prefixed project var
93
+ * for it too (see agentScopedEnvVarName), since createAgentEnv still
94
+ * falls back to that one, and "Remove" on this agent's row should leave
95
+ * the agent with no value of its own either way. */
96
+ export declare function unsetAgentEnvVar(agentName: string, name: string): Promise<void>;
@@ -6,10 +6,11 @@
6
6
  // `secret` is never echoed back once set — same never-echo-a-secret rule
7
7
  // web/http-tool-admin.ts's own `{{ENV_VAR}}` header handling already
8
8
  // establishes for a tool's own secrets.
9
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, readFileSync } 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';
12
+ import { agentScopedEnvVarName, envScopeOf } from '../core/agent-env.js';
13
+ import { getSecretStore } from '../core/secret-store.js';
13
14
  export { agentScopedEnvVarName };
14
15
  export class EnvVarNameError extends Error {
15
16
  }
@@ -50,7 +51,7 @@ export function listDeclaredEnvVars(agentName) {
50
51
  if (!existsSync(path))
51
52
  return [];
52
53
  const provenance = JSON.parse(readFileSync(path, 'utf8'));
53
- const agentValues = readAgentEnvFile(agentDir(agentName));
54
+ const agentValues = getSecretStore().readAgentValues(agentName, agentDir(agentName));
54
55
  const rows = new Map();
55
56
  function upsert(slot, abilityName, decl, description) {
56
57
  const scope = envScopeOf(decl);
@@ -120,115 +121,52 @@ export function listDeclaredEnvVars(agentName) {
120
121
  }
121
122
  return [...rows.values()];
122
123
  }
123
- // process.cwd(), matching exactly where bin/cli.ts's own runTsx passes
124
- // --env-file-if-exists=.env (Node's own --env-file resolves relative to
125
- // cwd too) — not core/agent-registry.ts's projectDir(), which resolves
126
- // relative to *that compiled file's own location* and would point at
127
- // node_modules/loopengine/dist/ in a real scaffolded project, not the
128
- // project's own root where .env actually lives.
129
- function envFilePath() {
130
- return join(process.cwd(), '.env');
131
- }
132
- // Node's own --env-file parser (what bin/cli.ts's runTsx already passes
133
- // as --env-file-if-exists=.env) needs a value containing whitespace, '#',
134
- // or a quote character quoted, so it reads back as one value instead of
135
- // being reinterpreted as a comment or truncated at the first space.
136
- // Single-quoting wins over double — confirmed live against Node's actual
137
- // parser that a single-quoted value reads back fully literally, with no
138
- // backslash-escape processing inside it at all: a real embedded newline,
139
- // a literal double-quote, or a literal backslash (a JSON secret like
140
- // GOOGLE_APPLICATION_CREDENTIALS_JSON has all three at once) all survive
141
- // untouched. Double-quoting a value with an *escaped* embedded quote does
142
- // NOT round-trip — confirmed live that Node's parser has no support for
143
- // \" as an escaped literal quote inside a double-quoted value; it just
144
- // ends the value there instead (`X="a\"b"` reads back as only `a\`,
145
- // silently dropping `b"` entirely). The double-quote fallback below is
146
- // only reached when the value itself contains a literal single quote —
147
- // the one character single-quoting can't represent at all.
148
- function serializeEnvValue(value) {
149
- if (!/[\s#"'\\]/.test(value) && value !== '')
150
- return value;
151
- if (!value.includes("'"))
152
- return `'${value}'`;
153
- return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
154
- }
155
124
  function assertEnvVarName(name) {
156
125
  if (!ENV_VAR_NAME_PATTERN.test(name)) {
157
126
  throw new EnvVarNameError(`"${name}" isn't a valid env var name (uppercase letters, digits, underscore, not starting with a digit).`);
158
127
  }
159
128
  }
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) {
163
- const lines = existsSync(path) ? readFileSync(path, 'utf8').split('\n') : [];
164
- const newLine = `${name}=${serializeEnvValue(value)}`;
165
- const existingIndex = lines.findIndex((line) => line.startsWith(`${name}=`));
166
- if (existingIndex === -1) {
167
- // Drop a single trailing blank line (from the file's own final
168
- // newline splitting into an empty last element) before appending,
169
- // so this doesn't accumulate a growing gap of blank lines across
170
- // repeated calls.
171
- if (lines.length > 0 && lines[lines.length - 1] === '')
172
- lines.pop();
173
- lines.push(newLine);
174
- }
175
- else {
176
- lines[existingIndex] = newLine;
177
- }
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) {
129
+ /** Persists a project-wide value through the secret store (the project's
130
+ * `.env` by default — see core/secret-store.ts) and applies it to *this*
131
+ * running process immediately via `process.env`, so a newly-installed
132
+ * ability's tools work without a restart. Callers (the PUT route in
133
+ * adapters/http.ts) are responsible for refusing to call this at all
134
+ * when the server has no auth configured — this function itself has no
135
+ * notion of HTTP auth, it just writes. */
136
+ export async function setEnvVar(name, value) {
199
137
  assertEnvVarName(name);
200
- upsertEnvFileLine(envFilePath(), name, value);
138
+ await getSecretStore().setShared(name, value);
201
139
  process.env[name] = value;
202
140
  }
203
- /** Removes NAME= entirely from the project's .env file (not just
204
- * blanking its value — an empty string is itself a real, distinct
205
- * value some vars would treat differently from "unset," e.g. falling
206
- * through to a default only when truly absent) and clears it from
207
- * *this* running process's own process.env immediately, the same
208
- * "no restart needed" promise setEnvVar already makes. A no-op,
209
- * not an error, when the var was never set in .env to begin with —
210
- * the end state ("not set") is identical either way. */
211
- export function unsetEnvVar(name) {
141
+ /** Removes a project-wide value entirely (not just blanking it — an
142
+ * empty string is itself a real, distinct value some vars would treat
143
+ * differently from "unset," e.g. falling through to a default only when
144
+ * truly absent) and clears it from *this* running process's own
145
+ * process.env immediately, the same "no restart needed" promise
146
+ * setEnvVar already makes. A no-op, not an error, when the var was
147
+ * never set to begin with — the end state ("not set") is identical
148
+ * either way. */
149
+ export async function unsetEnvVar(name) {
212
150
  assertEnvVarName(name);
213
- removeEnvFileLine(envFilePath(), name);
151
+ await getSecretStore().unsetShared(name);
214
152
  delete process.env[name];
215
153
  }
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.
154
+ /** Persists `agentName`'s own value (agents/<name>/.env by default) —
155
+ * read by that agent's tools through ToolContext.env (core/agent-env.ts),
156
+ * which reads it fresh on every lookup, so no restart is needed.
219
157
  * Deliberately never copied into process.env: that's shared by every
220
158
  * agent in the process, which is exactly what a per-agent value must
221
159
  * not be. Same auth responsibility for callers as setEnvVar. */
222
- export function setAgentEnvVar(agentName, name, value) {
160
+ export async function setAgentEnvVar(agentName, name, value) {
223
161
  assertEnvVarName(name);
224
- upsertEnvFileLine(agentEnvFilePath(agentDir(agentName)), name, value);
162
+ await getSecretStore().setAgent(agentName, agentDir(agentName), name, value);
225
163
  }
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) {
164
+ /** Removes `agentName`'s own value — and the legacy prefixed project var
165
+ * for it too (see agentScopedEnvVarName), since createAgentEnv still
166
+ * falls back to that one, and "Remove" on this agent's row should leave
167
+ * the agent with no value of its own either way. */
168
+ export async function unsetAgentEnvVar(agentName, name) {
231
169
  assertEnvVarName(name);
232
- removeEnvFileLine(agentEnvFilePath(agentDir(agentName)), name);
233
- unsetEnvVar(agentScopedEnvVarName(agentName, name));
170
+ await getSecretStore().unsetAgent(agentName, agentDir(agentName), name);
171
+ await unsetEnvVar(agentScopedEnvVarName(agentName, name));
234
172
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.1.62",
3
+ "version": "0.1.63",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -38,6 +38,12 @@
38
38
  "dist/core/agent-config.d.ts",
39
39
  "dist/core/agent-env.js",
40
40
  "dist/core/agent-env.d.ts",
41
+ "dist/core/secret-store.js",
42
+ "dist/core/secret-store.d.ts",
43
+ "dist/core/trusted-proxy.js",
44
+ "dist/core/trusted-proxy.d.ts",
45
+ "dist/core/usage.js",
46
+ "dist/core/usage.d.ts",
41
47
  "dist/core/session-store.js",
42
48
  "dist/core/session-store.d.ts",
43
49
  "dist/core/vector-index.js",
@@ -1,25 +0,0 @@
1
- ---
2
- name: composio-large-outputs
3
- description: How to retrieve a gateway tool's real output when its result says storedInFile is true instead of returning the data inline.
4
- ---
5
-
6
- # Gateway tools with a large result
7
-
8
- Some gateway tools (Composio-sourced ones in particular) return a small
9
- pointer object instead of their real output when the actual result is
10
- too large to return inline:
11
-
12
- ```
13
- {
14
- "successful": true,
15
- "storedInFile": true,
16
- "tokenCount": 10234,
17
- "outputFilePath": "/tmp/.../SOME_TOOL_OUTPUT_xxxxx.json"
18
- }
19
- ```
20
-
21
- When you see `storedInFile: true` in a tool result, the real data is
22
- **not** in that object — it's in the file at `outputFilePath`. Call
23
- `system_read_file` with that exact path to retrieve the actual content
24
- before answering. Don't guess at an answer, summarize from the pointer
25
- alone, or claim you don't have the data — read the file first.