loopengine 0.1.3 → 0.1.5

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,67 @@
1
+ import { getComposioAuthStatus } from '../core/gateway-tools.js';
2
+ // One entry per provider agent-config.ts's own AgentModelConfig union
3
+ // actually supports (see its own doc comment) — kept here, not derived
4
+ // from that type, since there's no runtime list to introspect a TS
5
+ // union from; adding a fourth provider means one more line here and one
6
+ // more arm in that union, not a rewrite.
7
+ const KNOWN_PROVIDERS = [
8
+ { provider: 'anthropic', envVar: 'ANTHROPIC_API_KEY' },
9
+ { provider: 'openai', envVar: 'OPENAI_API_KEY' },
10
+ { provider: 'deepseek', envVar: 'DEEPSEEK_API_KEY' },
11
+ ];
12
+ /** Every registered agent's resolved model, alongside whether each
13
+ * supported provider actually has credentials available in this
14
+ * process's environment — an operator's first question when a request
15
+ * fails isn't "what does this one agent request?" (Overview's own Model
16
+ * section already answers that) but "is this deployment even set up to
17
+ * call that provider at all?". Read-only: providers/env vars are wired
18
+ * into the actual model-call modules (model-calls/*.ts), not something
19
+ * this page can add support for by itself. An agent with a custom
20
+ * `createModelCall` (no `config.model`) is skipped — there's no
21
+ * provider/model pair to report for it, the same "custom" case
22
+ * describeAgent's own model field already carves out.
23
+ *
24
+ * Takes the registered agent names and a config lookup as parameters,
25
+ * rather than importing core/agent-registry.ts's listAgents/getEntry
26
+ * directly — that module is this repo's own reference app, not part of
27
+ * the published package (see index.ts's own header comment), and its
28
+ * top-level-await agent scan is resolved relative to *its own* file
29
+ * location, which would be meaningless run from inside node_modules.
30
+ * Every caller already has its own local registry's listAgents/getEntry
31
+ * on hand (adapters/http.ts, in this repo or a scaffolded project alike)
32
+ * to pass straight through. */
33
+ export function describeModelProviders(agentNames, getConfig) {
34
+ const providers = KNOWN_PROVIDERS.map(({ provider, envVar }) => ({
35
+ provider,
36
+ envVar,
37
+ configured: Boolean(process.env[envVar]),
38
+ }));
39
+ const agents = [];
40
+ for (const name of agentNames) {
41
+ const config = getConfig(name);
42
+ if (config?.model) {
43
+ agents.push({ agent: name, provider: config.model.provider, model: config.model.model ?? '(provider default)' });
44
+ }
45
+ }
46
+ return { providers, agents };
47
+ }
48
+ /** The provider-level counterpart to gateway-tools.ts's own registry
49
+ * (which is scoped to one agent's chosen tools within an already-
50
+ * connected provider) — this is "is Composio itself usable from this
51
+ * machine at all", not "what has agent X registered". Nango/Arcade/
52
+ * Scalekit are listed as not-yet-supported placeholders, matching
53
+ * gateway-tools.ts's own header comment about them slotting in later as
54
+ * thin mcpplug ToolSource adapters once this mechanism is proven —
55
+ * shown so the Gateways panel isn't silently missing them, not because
56
+ * there's anything to check for them yet. */
57
+ export async function describeGateways(cliCommand = 'composio') {
58
+ const composio = await getComposioAuthStatus(cliCommand);
59
+ return {
60
+ gateways: [
61
+ { provider: 'composio', supported: true, connected: composio.connected, email: composio.email, org: composio.org },
62
+ { provider: 'scalekit', supported: false, connected: false },
63
+ { provider: 'nango', supported: false, connected: false },
64
+ { provider: 'arcade', supported: false, connected: false },
65
+ ],
66
+ };
67
+ }
@@ -0,0 +1,37 @@
1
+ import type { ToolDefinition } from '../core/agent-config.js';
2
+ export declare class HttpToolNameError extends Error {
3
+ }
4
+ export declare class HttpToolExistsError extends Error {
5
+ }
6
+ export declare class HttpToolIndexShapeError extends Error {
7
+ }
8
+ export interface HttpToolField {
9
+ name: string;
10
+ type: 'string' | 'number' | 'boolean';
11
+ description?: string;
12
+ required: boolean;
13
+ }
14
+ export interface HttpToolSpec {
15
+ name: string;
16
+ description: string;
17
+ fields: HttpToolField[];
18
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
19
+ url: string;
20
+ headers: {
21
+ key: string;
22
+ value: string;
23
+ }[];
24
+ sendFieldsAsJsonBody: boolean;
25
+ responseJsonPath?: string;
26
+ }
27
+ /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
28
+ * that agent's tools/index.ts, then imports the file it just wrote and
29
+ * returns the real, callable ToolDefinition object — so the route
30
+ * handler (adapters/http.ts's POST /agents/:name/tools/http) can splice
31
+ * it straight into the live agent registry (core/agent-registry.ts's
32
+ * updateAgent) without guessing an export name or re-deriving anything
33
+ * this module already knows. */
34
+ export declare function createHttpTool(agentName: string, spec: HttpToolSpec): Promise<{
35
+ path: string;
36
+ tool: ToolDefinition;
37
+ }>;
@@ -0,0 +1,223 @@
1
+ // Lets an operator generate a real, hand-editable tool file from a
2
+ // constrained form — the Tools tab's fourth section, alongside Local
3
+ // tools/Subagents/Gateway Tools — instead of writing agents/<name>/
4
+ // tools/<tool>.ts by hand. Deliberately narrow: the only thing this
5
+ // generates is a parameterized HTTP call (method, a {field}-templated
6
+ // URL, headers, an optional JSON body/response-path mapping), never
7
+ // arbitrary code. A SKILL.md-style "store code as markdown, eval it at
8
+ // request time" version of this was considered and rejected: SKILL.md
9
+ // is safe specifically because its content is only ever read by the
10
+ // model, never executed — this generates a normal, readable, type-
11
+ // checked .ts file instead, the same class of artifact a human would
12
+ // have written by hand, just templated for one well-understood shape.
13
+ //
14
+ // Field/URL/header substitution is compiled into the *generated code*
15
+ // (a template literal with ${...} interpolations), not resolved at
16
+ // generation time — so a value is only ever combined with the URL via
17
+ // real encodeURIComponent, at call time, off whatever the model actually
18
+ // passed as input. A header value may also reference {{ENV_VAR}} — the
19
+ // generated code reads that from process.env at call time; the literal
20
+ // secret itself is never accepted by this module, persisted to the
21
+ // generated file, or echoed back through the admin API, the same
22
+ // "always env-sourced, never a config literal" rule SendEmail/
23
+ // HttpNotifierConfig's own secrets already follow (core/agent-config.ts).
24
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+ import { agentDir } from '../core/gateway-tools.js';
28
+ export class HttpToolNameError extends Error {
29
+ }
30
+ export class HttpToolExistsError extends Error {
31
+ }
32
+ export class HttpToolIndexShapeError extends Error {
33
+ }
34
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
35
+ const FIELD_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
36
+ const ENV_VAR_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
37
+ // Same escaping bin/cli.ts's own local tsStringLiteral uses for a
38
+ // single-quoted literal — duplicated here rather than imported, since
39
+ // it's two lines and not worth a cross-module export for.
40
+ function tsStringLiteral(value) {
41
+ return "'" + value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
42
+ }
43
+ // snake_case tool name -> camelCase export identifier, e.g.
44
+ // "lookup_order_status" -> "lookupOrderStatus" — matches every
45
+ // hand-written tool file's own convention (see agents/customer-service/
46
+ // tools/*.ts: issue_refund.ts exports `issueRefund`, etc.).
47
+ function toCamelCase(snakeCase) {
48
+ return snakeCase.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
49
+ }
50
+ // Escapes a literal segment for safe interpolation inside a generated
51
+ // JS template literal (backtick string) — the three characters that
52
+ // would otherwise break out of it or start a nested interpolation.
53
+ function escapeTemplateLiteralSegment(segment) {
54
+ return segment.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
55
+ }
56
+ /** Compiles a "{field}" template string into the *inside* of a JS
57
+ * template literal (caller wraps it in backticks) — each {field}
58
+ * becomes `${wrap(fieldExpr)}`, everything else is a literal segment,
59
+ * escaped for backtick safety. Throws if a referenced field isn't one
60
+ * of `fieldNames` — a typo'd placeholder should fail at generation
61
+ * time, not silently produce "undefined" in a live request later. */
62
+ function compileFieldTemplate(template, fieldNames, wrap) {
63
+ let out = '';
64
+ let lastIndex = 0;
65
+ const pattern = /\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
66
+ let match;
67
+ while ((match = pattern.exec(template))) {
68
+ out += escapeTemplateLiteralSegment(template.slice(lastIndex, match.index));
69
+ const field = match[1];
70
+ if (!fieldNames.has(field)) {
71
+ throw new HttpToolNameError(`Template references {${field}}, which isn't one of this tool's own fields.`);
72
+ }
73
+ out += '${' + wrap(`input.${field}`) + '}';
74
+ lastIndex = pattern.lastIndex;
75
+ }
76
+ out += escapeTemplateLiteralSegment(template.slice(lastIndex));
77
+ return out;
78
+ }
79
+ function generateToolCode(spec) {
80
+ const fieldNames = new Set(spec.fields.map((f) => f.name));
81
+ for (const field of spec.fields) {
82
+ if (!FIELD_NAME_PATTERN.test(field.name)) {
83
+ throw new HttpToolNameError(`Field name "${field.name}" must be a valid identifier (letters, digits, underscore, not starting with a digit).`);
84
+ }
85
+ }
86
+ // Every distinct {{ENV_VAR}} referenced across header values becomes
87
+ // one `const NAME = process.env.NAME` read (with a clear failure if
88
+ // unset), generated once up front — cleaner than inlining a
89
+ // read-and-check expression at every point of use, and it means a
90
+ // missing secret fails loudly the moment the tool actually runs, not
91
+ // silently as an empty header value.
92
+ const envVars = new Set();
93
+ const headerEntries = spec.headers.map(({ key, value }) => {
94
+ let compiled = '';
95
+ let lastIndex = 0;
96
+ const pattern = /\{\{([A-Z_][A-Z0-9_]*)\}\}|\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g;
97
+ let match;
98
+ while ((match = pattern.exec(value))) {
99
+ compiled += escapeTemplateLiteralSegment(value.slice(lastIndex, match.index));
100
+ if (match[1]) {
101
+ if (!ENV_VAR_PATTERN.test(match[1])) {
102
+ throw new HttpToolNameError(`"{{${match[1]}}}" isn't a valid env var name (uppercase letters, digits, underscore).`);
103
+ }
104
+ envVars.add(match[1]);
105
+ compiled += '${' + match[1] + '}';
106
+ }
107
+ else {
108
+ const field = match[2];
109
+ if (!fieldNames.has(field)) {
110
+ throw new HttpToolNameError(`Header "${key}" references {${field}}, which isn't one of this tool's own fields.`);
111
+ }
112
+ compiled += '${String(input.' + field + ')}';
113
+ }
114
+ lastIndex = pattern.lastIndex;
115
+ }
116
+ compiled += escapeTemplateLiteralSegment(value.slice(lastIndex));
117
+ return { key, compiled };
118
+ });
119
+ const urlInner = compileFieldTemplate(spec.url, fieldNames, (expr) => `encodeURIComponent(String(${expr}))`);
120
+ const properties = spec.fields
121
+ .map((f) => ` ${JSON.stringify(f.name)}: { type: ${JSON.stringify(f.type)}${f.description ? `, description: ${JSON.stringify(f.description)}` : ''} },`)
122
+ .join('\n');
123
+ const required = spec.fields.filter((f) => f.required).map((f) => JSON.stringify(f.name));
124
+ const envReads = [...envVars]
125
+ .map((name) => ` const ${name} = process.env.${name}\n if (!${name}) throw new Error(${tsStringLiteral(`${spec.name}: ${name} is not set`)})\n`)
126
+ .join('');
127
+ const headersObj = headerEntries.length === 0
128
+ ? ''
129
+ : ` headers: {\n${headerEntries.map(({ key, compiled }) => ` ${JSON.stringify(key)}: \`${compiled}\`,`).join('\n')}\n },\n`;
130
+ const bodyLine = spec.sendFieldsAsJsonBody && spec.method !== 'GET' && spec.method !== 'DELETE' ? ' body: JSON.stringify(input),\n' : '';
131
+ const responseHandling = spec.responseJsonPath
132
+ ? (() => {
133
+ const segments = spec.responseJsonPath.split('.').filter(Boolean);
134
+ for (const seg of segments) {
135
+ if (!FIELD_NAME_PATTERN.test(seg)) {
136
+ throw new HttpToolNameError(`responseJsonPath segment "${seg}" must be a valid identifier.`);
137
+ }
138
+ }
139
+ const access = segments.map((s) => `?.${s}`).join('');
140
+ return ` const body = await res.json()\n return JSON.stringify(body${access} ?? null)\n`;
141
+ })()
142
+ : ' return await res.text()\n';
143
+ return `// Generated by the Admin UI's HTTP tool builder (Tools tab) — a
144
+ // parameterized HTTP call, not hand-written logic. Edit freely for
145
+ // anything this template can't express; the Admin UI only ever
146
+ // regenerates a tool it created itself, never overwrites a file it
147
+ // doesn't recognize (see web/http-tool-admin.ts's own HttpToolExistsError).
148
+ import type { ToolDefinition } from 'loopengine'
149
+
150
+ export const ${toCamelCase(spec.name)}: ToolDefinition = {
151
+ name: ${tsStringLiteral(spec.name)},
152
+ description: ${tsStringLiteral(spec.description)},
153
+ input_schema: {
154
+ type: 'object',
155
+ properties: {
156
+ ${properties}
157
+ },
158
+ required: [${required.join(', ')}],
159
+ },
160
+ execute: async (input) => {
161
+ ${envReads} const res = await fetch(\`${urlInner}\`, {
162
+ method: ${tsStringLiteral(spec.method)},
163
+ ${headersObj}${bodyLine} })
164
+ if (!res.ok) throw new Error(\`${spec.name}: HTTP \${res.status}\`)
165
+ ${responseHandling} },
166
+ }
167
+ `;
168
+ }
169
+ // tools/index.ts's own shape is deliberately simple and consistent
170
+ // across every real example in this repo (agents/customer-service/
171
+ // tools/index.ts, create-loopengine's own template) — a handful of
172
+ // named imports, then one `export const tools: ToolDefinition[] = [...]`
173
+ // array literal. This patches that exact shape with two small string
174
+ // edits (one new import line, one new array entry) rather than a full
175
+ // TS-AST rewrite; if the file has been hand-edited into something this
176
+ // pattern doesn't recognize, it refuses instead of risking corrupting it
177
+ // — same "refuse rather than guess" discipline agent-file-admin.ts's own
178
+ // doc comment already establishes for index.ts edits.
179
+ function addToolToIndex(toolsIndexPath, toolFileName, exportName) {
180
+ const source = readFileSync(toolsIndexPath, 'utf8');
181
+ const arrayMatch = source.match(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/);
182
+ if (!arrayMatch) {
183
+ throw new HttpToolIndexShapeError(`${toolsIndexPath} doesn't match the expected "export const tools: ToolDefinition[] = [...]" shape — add this tool to it by hand instead.`);
184
+ }
185
+ const importLine = `import { ${exportName} } from './${toolFileName}.js'\n`;
186
+ const lastImportMatch = [...source.matchAll(/^import .+\n/gm)].pop();
187
+ const insertAt = lastImportMatch ? lastImportMatch.index + lastImportMatch[0].length : 0;
188
+ const withImport = source.slice(0, insertAt) + importLine + source.slice(insertAt);
189
+ const existingEntries = arrayMatch[1].trim();
190
+ const newEntries = existingEntries ? `${existingEntries}, ${exportName}` : exportName;
191
+ const withEntry = withImport.replace(/export const tools: ToolDefinition\[\] = \[([^\]]*)\]/, `export const tools: ToolDefinition[] = [${newEntries}]`);
192
+ writeFileSync(toolsIndexPath, withEntry);
193
+ }
194
+ /** Generates agents/<agentName>/tools/<spec.name>.ts, registers it in
195
+ * that agent's tools/index.ts, then imports the file it just wrote and
196
+ * returns the real, callable ToolDefinition object — so the route
197
+ * handler (adapters/http.ts's POST /agents/:name/tools/http) can splice
198
+ * it straight into the live agent registry (core/agent-registry.ts's
199
+ * updateAgent) without guessing an export name or re-deriving anything
200
+ * this module already knows. */
201
+ export async function createHttpTool(agentName, spec) {
202
+ if (!TOOL_NAME_PATTERN.test(spec.name)) {
203
+ throw new HttpToolNameError(`Tool name must be lowercase snake_case (e.g. "lookup_order_status") — got "${spec.name}"`);
204
+ }
205
+ const toolsDir = join(agentDir(agentName), 'tools');
206
+ const toolPath = join(toolsDir, `${spec.name}.ts`);
207
+ if (existsSync(toolPath)) {
208
+ throw new HttpToolExistsError(`agents/${agentName}/tools/${spec.name}.ts already exists — pick a different name or edit it directly.`);
209
+ }
210
+ const code = generateToolCode(spec);
211
+ mkdirSync(toolsDir, { recursive: true });
212
+ writeFileSync(toolPath, code);
213
+ const indexPath = join(toolsDir, 'index.ts');
214
+ if (existsSync(indexPath)) {
215
+ addToolToIndex(indexPath, spec.name, toCamelCase(spec.name));
216
+ }
217
+ else {
218
+ writeFileSync(indexPath, `import type { ToolDefinition } from 'loopengine'\nimport { ${toCamelCase(spec.name)} } from './${spec.name}.js'\n\nexport const tools: ToolDefinition[] = [${toCamelCase(spec.name)}]\n`);
219
+ }
220
+ const exportName = toCamelCase(spec.name);
221
+ const mod = (await import(pathToFileURL(toolPath).href));
222
+ return { path: toolPath, tool: mod[exportName] };
223
+ }
@@ -0,0 +1 @@
1
+ export declare const playgroundHtml: string;