loopengine 0.1.2 → 0.1.4

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.
@@ -0,0 +1,165 @@
1
+ // Lets an operator edit an already-registered agent's systemPrompt/model
2
+ // directly from /agents/config's Overview tab, persisting the change to
3
+ // agents/<name>/index.ts itself — unlike gateway-tools.yml/actauth.yml/
4
+ // SKILL.md (plain data, safe to fully regenerate or edit via a
5
+ // dedicated Document API), an agent's index.ts is real TypeScript source
6
+ // that can also carry custom imports, a hand-written createModelCall,
7
+ // other AgentConfig fields, and comments an operator cares about. This
8
+ // edits via the TypeScript compiler API's AST plus exact source
9
+ // character offsets — the closest equivalent this repo has to `yaml`'s
10
+ // own Document API (parse -> locate the exact node -> splice its source
11
+ // text -> nothing else in the file changes) for TS source, since there's
12
+ // no ready-made "TS Document API" the way there is for YAML.
13
+ //
14
+ // Deliberately conservative: refuses, with a clear error, to touch a
15
+ // field whose *current* value isn't a plain, recognizable shape
16
+ // (systemPrompt: a string literal; model: an inline object literal with
17
+ // string-literal provider/model) rather than guess at rewriting anything
18
+ // more complex — a hand-customized agent (a template literal
19
+ // systemPrompt, a custom createModelCall instead of config.model, ...)
20
+ // is exactly the case this must never touch blindly.
21
+ //
22
+ // Deliberately lives outside bin/cli.ts, even though it reuses bin/cli.ts's own
23
+ // AgentModelError/Provider shape: bin/cli.ts is published as loopengine's
24
+ // own CLI binary (dist/bin/cli.js — see package.json's own "files" list),
25
+ // and this module's use of the `typescript` package (a devDependency,
26
+ // not a real dependency, of this package) would risk breaking
27
+ // `npx loopengine <command>` for any consumer whose own project doesn't
28
+ // happen to also have typescript installed. This module (like
29
+ // skills-admin.ts/actauth-admin.ts/global-config.ts before it) is only
30
+ // ever imported by adapters/http.ts, which itself is never published as
31
+ // part of the loopengine package — it's project-owned source a
32
+ // scaffolded project gets its own copy of, where `typescript` is already
33
+ // a guaranteed devDependency (see create-loopengine's own template
34
+ // package.json).
35
+ import { readFileSync, writeFileSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import ts from 'typescript';
38
+ import { agentDir } from '../core/gateway-tools.js';
39
+ import { AgentModelError } from '../bin/cli.js';
40
+ export class AgentEditNotSupportedError extends Error {
41
+ }
42
+ export class AgentFileNotFoundError extends Error {
43
+ }
44
+ const MODEL_ENV_VAR = {
45
+ anthropic: 'ANTHROPIC_API_KEY',
46
+ openai: 'OPENAI_API_KEY',
47
+ deepseek: 'DEEPSEEK_API_KEY',
48
+ };
49
+ // The exact shape agentIndexTemplate (bin/cli.ts) always generates for the
50
+ // model field's trailing comment — used to recognize a comment this
51
+ // tooling itself wrote (safe to correct) versus anything else an
52
+ // operator might have hand-written there (left alone — see
53
+ // updateModelComment's own doc comment).
54
+ const MODEL_COMMENT_PATTERN = /^\/\/\s*reads\s+[A-Z_]+$/;
55
+ // Escapes a value for safe interpolation into a single-quoted TS string
56
+ // literal in generated code — same reasoning (and same escaping) as
57
+ // bin/cli.ts's own tsStringLiteral, duplicated rather than imported since
58
+ // bin/cli.ts's copy is a private, unexported helper and this is the only
59
+ // other place that needs it.
60
+ function tsStringLiteral(value) {
61
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n') + "'";
62
+ }
63
+ // ts.getTrailingCommentRanges only looks for a comment starting exactly
64
+ // at `pos`, skipping whitespace but not other tokens — a trailing comma
65
+ // right after the model property's own end (the common case: model
66
+ // usually isn't the last property) sits in the way, so this retries one
67
+ // character further in if the first attempt found nothing and that
68
+ // character is a comma. Confirmed both cases live against a real parse
69
+ // before landing this — `getTrailingCommentRanges` genuinely returns
70
+ // undefined at the property's own end when a comma follows.
71
+ function trailingCommentAfter(source, pos) {
72
+ const ranges = ts.getTrailingCommentRanges(source, pos) ?? (source[pos] === ',' ? ts.getTrailingCommentRanges(source, pos + 1) : undefined);
73
+ return ranges?.[0];
74
+ }
75
+ function findConfigObjectLiteral(sourceFile) {
76
+ for (const stmt of sourceFile.statements) {
77
+ if (!ts.isVariableStatement(stmt))
78
+ continue;
79
+ if (!stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword))
80
+ continue;
81
+ for (const decl of stmt.declarationList.declarations) {
82
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'config' && decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
83
+ return decl.initializer;
84
+ }
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+ function findProperty(obj, name) {
90
+ return obj.properties.find((p) => p.name !== undefined && ts.isIdentifier(p.name) && p.name.text === name);
91
+ }
92
+ /** Surgically rewrites just the fields given in `fields` — see this
93
+ * file's own header comment for why, and for the "refuse rather than
94
+ * guess" rule this follows for anything that isn't a plain, recognizable
95
+ * shape. Returns the values actually written (model's own default
96
+ * resolution included), so the caller (adapters/http.ts's
97
+ * handleEditAgent) can apply the exact same values to the *live*
98
+ * registry entry — see core/agent-registry.ts's own updateAgent — without
99
+ * needing to re-derive them. A field omitted from `fields` entirely is
100
+ * left completely untouched, both on disk and in the return value. */
101
+ export function editAgentFile(agentName, fields) {
102
+ const indexPath = join(agentDir(agentName), 'index.ts');
103
+ let source;
104
+ try {
105
+ source = readFileSync(indexPath, 'utf8');
106
+ }
107
+ catch {
108
+ throw new AgentFileNotFoundError(`agents/${agentName}/index.ts not found.`);
109
+ }
110
+ const sourceFile = ts.createSourceFile(indexPath, source, ts.ScriptTarget.Latest, true);
111
+ const configObj = findConfigObjectLiteral(sourceFile);
112
+ if (!configObj) {
113
+ throw new AgentEditNotSupportedError('Could not find "export const config: AgentConfig = { ... }" in this file — edit it directly.');
114
+ }
115
+ const edits = [];
116
+ const result = {};
117
+ if (fields.systemPrompt !== undefined) {
118
+ const prop = findProperty(configObj, 'systemPrompt');
119
+ if (!prop || !ts.isPropertyAssignment(prop) || !ts.isStringLiteral(prop.initializer)) {
120
+ throw new AgentEditNotSupportedError('systemPrompt in this file is not a plain string literal — edit it directly.');
121
+ }
122
+ edits.push({ start: prop.initializer.getStart(sourceFile), end: prop.initializer.getEnd(), text: tsStringLiteral(fields.systemPrompt) });
123
+ result.systemPrompt = fields.systemPrompt;
124
+ }
125
+ if (fields.model !== undefined) {
126
+ const prop = findProperty(configObj, 'model');
127
+ if (!prop || !ts.isPropertyAssignment(prop) || !ts.isObjectLiteralExpression(prop.initializer)) {
128
+ throw new AgentEditNotSupportedError('model in this file is not a plain inline object — it may use a custom createModelCall; edit it directly.');
129
+ }
130
+ const provider = fields.model.provider;
131
+ const modelName = fields.model.model?.trim() || (provider === 'anthropic' ? 'claude-sonnet-5' : '');
132
+ if (provider !== 'anthropic' && !modelName) {
133
+ throw new AgentModelError(`A model name is required for provider '${provider}' — only anthropic has a default (claude-sonnet-5).`);
134
+ }
135
+ edits.push({
136
+ start: prop.initializer.getStart(sourceFile),
137
+ end: prop.initializer.getEnd(),
138
+ text: `{ provider: '${provider}', model: ${tsStringLiteral(modelName)} }`,
139
+ });
140
+ result.model = { provider, model: modelName };
141
+ // The property's own value is now correct, but a stale
142
+ // "// reads ANTHROPIC_API_KEY" sitting right after it (from before a
143
+ // provider change) would be actively misleading, not just cosmetic —
144
+ // it'd point an operator at the wrong env var. Only touched if it
145
+ // still matches the exact shape agentIndexTemplate itself generates
146
+ // (see MODEL_COMMENT_PATTERN) — a hand-written comment in that spot
147
+ // is left alone, same "don't guess" rule the value itself follows.
148
+ const comment = trailingCommentAfter(source, prop.getEnd());
149
+ if (comment && MODEL_COMMENT_PATTERN.test(source.slice(comment.pos, comment.end))) {
150
+ edits.push({ start: comment.pos, end: comment.end, text: `// reads ${MODEL_ENV_VAR[provider]}` });
151
+ }
152
+ }
153
+ if (!edits.length)
154
+ return result;
155
+ // Apply from the end of the file backward — each edit's start/end was
156
+ // computed against the *original* source, so splicing front-to-back
157
+ // would shift every later offset out from under the next edit.
158
+ edits.sort((a, b) => b.start - a.start);
159
+ let updated = source;
160
+ for (const edit of edits) {
161
+ updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
162
+ }
163
+ writeFileSync(indexPath, updated);
164
+ return result;
165
+ }
@@ -0,0 +1 @@
1
+ export declare const agentsConfigPageHtml: string;