create-loopengine 0.1.77 → 0.1.78

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.1.77",
3
+ "version": "0.1.78",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -195,6 +195,16 @@ import {
195
195
  removeAbilitySourceFromConsole,
196
196
  saveAbilitySourceFromConsole,
197
197
  abilityDetails,
198
+ WorkflowError,
199
+ createWorkflow,
200
+ forgetWorkflow,
201
+ getPlanJob,
202
+ listAgentWorkflows,
203
+ planWorkflow,
204
+ runWorkflowNow,
205
+ startPlanJob,
206
+ type WorkflowAgentInfo,
207
+ workflowPageHtml,
198
208
  MAX_UPLOAD_BYTES,
199
209
  installUploadedAbility,
200
210
  installWorkspaceAbility,
@@ -1014,6 +1024,175 @@ async function handleInterviews(req: IncomingMessage, res: ServerResponse, pathn
1014
1024
  }
1015
1025
  }
1016
1026
 
1027
+ // "Describe a workflow" (loopengine's web/workflow-admin.ts): what the planner and the
1028
+ // checks know about each agent: its tools, abilities, rules and primary
1029
+ // messaging app.
1030
+ async function workflowAgentInfo(): Promise<WorkflowAgentInfo[]> {
1031
+ return Promise.all(
1032
+ listAgents().flatMap((name) => {
1033
+ const entry = getEntry(name)
1034
+ if (!entry) return []
1035
+ return [(async (): Promise<WorkflowAgentInfo> => {
1036
+ const { rules, defaultDecision } = readActauthConfig(name)
1037
+ // Abilities by default: gateway tools (Composio and the like) are
1038
+ // left out, so a workflow is never built on one.
1039
+ let tools: string[] = []
1040
+ let gateway = new Set<string>()
1041
+ try {
1042
+ gateway = new Set((await loadGatewayToolsFromDir(agentDir(name))).map((t) => t.name))
1043
+ const own = entry.config.tools ?? (await loadDefaultTools(entry.config))
1044
+ const all = [...own, ...(await loadSubagentAsTools(entry.config))]
1045
+ tools = [...new Set(all.map((t) => t.name))].filter((t) => !gateway.has(t))
1046
+ } catch {}
1047
+ let abilities: string[] = []
1048
+ try {
1049
+ abilities = listInstalledAbilities(name).map((a) => a.name)
1050
+ } catch {}
1051
+ let messaging: string | null = null
1052
+ try {
1053
+ const n = describeAgentNotifier(entry.config, defaultWebhook)
1054
+ messaging = n.source === 'none' ? null : (n.primary ?? n.channel ?? null)
1055
+ } catch {}
1056
+ const decided = (d: unknown): d is 'allow' | 'ask' | 'deny' => d === 'allow' || d === 'ask' || d === 'deny'
1057
+ return {
1058
+ name,
1059
+ tools,
1060
+ abilities,
1061
+ rules: rules.flatMap((r) => (decided(r.decision) && !gateway.has(r.tool) ? [{ tool: r.tool, decision: r.decision, scope: r.scope }] : [])),
1062
+ defaultDecision: decided(defaultDecision) ? defaultDecision : 'ask',
1063
+ messaging,
1064
+ }
1065
+ })()]
1066
+ }),
1067
+ )
1068
+ }
1069
+
1070
+ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathname: string): Promise<void> {
1071
+ const json = (status: number, body: unknown): void => {
1072
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1073
+ }
1074
+ const [action, jobId] = pathname.split('/').slice(2) // after /workflows
1075
+ const dirOf = (name: string) => join(projectDir(), 'agents', name)
1076
+ try {
1077
+ // No model to plan with until an agent names one and its key is set
1078
+ // (building the model call throws without the key).
1079
+ let picked: ReturnType<typeof pickInterviewModel>
1080
+ try {
1081
+ picked = pickInterviewModel(interviewAgents())
1082
+ } catch {
1083
+ picked = undefined
1084
+ }
1085
+ if (!action && req.method === 'GET') return json(200, { ready: Boolean(picked), canInstallAbilities: secretsProtected, agents: listAgents() })
1086
+ // A planning job's progress (the page checks every few seconds).
1087
+ if (action === 'plan' && jobId && req.method === 'GET') {
1088
+ const job = getPlanJob(jobId)
1089
+ return job ? json(200, job) : json(404, { error: 'No such planning job (it may have expired); plan it again.' })
1090
+ }
1091
+ if (action === 'plan' && !jobId && req.method === 'POST') {
1092
+ 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.' })
1093
+ const body = await readJsonBody(req)
1094
+ const base = interviewTools(interviewAgents())
1095
+ // The same model, with room for a whole plan in one reply (an
1096
+ // agent's own model call stops at its maxTokens, often 4096).
1097
+ let modelCall = picked.modelCall
1098
+ try {
1099
+ modelCall = (await synthesizeCreateModelCall({ ...picked.model, maxTokens: 8192 } as AgentModelConfig))()
1100
+ } catch {}
1101
+ if (typeof body.description !== 'string' || body.description.trim().length < 15) {
1102
+ return json(400, { error: 'Describe the workflow in a sentence or two: what starts it, the steps, and where you want to decide.' })
1103
+ }
1104
+ const job = startPlanJob(() => planWorkflow(body.description, modelCall, {
1105
+ agentInfo: workflowAgentInfo,
1106
+ // A summary: an agent's first tools and its rules that aren't the
1107
+ // default, to keep what the model reads short.
1108
+ listAgents: async () => (await workflowAgentInfo()).map((a) => ({
1109
+ name: a.name,
1110
+ abilities: a.abilities,
1111
+ messaging: a.messaging,
1112
+ defaultDecision: a.defaultDecision,
1113
+ toolCount: a.tools.length,
1114
+ tools: a.tools.slice(0, 40),
1115
+ rules: a.rules.filter((r) => r.decision !== a.defaultDecision).slice(0, 40).map((r) => `${r.tool}: ${r.decision}`),
1116
+ })),
1117
+ searchPublicAbilities: (query) => base.searchPublicAbilities(query),
1118
+ listWorkspaceAbilities: async () => listWorkspaceAbilities().map((a) => ({ name: a.name, description: a.description, spec: a.spec })),
1119
+ abilityDetails: async (spec) => {
1120
+ const d = await abilityDetails(spec)
1121
+ 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) }
1122
+ },
1123
+ listConnections: () => base.listConnections(),
1124
+ }))
1125
+ return json(202, job)
1126
+ }
1127
+ if (action === 'create' && req.method === 'POST') {
1128
+ const body = await readJsonBody(req)
1129
+ const model = pickAgentModel(interviewAgents())
1130
+ const created = await createWorkflow(body, {
1131
+ agentExists: (name) => Boolean(getEntry(name)) || existsSync(dirOf(name)),
1132
+ agentDir: dirOf,
1133
+ agentInfo: workflowAgentInfo,
1134
+ scaffoldAgent: async (name, systemPrompt) => {
1135
+ const parsed = parseAgentTemplateOptions({ systemPrompt, ...(model ? { model } : {}) })
1136
+ if (!parsed.ok) throw new WorkflowError(parsed.error)
1137
+ await scaffoldAgent(projectDir(), name, parsed.value)
1138
+ },
1139
+ registerAgent: async (name) => {
1140
+ const indexPath = join(dirOf(name), 'index.ts')
1141
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1142
+ },
1143
+ // Skills, rules and gateway tools are read on every run, so a
1144
+ // loaded agent already sees them; new ability code restarts the
1145
+ // server (exitIfCodeChanged). Only an agent not loaded yet is.
1146
+ reloadAgent: async (name) => {
1147
+ if (getEntry(name)) return
1148
+ const indexPath = join(dirOf(name), 'index.ts')
1149
+ registerAgent(await loadAgentModule(indexPath, indexPath))
1150
+ },
1151
+ // As for templates and the Abilities tab: installing runs
1152
+ // third-party code, so only behind the admin login.
1153
+ ...(secretsProtected ? { installAbility: async (name: string, spec: string) => void (await installAbility(name, spec)) } : {}),
1154
+ envValue: (name, variable) => createAgentEnv(name, dirOf(name)).get(variable),
1155
+ messagingConfigured: (name) => {
1156
+ const entry = getEntry(name)
1157
+ return entry ? describeAgentNotifier(entry.config, defaultWebhook).source !== 'none' : false
1158
+ },
1159
+ })
1160
+ json(200, created)
1161
+ // New tools in an agent that was already running load with a restart,
1162
+ // as after installing an ability from its tab.
1163
+ if (!created.newAgent && created.abilities.installed.length) exitIfCodeChanged(res)
1164
+ return
1165
+ }
1166
+ return json(404, { error: 'not found' })
1167
+ } catch (err) {
1168
+ const message = err instanceof Error ? err.message : String(err)
1169
+ if (err instanceof WorkflowError) return json(400, { error: message })
1170
+ console.error('[loopengine] workflow failed:', err)
1171
+ return json(502, { error: `Couldn't plan or create the workflow: ${message}` })
1172
+ }
1173
+ }
1174
+
1175
+ // An agent's workflows (its Overview): list, run now, forget.
1176
+ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
1177
+ const json = (status: number, body: unknown): void => {
1178
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
1179
+ }
1180
+ if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
1181
+ const dir = agentDir(agentName)
1182
+ try {
1183
+ if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1184
+ if (id && action === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
1185
+ if (id && !action && req.method === 'DELETE') {
1186
+ forgetWorkflow(dir, id)
1187
+ return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
1188
+ }
1189
+ return json(404, { error: 'not found' })
1190
+ } catch (err) {
1191
+ const message = err instanceof Error ? err.message : String(err)
1192
+ return json(err instanceof WorkflowError ? 400 : 500, { error: message })
1193
+ }
1194
+ }
1195
+
1017
1196
  // Templates (loopengine's web/templates-admin.ts): ready-made agents,
