loopengine 0.1.62 → 0.1.64
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 +37 -11
- package/dist/core/agent-env.d.ts +2 -8
- package/dist/core/agent-env.js +7 -19
- package/dist/core/model-calls/anthropic-model-call.js +2 -1
- package/dist/core/model-calls/deepseek-model-call.js +2 -1
- package/dist/core/model-calls/gemini-model-call.js +2 -1
- package/dist/core/model-calls/glm-model-call.js +2 -1
- package/dist/core/model-calls/kimi-model-call.js +2 -1
- package/dist/core/model-calls/openai-model-call.js +2 -1
- package/dist/core/run-agent.d.ts +6 -0
- package/dist/core/run-agent.js +20 -2
- package/dist/core/secret-store.d.ts +32 -0
- package/dist/core/secret-store.js +116 -0
- package/dist/core/trusted-proxy.d.ts +15 -0
- package/dist/core/trusted-proxy.js +47 -0
- package/dist/core/usage.d.ts +18 -0
- package/dist/core/usage.js +32 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/web/agents-config-page.js +228 -294
- package/dist/web/agents-list-page.js +80 -99
- package/dist/web/console-shell.d.ts +2 -0
- package/dist/web/console-shell.js +156 -0
- package/dist/web/dev-ui-styles.js +146 -40
- package/dist/web/env-admin.d.ts +26 -25
- package/dist/web/env-admin.js +39 -103
- package/dist/web/global-config-page.js +26 -80
- package/dist/web/playground.js +161 -105
- package/package.json +7 -1
- package/dist/core/system-skills/system-skills/composio-large-outputs/SKILL.md +0 -25
package/README.md
CHANGED
|
@@ -108,17 +108,25 @@ working examples.
|
|
|
108
108
|
|
|
109
109
|
## Admin UI
|
|
110
110
|
|
|
111
|
-
Run the HTTP adapter and open `http://localhost:8787/agents
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
Run the HTTP adapter and open `http://localhost:8787/agents` — one
|
|
112
|
+
console for every registered agent: a sidebar of agents (with a badge
|
|
113
|
+
when one still needs a setting), and for each agent a set of tabs. No
|
|
114
|
+
code changes or redeploy needed for most of it:
|
|
114
115
|
|
|
115
116
|
| Tab | What you can do |
|
|
116
117
|
| --- | --- |
|
|
117
|
-
| **
|
|
118
|
+
| **Chat** | Talk to the agent, approve or deny tool calls as they come up, and follow each step (model calls, tool calls, permission checks) in the Run trace. Past conversations are under History. |
|
|
119
|
+
| **Overview** | A card per tab (tools, permissions, skills, settings), plus the system prompt, model, hooks and limits, each editable in place. |
|
|
120
|
+
| **Tools** | Your hand-written tools, tools from integrations (e.g. [Composio](https://composio.dev)) and subagents, with whether each call runs on its own, asks first, or is denied. Build an HTTP tool or connect an integration without touching a file. |
|
|
118
121
|
| **Skills** | Create, edit, and delete `SKILL.md` files for this agent directly in the browser — write the body, preview the rendered markdown, save. |
|
|
119
|
-
| **
|
|
120
|
-
| **
|
|
121
|
-
| **Environment** | Every
|
|
122
|
+
| **Abilities** | Install, upgrade, and uninstall abilities (see [Ability system](#ability-system)). |
|
|
123
|
+
| **Permissions** | Add, edit, and delete permission rules (actauth) — who, tool, decision — and choose what happens when no rule matches. |
|
|
124
|
+
| **Environment** | Every setting an installed ability reads, with its project-wide value and this agent's own value side by side, and which one the agent actually uses. A secret is never shown again once set. |
|
|
125
|
+
|
|
126
|
+
Models and Integrations in the sidebar show which model providers have a
|
|
127
|
+
key set and which outside tool sources are connected. Tabs deep-link:
|
|
128
|
+
`/agents/config?agent=<name>&tab=tools`, `/playground?agent=<name>` for
|
|
129
|
+
Chat.
|
|
122
130
|
|
|
123
131
|
Every tab is backed by a real API (`GET /agents/:name/config`, `.../actauth`,
|
|
124
132
|
`.../env`, ...) that reuses the exact same resolution `runAgent()` itself
|
|
@@ -264,10 +272,11 @@ content-negotiated on the `Accept` header, so nothing that already calls
|
|
|
264
272
|
it as an API needs to change) to see every registered agent with links
|
|
265
273
|
into the playground and config page below.
|
|
266
274
|
|
|
267
|
-
**
|
|
268
|
-
`?agent=<name>` to preselect one) —
|
|
269
|
-
|
|
270
|
-
frames. Same `/messages/stream` route
|
|
275
|
+
**Chat:** open `http://localhost:8787/playground` (optionally
|
|
276
|
+
`?agent=<name>` to preselect one) — the console's Chat tab: talk to an
|
|
277
|
+
agent and watch the loop-step event stream render live in the Run trace
|
|
278
|
+
instead of reading raw SSE frames. Same `/messages/stream` route
|
|
279
|
+
underneath.
|
|
271
280
|
|
|
272
281
|
**Building your own client:** the playground is one UI on top of a typed
|
|
273
282
|
event protocol, not the only way to consume a turn — see
|
|
@@ -332,3 +341,20 @@ docker compose up --build
|
|
|
332
341
|
Builds the HTTP adapter into a container and starts it alongside Redis.
|
|
333
342
|
For production, push the image to any container platform with `REDIS_URL`
|
|
334
343
|
and your model/tool API keys set as environment variables.
|
|
344
|
+
|
|
345
|
+
### Hosting behind a platform
|
|
346
|
+
|
|
347
|
+
These settings are for running LoopEngine inside a hosting platform
|
|
348
|
+
(one workspace per user, behind a router that has already authenticated
|
|
349
|
+
them). All are off by default; a self-hosted install that doesn't set
|
|
350
|
+
them behaves exactly as described above.
|
|
351
|
+
|
|
352
|
+
| Setting | What it does |
|
|
353
|
+
| --- | --- |
|
|
354
|
+
| `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. |
|
|
355
|
+
| `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. |
|
|
356
|
+
| `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. |
|
|
357
|
+
| `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. |
|
|
358
|
+
|
|
359
|
+
`deploy/workspace/Dockerfile` is a runtime image for this: it serves a
|
|
360
|
+
scaffolded project mounted at `/workspace` instead of baking agents in.
|
package/dist/core/agent-env.d.ts
CHANGED
|
@@ -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`
|
|
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'`
|
package/dist/core/agent-env.js
CHANGED
|
@@ -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
|
|
7
|
-
// agent
|
|
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`
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/run-agent.d.ts
CHANGED
|
@@ -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
|
package/dist/core/run-agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|