create-loopengine 0.1.77 → 0.2.0
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/package.json +1 -1
- package/template/adapters/http.ts +481 -8
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// file's call — see AgentConfig.sessionIdFor and defaultSessionIdFor below.
|
|
30
30
|
import { execFile } from 'node:child_process'
|
|
31
31
|
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
32
|
-
import { createReadStream, existsSync } from 'node:fs'
|
|
32
|
+
import { createReadStream, existsSync, readFileSync, rmSync } from 'node:fs'
|
|
33
33
|
import { stat } from 'node:fs/promises'
|
|
34
34
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
35
35
|
import { extname, join, resolve as resolvePath, sep } from 'node:path'
|
|
@@ -183,6 +183,7 @@ import {
|
|
|
183
183
|
removeAbility,
|
|
184
184
|
listInstalledAbilities,
|
|
185
185
|
backfillDependencies,
|
|
186
|
+
configureAgentMcpTools,
|
|
186
187
|
AbilityAlreadyInstalledError,
|
|
187
188
|
AbilityCollisionError,
|
|
188
189
|
AbilityManifestError,
|
|
@@ -195,13 +196,44 @@ import {
|
|
|
195
196
|
removeAbilitySourceFromConsole,
|
|
196
197
|
saveAbilitySourceFromConsole,
|
|
197
198
|
abilityDetails,
|
|
199
|
+
WorkflowError,
|
|
200
|
+
createWorkflow,
|
|
201
|
+
forgetWorkflow,
|
|
202
|
+
getPlanJob,
|
|
203
|
+
listAgentWorkflows,
|
|
204
|
+
planWorkflow,
|
|
205
|
+
runWorkflowNow,
|
|
206
|
+
startPlanJob,
|
|
207
|
+
type WorkflowAgentInfo,
|
|
208
|
+
type WorkflowMcpDeps,
|
|
209
|
+
workflowPageHtml,
|
|
198
210
|
MAX_UPLOAD_BYTES,
|
|
199
211
|
installUploadedAbility,
|
|
200
212
|
installWorkspaceAbility,
|
|
201
213
|
type AbilityUpload,
|
|
202
214
|
WorkspaceAbilityError,
|
|
215
|
+
addWorkspaceAbility,
|
|
203
216
|
listWorkspaceAbilities,
|
|
204
217
|
removeWorkspaceAbility,
|
|
218
|
+
getMcpServer,
|
|
219
|
+
searchMcpServers,
|
|
220
|
+
isMcpAuthError,
|
|
221
|
+
listMcpServerTools,
|
|
222
|
+
resolveMcpManifest,
|
|
223
|
+
McpAbilityGenerationError,
|
|
224
|
+
generateMcpAbility,
|
|
225
|
+
mcpAbilityNames,
|
|
226
|
+
readAbilityDetails,
|
|
227
|
+
AppToolsError,
|
|
228
|
+
appCatalog,
|
|
229
|
+
appServer,
|
|
230
|
+
appTools,
|
|
231
|
+
connectedApps,
|
|
232
|
+
isAppAbility,
|
|
233
|
+
loadAppIcons,
|
|
234
|
+
refreshAppTools,
|
|
235
|
+
setAppDefault,
|
|
236
|
+
type AgentAppTools,
|
|
205
237
|
AbilitySourceError,
|
|
206
238
|
abilityOrigin,
|
|
207
239
|
readAbilitySources,
|
|
@@ -522,7 +554,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
522
554
|
skillIndexBudgetTokens: config.skillIndexBudgetTokens ?? 2000,
|
|
523
555
|
skillsDirs: skillsDirs,
|
|
524
556
|
// How many abilities are installed — just the count, for the console's
|
|
525
|
-
// tab badge; the
|
|
557
|
+
// tab badge; the Apps tab's own GET .../abilities has the detail
|
|
526
558
|
// (and its slower npm version lookups).
|
|
527
559
|
abilityCount: listInstalledAbilities(config.name).length,
|
|
528
560
|
skills: skillIndex.map((s) => ({ name: s.name, description: s.description })),
|
|
@@ -1014,6 +1046,418 @@ async function handleInterviews(req: IncomingMessage, res: ServerResponse, pathn
|
|
|
1014
1046
|
}
|
|
1015
1047
|
}
|
|
1016
1048
|
|
|
1049
|
+
// "Describe a workflow" (loopengine's web/workflow-admin.ts): what the planner and the
|
|
1050
|
+
// checks know about each agent: its tools, abilities, rules and primary
|
|
1051
|
+
// messaging app.
|
|
1052
|
+
async function workflowAgentInfo(): Promise<WorkflowAgentInfo[]> {
|
|
1053
|
+
return Promise.all(
|
|
1054
|
+
listAgents().flatMap((name) => {
|
|
1055
|
+
const entry = getEntry(name)
|
|
1056
|
+
if (!entry) return []
|
|
1057
|
+
return [(async (): Promise<WorkflowAgentInfo> => {
|
|
1058
|
+
const { rules, defaultDecision } = readActauthConfig(name)
|
|
1059
|
+
// Abilities by default: gateway tools (Composio and the like) are
|
|
1060
|
+
// left out, so a workflow is never built on one.
|
|
1061
|
+
let tools: string[] = []
|
|
1062
|
+
let gateway = new Set<string>()
|
|
1063
|
+
try {
|
|
1064
|
+
gateway = new Set((await loadGatewayToolsFromDir(agentDir(name))).map((t) => t.name))
|
|
1065
|
+
const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
|
|
1066
|
+
const all = [...own, ...(await loadSubagentAsTools(entry.config))]
|
|
1067
|
+
tools = [...new Set(all.map((t) => t.name))].filter((t) => !gateway.has(t))
|
|
1068
|
+
} catch {}
|
|
1069
|
+
let abilities: string[] = []
|
|
1070
|
+
try {
|
|
1071
|
+
abilities = listInstalledAbilities(name).map((a) => a.name)
|
|
1072
|
+
} catch {}
|
|
1073
|
+
let messaging: string | null = null
|
|
1074
|
+
try {
|
|
1075
|
+
const n = describeAgentNotifier(entry.config, defaultWebhook)
|
|
1076
|
+
messaging = n.source === 'none' ? null : (n.primary ?? n.channel ?? null)
|
|
1077
|
+
} catch {}
|
|
1078
|
+
const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
|
|
1079
|
+
return {
|
|
1080
|
+
name,
|
|
1081
|
+
tools,
|
|
1082
|
+
abilities,
|
|
1083
|
+
rules: rules.flatMap((r) => (decided(r.decision) && !gateway.has(r.tool) ? [{ tool: r.tool, decision: r.decision, scope: r.scope }] : [])),
|
|
1084
|
+
defaultDecision: decided(defaultDecision) ? defaultDecision : 'ask',
|
|
1085
|
+
messaging,
|
|
1086
|
+
}
|
|
1087
|
+
})()]
|
|
1088
|
+
}),
|
|
1089
|
+
)
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Steps no ability can do can use an app's MCP server
|
|
1093
|
+
// (core/mcp-directory.ts), made into a private ability on Create.
|
|
1094
|
+
const workflowMcp: WorkflowMcpDeps = {
|
|
1095
|
+
getServer: getMcpServer,
|
|
1096
|
+
// The workspace ability already using the server at `url`.
|
|
1097
|
+
existingFor(url) {
|
|
1098
|
+
for (const found of listWorkspaceAbilities()) {
|
|
1099
|
+
try {
|
|
1100
|
+
const manifest = JSON.parse(readFileSync(join(found.spec.replace(/^file:/, ''), 'loopengine.ability.json'), 'utf8')) as { mcp?: { url?: string } }
|
|
1101
|
+
if (found.source !== `mcp:${url}` && manifest.mcp?.url !== url) continue
|
|
1102
|
+
return { spec: found.spec, tools: resolveMcpManifest(found.name, manifest.mcp).tools }
|
|
1103
|
+
} catch {}
|
|
1104
|
+
}
|
|
1105
|
+
return undefined
|
|
1106
|
+
},
|
|
1107
|
+
keyValue: (name) => process.env[name],
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** A server's tools for the planner, or why they can't be seen yet. */
|
|
1111
|
+
async function workflowMcpServerTools(id: string): Promise<unknown> {
|
|
1112
|
+
const server = await getMcpServer(id)
|
|
1113
|
+
if (!server) return `No MCP server ${id}.`
|
|
1114
|
+
const { keyEnv } = mcpAbilityNames(server)
|
|
1115
|
+
const key = process.env[keyEnv]
|
|
1116
|
+
try {
|
|
1117
|
+
const tools = await listMcpServerTools(
|
|
1118
|
+
{ url: server.url, ...(server.transport === 'sse' ? { transport: 'sse' as const } : {}), auth: key ? { type: 'key', env: keyEnv, ...(server.auth ?? {}) } : { type: 'none' } },
|
|
1119
|
+
{ get: (n) => (n === keyEnv ? key : undefined), require: (n) => { if (n === keyEnv && key) return key; throw new Error(`${n} is not set`) } },
|
|
1120
|
+
)
|
|
1121
|
+
return { server: server.title, tools: tools.slice(0, 80).map((t) => ({ name: t.name, description: t.description.split('\n')[0].slice(0, 200), ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}) })) }
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1124
|
+
return isMcpAuthError(err)
|
|
1125
|
+
? `${server.title} needs a key before its tools can be seen. Plan its steps by what they do (leave "tool" empty); the person adds the key on the plan, and its tools are picked on Create.`
|
|
1126
|
+
: `Couldn't reach ${server.title}: ${message.slice(0, 200)}`
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Each agent's tools from the app `abilityName`, with the permission
|
|
1131
|
+
* each has there (its first rule for the tool). */
|
|
1132
|
+
function agentAppTools(abilityName: string): AgentAppTools {
|
|
1133
|
+
const out: AgentAppTools = {}
|
|
1134
|
+
for (const agent of listAgents()) {
|
|
1135
|
+
let record: ReturnType<typeof listInstalledAbilities>[number] | undefined
|
|
1136
|
+
try {
|
|
1137
|
+
record = listInstalledAbilities(agent).find((a) => a.name === abilityName && a.mcp)
|
|
1138
|
+
} catch {}
|
|
1139
|
+
if (!record?.mcp) continue
|
|
1140
|
+
const rules = readActauthConfig(agent).rules
|
|
1141
|
+
const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
|
|
1142
|
+
out[agent] = record.mcp.tools.map((t) => {
|
|
1143
|
+
const d = rules.find((r) => r.tool === t.name)?.decision
|
|
1144
|
+
return { remote: t.remote, decision: decided(d) ? d : 'ask' }
|
|
1145
|
+
})
|
|
1146
|
+
}
|
|
1147
|
+
return out
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** Adds more of an app's tools to an agent that has it, each at the
|
|
1151
|
+
* app's default permission, keeping the agent's current ones. */
|
|
1152
|
+
function addAgentAppTools(agent: string, spec: string, tools: string[]): void {
|
|
1153
|
+
const dir = spec.replace(/^file:/, '')
|
|
1154
|
+
const name = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { name: string }).name
|
|
1155
|
+
const current = agentAppTools(name)[agent] ?? []
|
|
1156
|
+
const defaults = readAbilityDetails(dir).tools
|
|
1157
|
+
const manifest = JSON.parse(readFileSync(join(dir, 'loopengine.ability.json'), 'utf8')) as { mcp?: unknown }
|
|
1158
|
+
const all = resolveMcpManifest(name, manifest.mcp).tools
|
|
1159
|
+
const choice = [...current]
|
|
1160
|
+
for (const remote of tools) {
|
|
1161
|
+
if (choice.some((c) => c.remote === remote)) continue
|
|
1162
|
+
const installed = all.find((t) => t.remote === remote)?.name
|
|
1163
|
+
const d = defaults.find((t) => t.name === installed)?.decision
|
|
1164
|
+
choice.push({ remote, decision: d === 'allow' || d === 'deny' ? d : 'ask' })
|
|
1165
|
+
}
|
|
1166
|
+
configureAgentMcpTools(agent, name, dir, choice)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** For a template or workflow: the connected app's workspace spec, or
|
|
1170
|
+
* undefined when it isn't connected. Its tools are chosen at install. */
|
|
1171
|
+
async function prepareWorkspaceApp(id: string): Promise<string | undefined> {
|
|
1172
|
+
const server = await appServer(id)
|
|
1173
|
+
return describeApps().connected.find((c) => c.id === id || (server && c.url === server.url))?.spec
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function describeApps(): { apps: ReturnType<typeof appCatalog>; connected: ReturnType<typeof connectedApps>; canConnect: boolean } {
|
|
1177
|
+
const agentAbilities: Record<string, string[]> = {}
|
|
1178
|
+
for (const name of listAgents()) {
|
|
1179
|
+
try {
|
|
1180
|
+
agentAbilities[name] = listInstalledAbilities(name).map((a) => a.name)
|
|
1181
|
+
} catch {}
|
|
1182
|
+
}
|
|
1183
|
+
const connected = connectedApps(listWorkspaceAbilities(), agentAbilities, (name) => process.env[name])
|
|
1184
|
+
return { apps: appCatalog(connected), connected, canConnect: secretsProtected }
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function handleAgentApp(req: IncomingMessage, res: ServerResponse, agent: string, ability: string): Promise<void> {
|
|
1188
|
+
const json = (status: number, body: unknown): void => {
|
|
1189
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1190
|
+
}
|
|
1191
|
+
try {
|
|
1192
|
+
if (!getEntry(agent)) return json(404, { error: `unknown agent '${agent}'` })
|
|
1193
|
+
const app = describeApps().connected.find((c) => c.ability === ability)
|
|
1194
|
+
if (!app) return json(400, { error: 'That app isn\'t connected.' })
|
|
1195
|
+
const dir = app.spec.replace(/^file:/, '')
|
|
1196
|
+
const envValue = (name: string) => process.env[name]
|
|
1197
|
+
const view = async () => {
|
|
1198
|
+
const mine = agentAppTools(ability)[agent]
|
|
1199
|
+
const all = await appTools(app.spec, envValue, {})
|
|
1200
|
+
return {
|
|
1201
|
+
app: { title: app.title, ability },
|
|
1202
|
+
installed: Boolean(mine),
|
|
1203
|
+
tools: all.map((t) => {
|
|
1204
|
+
const chosen = mine?.find((m) => m.remote === t.remote)
|
|
1205
|
+
return { remote: t.remote, name: t.name, description: t.description, ...(t.readOnly !== undefined ? { readOnly: t.readOnly } : {}), ...(t.destructive ? { destructive: true } : {}), default: t.decision, selected: Boolean(chosen), decision: chosen?.decision ?? t.decision }
|
|
1206
|
+
}),
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (req.method === 'GET') return json(200, await view())
|
|
1210
|
+
if (req.method !== 'PUT') return json(404, { error: 'not found' })
|
|
1211
|
+
if (!secretsProtected) return json(403, { error: 'Changing an agent\'s tools needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1212
|
+
const body = await readJsonBody(req)
|
|
1213
|
+
const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
|
|
1214
|
+
const choice = (Array.isArray(body.tools) ? body.tools : []).flatMap((t: unknown) => {
|
|
1215
|
+
const e = (t ?? {}) as { remote?: unknown; decision?: unknown }
|
|
1216
|
+
return typeof e.remote === 'string' && decided(e.decision) ? [{ remote: e.remote, decision: e.decision }] : []
|
|
1217
|
+
})
|
|
1218
|
+
if (!choice.length) return json(400, { error: 'Choose at least one tool, or remove the app from the agent instead.' })
|
|
1219
|
+
if (agentAppTools(ability)[agent]) configureAgentMcpTools(agent, ability, dir, choice)
|
|
1220
|
+
else await installAbility(agent, app.spec, { mcpTools: choice.map((c) => c.remote), mcpDecisions: Object.fromEntries(choice.map((c) => [c.remote, c.decision])) })
|
|
1221
|
+
return json(200, await view())
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1224
|
+
if (err instanceof AbilityManifestError || err instanceof AbilityCollisionError || err instanceof AppToolsError) return json(400, { error: message })
|
|
1225
|
+
console.error('[loopengine] agent app failed:', err)
|
|
1226
|
+
return json(502, { error: message })
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async function handleApps(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
|
|
1231
|
+
const json = (status: number, body: unknown): void => {
|
|
1232
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
if (pathname === '/config/apps' && req.method === 'GET') {
|
|
1236
|
+
// Icons arrive from the registry on a first load; wait briefly.
|
|
1237
|
+
await Promise.race([loadAppIcons(), new Promise((resolve) => setTimeout(resolve, 2500))])
|
|
1238
|
+
return json(200, describeApps())
|
|
1239
|
+
}
|
|
1240
|
+
if (pathname === '/config/apps/search' && req.method === 'GET') {
|
|
1241
|
+
const q = new URL(req.url ?? '/', 'http://localhost').searchParams.get('q')?.trim() ?? ''
|
|
1242
|
+
if (q.length < 2) return json(400, { error: 'Type at least two letters.' })
|
|
1243
|
+
return json(200, { servers: await searchMcpServers(q, 15) })
|
|
1244
|
+
}
|
|
1245
|
+
if (pathname === '/config/apps/connect' && req.method === 'POST') {
|
|
1246
|
+
if (!secretsProtected) return json(403, { error: 'Connecting an app saves its key on this server, so it needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1247
|
+
const body = await readJsonBody(req)
|
|
1248
|
+
const id = typeof body.id === 'string' ? body.id.trim() : ''
|
|
1249
|
+
const key = typeof body.key === 'string' ? body.key.trim() : ''
|
|
1250
|
+
const server = id ? await appServer(id) : undefined
|
|
1251
|
+
if (!server) return json(400, { error: 'No such app.' })
|
|
1252
|
+
let picked: ReturnType<typeof pickInterviewModel>
|
|
1253
|
+
try {
|
|
1254
|
+
picked = pickInterviewModel(interviewAgents())
|
|
1255
|
+
} catch {
|
|
1256
|
+
picked = undefined
|
|
1257
|
+
}
|
|
1258
|
+
const generated = await generateMcpAbility({
|
|
1259
|
+
server,
|
|
1260
|
+
...(key ? { key } : {}),
|
|
1261
|
+
workflow: { name: `${server.title} for this workspace`, summary: `Everyday work with ${server.title}`, steps: [] },
|
|
1262
|
+
...(picked ? { modelCall: picked.modelCall } : {}),
|
|
1263
|
+
})
|
|
1264
|
+
try {
|
|
1265
|
+
addWorkspaceAbility(generated.dir, `mcp:${server.url}`)
|
|
1266
|
+
} finally {
|
|
1267
|
+
rmSync(generated.dir, { recursive: true, force: true })
|
|
1268
|
+
}
|
|
1269
|
+
if (key && generated.keyEnv) await setEnvVar(generated.keyEnv, key)
|
|
1270
|
+
return json(200, { ...describeApps(), connectedAbility: generated.name })
|
|
1271
|
+
}
|
|
1272
|
+
// A connected app's tools: everything its server offers, each with
|
|
1273
|
+
// its default permission and the agents that use it. Defaults only
|
|
1274
|
+
// affect agents that add a tool from now on.
|
|
1275
|
+
if (pathname === '/config/apps/tools' || pathname === '/config/apps/refresh') {
|
|
1276
|
+
const params = new URL(req.url ?? '/', 'http://localhost').searchParams
|
|
1277
|
+
const body = req.method === 'POST' ? await readJsonBody(req) : {}
|
|
1278
|
+
const ability = String(req.method === 'POST' ? body.ability ?? '' : params.get('ability') ?? '')
|
|
1279
|
+
const app = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
|
|
1280
|
+
if (!app) return json(400, { error: 'That app isn\'t connected.' })
|
|
1281
|
+
const envValue = (name: string) => process.env[name]
|
|
1282
|
+
const list = async (fresh = false) => appTools(app.spec, envValue, agentAppTools(app.name), { fresh })
|
|
1283
|
+
if (req.method === 'GET') return json(200, { tools: await list(params.get('fresh') === '1') })
|
|
1284
|
+
if (req.method !== 'POST') return json(404, { error: 'not found' })
|
|
1285
|
+
if (!secretsProtected) return json(403, { error: 'Changing an app needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1286
|
+
if (pathname === '/config/apps/refresh') {
|
|
1287
|
+
const changed = await refreshAppTools(app.spec, envValue)
|
|
1288
|
+
return json(200, { tools: await list(), ...changed, ...describeApps() })
|
|
1289
|
+
}
|
|
1290
|
+
const tool = typeof body.tool === 'string' ? body.tool : ''
|
|
1291
|
+
const decision = body.decision === 'allow' || body.decision === 'ask' || body.decision === 'deny' ? body.decision : undefined
|
|
1292
|
+
if (!tool || !decision) return json(400, { error: 'tool and decision are required' })
|
|
1293
|
+
setAppDefault(app.spec, tool, decision)
|
|
1294
|
+
return json(200, { tools: await list() })
|
|
1295
|
+
}
|
|
1296
|
+
if (pathname === '/config/apps/connect' && req.method === 'DELETE') {
|
|
1297
|
+
if (!secretsProtected) return json(403, { error: 'Disconnecting an app needs LOOPENGINE_ADMIN_AUTH set first.' })
|
|
1298
|
+
const ability = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ability') ?? ''
|
|
1299
|
+
const found = listWorkspaceAbilities().find((a) => a.name === ability && isAppAbility(a))
|
|
1300
|
+
if (!found) return json(400, { error: 'That app isn\'t connected.' })
|
|
1301
|
+
removeWorkspaceAbility(found.name)
|
|
1302
|
+
return json(200, describeApps())
|
|
1303
|
+
}
|
|
1304
|
+
return json(404, { error: 'not found' })
|
|
1305
|
+
} catch (err) {
|
|
1306
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1307
|
+
if (err instanceof McpAbilityGenerationError || err instanceof WorkspaceAbilityError || err instanceof AppToolsError) return json(400, { error: message })
|
|
1308
|
+
if (isMcpAuthError(err)) return json(400, { error: 'The app turned down its saved key; reconnect it with a new one.' })
|
|
1309
|
+
console.error('[loopengine] apps failed:', err)
|
|
1310
|
+
return json(502, { error: message })
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
|
|
1315
|
+
const json = (status: number, body: unknown): void => {
|
|
1316
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1317
|
+
}
|
|
1318
|
+
const [action, jobId] = pathname.split('/').slice(2) // after /workflows
|
|
1319
|
+
const dirOf = (name: string) => join(projectDir(), 'agents', name)
|
|
1320
|
+
try {
|
|
1321
|
+
// No model to plan with until an agent names one and its key is set
|
|
1322
|
+
// (building the model call throws without the key).
|
|
1323
|
+
let picked: ReturnType<typeof pickInterviewModel>
|
|
1324
|
+
try {
|
|
1325
|
+
picked = pickInterviewModel(interviewAgents())
|
|
1326
|
+
} catch {
|
|
1327
|
+
picked = undefined
|
|
1328
|
+
}
|
|
1329
|
+
if (!action && req.method === 'GET') return json(200, { ready: Boolean(picked), canInstallAbilities: secretsProtected, agents: listAgents() })
|
|
1330
|
+
// A planning job's progress (the page checks every few seconds).
|
|
1331
|
+
if (action === 'plan' && jobId && req.method === 'GET') {
|
|
1332
|
+
const job = getPlanJob(jobId)
|
|
1333
|
+
return job ? json(200, job) : json(404, { error: 'No such planning job (it may have expired); plan it again.' })
|
|
1334
|
+
}
|
|
1335
|
+
if (action === 'plan' && !jobId && req.method === 'POST') {
|
|
1336
|
+
if (!picked) return json(409, { error: 'No model is ready to plan with: set one on an agent (Overview → Model) and add its key under Models.' })
|
|
1337
|
+
const body = await readJsonBody(req)
|
|
1338
|
+
const base = interviewTools(interviewAgents())
|
|
1339
|
+
// The same model, with room for a whole plan in one reply (an
|
|
1340
|
+
// agent's own model call stops at its maxTokens, often 4096).
|
|
1341
|
+
let modelCall = picked.modelCall
|
|
1342
|
+
try {
|
|
1343
|
+
modelCall = (await synthesizeCreateModelCall({ ...picked.model, maxTokens: 8192 } as AgentModelConfig))()
|
|
1344
|
+
} catch {}
|
|
1345
|
+
if (typeof body.description !== 'string' || body.description.trim().length < 15) {
|
|
1346
|
+
return json(400, { error: 'Describe the workflow in a sentence or two: what starts it, the steps, and where you want to decide.' })
|
|
1347
|
+
}
|
|
1348
|
+
const job = startPlanJob(() => planWorkflow(body.description, modelCall, {
|
|
1349
|
+
agentInfo: workflowAgentInfo,
|
|
1350
|
+
// A summary: an agent's first tools and its rules that aren't the
|
|
1351
|
+
// default, to keep what the model reads short.
|
|
1352
|
+
listAgents: async () => (await workflowAgentInfo()).map((a) => ({
|
|
1353
|
+
name: a.name,
|
|
1354
|
+
abilities: a.abilities,
|
|
1355
|
+
messaging: a.messaging,
|
|
1356
|
+
defaultDecision: a.defaultDecision,
|
|
1357
|
+
toolCount: a.tools.length,
|
|
1358
|
+
tools: a.tools.slice(0, 40),
|
|
1359
|
+
rules: a.rules.filter((r) => r.decision !== a.defaultDecision).slice(0, 40).map((r) => `${r.tool}: ${r.decision}`),
|
|
1360
|
+
})),
|
|
1361
|
+
searchPublicAbilities: (query) => base.searchPublicAbilities(query),
|
|
1362
|
+
listWorkspaceAbilities: async () => listWorkspaceAbilities().map((a) => ({ name: a.name, description: a.description, spec: a.spec })),
|
|
1363
|
+
abilityDetails: async (spec) => {
|
|
1364
|
+
const d = await abilityDetails(spec)
|
|
1365
|
+
return { name: d.name, tools: d.tools.map((t) => ({ name: t.name, description: t.description.slice(0, 300), decision: t.decision ?? null })), settings: d.settings.map((x) => x.name) }
|
|
1366
|
+
},
|
|
1367
|
+
listConnections: () => base.listConnections(),
|
|
1368
|
+
searchMcpServers: async (query) => (await searchMcpServers(query)).map((s) => ({ id: s.id, title: s.title, description: s.description, url: s.url, official: Boolean(s.official), verified: s.verified, source: s.source })),
|
|
1369
|
+
mcpServerTools: workflowMcpServerTools,
|
|
1370
|
+
mcp: workflowMcp,
|
|
1371
|
+
}))
|
|
1372
|
+
return json(202, job)
|
|
1373
|
+
}
|
|
1374
|
+
if (action === 'create' && req.method === 'POST') {
|
|
1375
|
+
const body = await readJsonBody(req)
|
|
1376
|
+
const model = pickAgentModel(interviewAgents())
|
|
1377
|
+
const created = await createWorkflow(body, {
|
|
1378
|
+
addAppTools: async (agent, spec, tools) => addAgentAppTools(agent, spec, tools),
|
|
1379
|
+
mcp: {
|
|
1380
|
+
...workflowMcp,
|
|
1381
|
+
// The planning model picks the server's tools and writes the skill.
|
|
1382
|
+
generate: (request) => generateMcpAbility({ ...request, ...(picked ? { modelCall: picked.modelCall } : {}) }),
|
|
1383
|
+
store: (dir, source) => {
|
|
1384
|
+
try {
|
|
1385
|
+
return addWorkspaceAbility(dir, source).spec
|
|
1386
|
+
} finally {
|
|
1387
|
+
rmSync(dir, { recursive: true, force: true })
|
|
1388
|
+
}
|
|
1389
|
+
},
|
|
1390
|
+
saveKey: async (name, value) => {
|
|
1391
|
+
if (!secretsProtected) throw new Error('saving keys needs LOOPENGINE_ADMIN_AUTH set on the server')
|
|
1392
|
+
await setEnvVar(name, value)
|
|
1393
|
+
},
|
|
1394
|
+
},
|
|
1395
|
+
agentExists: (name) => Boolean(getEntry(name)) || existsSync(dirOf(name)),
|
|
1396
|
+
agentDir: dirOf,
|
|
1397
|
+
agentInfo: workflowAgentInfo,
|
|
1398
|
+
scaffoldAgent: async (name, systemPrompt) => {
|
|
1399
|
+
const parsed = parseAgentTemplateOptions({ systemPrompt, ...(model ? { model } : {}) })
|
|
1400
|
+
if (!parsed.ok) throw new WorkflowError(parsed.error)
|
|
1401
|
+
await scaffoldAgent(projectDir(), name, parsed.value)
|
|
1402
|
+
},
|
|
1403
|
+
registerAgent: async (name) => {
|
|
1404
|
+
const indexPath = join(dirOf(name), 'index.ts')
|
|
1405
|
+
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
1406
|
+
},
|
|
1407
|
+
// Skills, rules and gateway tools are read on every run, so a
|
|
1408
|
+
// loaded agent already sees them; new ability code restarts the
|
|
1409
|
+
// server (exitIfCodeChanged). Only an agent not loaded yet is.
|
|
1410
|
+
reloadAgent: async (name) => {
|
|
1411
|
+
if (getEntry(name)) return
|
|
1412
|
+
const indexPath = join(dirOf(name), 'index.ts')
|
|
1413
|
+
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
1414
|
+
},
|
|
1415
|
+
// As for templates and the Apps tab: installing runs
|
|
1416
|
+
// third-party code, so only behind the admin login.
|
|
1417
|
+
...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
|
|
1418
|
+
prepareApp: prepareWorkspaceApp,
|
|
1419
|
+
envValue: (name, variable) => createAgentEnv(name, dirOf(name)).get(variable),
|
|
1420
|
+
messagingConfigured: (name) => {
|
|
1421
|
+
const entry = getEntry(name)
|
|
1422
|
+
return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
|
|
1423
|
+
},
|
|
1424
|
+
})
|
|
1425
|
+
json(200, created)
|
|
1426
|
+
// New tools in an agent that was already running load with a restart,
|
|
1427
|
+
// as after installing an ability from its tab.
|
|
1428
|
+
if (!created.newAgent && created.abilities.installed.length) exitIfCodeChanged(res)
|
|
1429
|
+
return
|
|
1430
|
+
}
|
|
1431
|
+
return json(404, { error: 'not found' })
|
|
1432
|
+
} catch (err) {
|
|
1433
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1434
|
+
if (err instanceof WorkflowError) return json(400, { error: message })
|
|
1435
|
+
console.error('[loopengine] workflow failed:', err)
|
|
1436
|
+
return json(502, { error: `Couldn't plan or create the workflow: ${message}` })
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// An agent's workflows (its Overview): list, run now, forget.
|
|
1441
|
+
async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
|
|
1442
|
+
const json = (status: number, body: unknown): void => {
|
|
1443
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
1444
|
+
}
|
|
1445
|
+
if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
|
|
1446
|
+
const dir = agentDir(agentName)
|
|
1447
|
+
try {
|
|
1448
|
+
if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1449
|
+
if (id && action === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
|
|
1450
|
+
if (id && !action && req.method === 'DELETE') {
|
|
1451
|
+
forgetWorkflow(dir, id)
|
|
1452
|
+
return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1453
|
+
}
|
|
1454
|
+
return json(404, { error: 'not found' })
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1457
|
+
return json(err instanceof WorkflowError ? 400 : 500, { error: message })
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1017
1461
|
// Templates (loopengine's web/templates-admin.ts): ready-made agents,
|
|
1018
1462
|
// created through the same scaffold and registration as Agents → + New.
|
|
1019
1463
|
// A template agent takes the model of an agent that already works here,
|
|
@@ -1045,10 +1489,11 @@ async function handleStarterTemplates(req: IncomingMessage, res: ServerResponse,
|
|
|
1045
1489
|
const indexPath = join(dirOf(name), 'index.ts')
|
|
1046
1490
|
registerAgent(await loadAgentModule(indexPath, indexPath))
|
|
1047
1491
|
},
|
|
1048
|
-
// Same rule as the
|
|
1492
|
+
// Same rule as the Apps tab: installing runs third-party code,
|
|
1049
1493
|
// so only behind the admin login. The agent isn't loaded yet, so
|
|
1050
1494
|
// nothing needs a restart.
|
|
1051
|
-
...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
|
|
1495
|
+
...(secretsProtected ? { installAbility: async (name: string, spec: string, options?: { mcpTools?: string[] }) => void (await installAbility(name, spec, options ?? {})) } : {}),
|
|
1496
|
+
prepareApp: prepareWorkspaceApp,
|
|
1052
1497
|
envValue,
|
|
1053
1498
|
messagingConfigured: (name) => {
|
|
1054
1499
|
const entry = getEntry(name)
|
|
@@ -1642,7 +2087,7 @@ function hasNodeModule(name: string): boolean {
|
|
|
1642
2087
|
return existsSync(join(process.cwd(), 'node_modules', name))
|
|
1643
2088
|
}
|
|
1644
2089
|
|
|
1645
|
-
// Backs the Admin UI's
|
|
2090
|
+
// Backs the Admin UI's Apps tab list — everything currently
|
|
1646
2091
|
// installed for this agent, each annotated with the latest version
|
|
1647
2092
|
// currently published on the public npm registry (fetchLatestAbilityVersion,
|
|
1648
2093
|
// best-effort — null for a private/file:/git-installed ability, or on any
|
|
@@ -1682,7 +2127,7 @@ async function handleAbilitiesGet(res: ServerResponse, agentName: string): Promi
|
|
|
1682
2127
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ abilities }))
|
|
1683
2128
|
}
|
|
1684
2129
|
|
|
1685
|
-
// Backs the
|
|
2130
|
+
// Backs the Apps tab's search box — proxies to npm's own public
|
|
1686
2131
|
// registry search (loopengine's own searchPublicAbilities) so the
|
|
1687
2132
|
// browser never needs direct access to registry.npmjs.org, and the
|
|
1688
2133
|
// actual query text (the loopengine-ability keyword filter) stays a
|
|
@@ -3488,6 +3933,27 @@ const server = createServer(async (req, res) => {
|
|
|
3488
3933
|
await handleInterviews(req, res, pathname)
|
|
3489
3934
|
return
|
|
3490
3935
|
}
|
|
3936
|
+
// "Describe a workflow": the page, and its routes.
|
|
3937
|
+
if (req.method === 'GET' && pathname === '/workflows/new') {
|
|
3938
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(workflowPageHtml)
|
|
3939
|
+
return
|
|
3940
|
+
}
|
|
3941
|
+
if (pathname === '/workflows' || pathname === '/workflows/plan' || pathname.startsWith('/workflows/plan/') || pathname === '/workflows/create') {
|
|
3942
|
+
await handleWorkflows(req, res, pathname)
|
|
3943
|
+
return
|
|
3944
|
+
}
|
|
3945
|
+
// An agent's tools from one connected app: which it has, and each
|
|
3946
|
+
// one's permission for this agent (the Apps tab's app picker).
|
|
3947
|
+
const agentAppMatch = pathname.match(/^\/agents\/([^/]+)\/apps\/([^/]+)$/)
|
|
3948
|
+
if (agentAppMatch) {
|
|
3949
|
+
await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
|
|
3950
|
+
return
|
|
3951
|
+
}
|
|
3952
|
+
const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
|
|
3953
|
+
if (agentWorkflowsMatch) {
|
|
3954
|
+
await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
|
|
3955
|
+
return
|
|
3956
|
+
}
|
|
3491
3957
|
// Templates: the page, and its routes.
|
|
3492
3958
|
if (req.method === 'GET' && pathname === '/templates') {
|
|
3493
3959
|
res.writeHead(200, { 'content-type': 'text/html' }).end(templatesPageHtml)
|
|
@@ -3848,7 +4314,7 @@ const server = createServer(async (req, res) => {
|
|
|
3848
4314
|
return
|
|
3849
4315
|
}
|
|
3850
4316
|
|
|
3851
|
-
// Backs the Admin UI's
|
|
4317
|
+
// Backs the Admin UI's Apps tab — see handleAbilitiesGet/
|
|
3852
4318
|
// handleAbilitiesSearchGet/handleAbilityInstallPost's own doc
|
|
3853
4319
|
// comments above. The /search route is checked before the plain
|
|
3854
4320
|
// :agentName routes right below since 'search' would otherwise
|
|
@@ -3898,10 +4364,17 @@ const server = createServer(async (req, res) => {
|
|
|
3898
4364
|
}
|
|
3899
4365
|
return
|
|
3900
4366
|
}
|
|
4367
|
+
// The workspace's Apps (core/mcp-apps.ts): popular apps' MCP servers,
|
|
4368
|
+
// and the ones connected. Connecting stores a key and makes a
|
|
4369
|
+
// workspace ability, so like installing one it needs the admin login.
|
|
4370
|
+
if (pathname.startsWith('/config/apps')) {
|
|
4371
|
+
await handleApps(req, res, pathname)
|
|
4372
|
+
return
|
|
4373
|
+
}
|
|
3901
4374
|
// The workspace's private abilities (core/workspace-abilities.ts):
|
|
3902
4375
|
// installed from the Abilities page, added to agents from their tab.
|
|
3903
4376
|
if (pathname === '/abilities/workspace' && req.method === 'GET') {
|
|
3904
|
-
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities() }))
|
|
4377
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ abilities: listWorkspaceAbilities().map((a) => ({ ...a, app: isAppAbility(a) })) }))
|
|
3905
4378
|
return
|
|
3906
4379
|
}
|
|
3907
4380
|
if (pathname === '/abilities/workspace' && (req.method === 'POST' || req.method === 'DELETE')) {
|