1018
1197
  // created through the same scaffold and registration as Agents → + New.
1019
1198
  // A template agent takes the model of an agent that already works here,
@@ -3488,6 +3667,20 @@ const server = createServer(async (req, res) => {
3488
3667
  await handleInterviews(req, res, pathname)
3489
3668
  return
3490
3669
  }
3670
+ // "Describe a workflow": the page, and its routes.
3671
+ if (req.method === 'GET' && pathname === '/workflows/new') {
3672
+ res.writeHead(200, { 'content-type': 'text/html' }).end(workflowPageHtml)
3673
+ return
3674
+ }
3675
+ if (pathname === '/workflows' || pathname === '/workflows/plan' || pathname.startsWith('/workflows/plan/') || pathname === '/workflows/create') {
3676
+ await handleWorkflows(req, res, pathname)
3677
+ return
3678
+ }
3679
+ const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
3680
+ if (agentWorkflowsMatch) {
3681
+ await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
3682
+ return
3683
+ }
3491
3684
  // Templates: the page, and its routes.
3492
3685
  if (req.method === 'GET' && pathname === '/templates') {
3493
3686
  res.writeHead(200, { 'content-type': 'text/html' }).end(templatesPageHtml)
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.1.96",
12
+ "loopengine": "^0.1.97",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"