create-loopengine 0.1.8 → 0.1.11

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.8",
3
+ "version": "0.1.11",
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",
@@ -1,6 +1,6 @@
1
1
  ANTHROPIC_API_KEY=
2
- # Using OpenAI instead? Swap agents/example-agent.ts's createModelCall for
3
- # createOpenAIModelCall({ model: '...' }) and set this instead:
2
+ # Using OpenAI instead? Swap agents/example-agent/index.ts's createModelCall
3
+ # for createOpenAIModelCall({ model: '...' }) and set this instead:
4
4
  # OPENAI_API_KEY=
5
5
 
6
6
  PORT=8787
@@ -8,3 +8,9 @@ PORT=8787
8
8
  # Optional — omit for local, file-based sessions under .sessions/. Set this
9
9
  # once you run more than one server instance against the same sessions.
10
10
  # REDIS_URL=redis://localhost:6379
11
+
12
+ # Optional, but set this before deploying anywhere reachable by anyone but
13
+ # you: without it, every route on this server — including each agent's
14
+ # conversation history — is wide open to anyone who can reach it.
15
+ # "user:pass" turns on HTTP Basic Auth for all of them.
16
+ # LOOPENGINE_ADMIN_AUTH=user:pass
@@ -31,26 +31,50 @@ Or from the command line directly, no server needed:
31
31
  npm run cli -- --agent example-agent "what is the weather in Boston?"
32
32
  ```
33
33
 
34
+ ## Admin UI
35
+
36
+ `npm run dev`, then open:
37
+
38
+ - `http://localhost:8787/agents/config` — create agents, edit system
39
+ prompts and models, connect Composio tool sources, and manage each
40
+ agent's permission rules and skills, all from the browser. "Create new
41
+ agent" writes a real folder under `agents/` and registers it live, no
42
+ restart needed.
43
+ - `http://localhost:8787/playground` — send a real message to any agent
44
+ and watch its tool calls and permission decisions happen live, approve
45
+ or deny a gated call right there.
46
+
47
+ Both are wide open by default (local-only convenience) — set
48
+ `LOOPENGINE_ADMIN_AUTH="user:pass"` in `.env` before deploying anywhere
49
+ reachable by anyone but you (see `.env.example`).
50
+
34
51
  ## Add another agent
35
52
 
36
- 1. Copy `agents/example-agent.ts` to `agents/your-agent.ts` and edit its
37
- `name`, `systemPrompt`, `tools`, and `rules`.
38
- 2. That's it — `agent-registry.ts` discovers it automatically by
53
+ 1. Copy `agents/example-agent/` to `agents/your-agent/` — `index.ts`
54
+ (persona + which tools), `tools/` (one file per tool), `actauth.yml`
55
+ (the permission story), and `skills/` if it needs any.
56
+ 2. Edit `index.ts`'s `name` and `systemPrompt`, and `tools/index.ts`'s
57
+ tool list. `rules`/`skillsDirs` don't need touching — they're left
58
+ unset in `index.ts` on purpose, so they keep defaulting to
59
+ `actauth.yml`/`skills/` next to it.
60
+ 3. That's it — `agent-registry.ts` discovers it automatically by
39
61
  `AgentConfig.name`, no registration step. Nothing to edit, no import to
40
62
  add, no adapter change.
41
- 3. Call it at `/agents/your-agent/messages`.
63
+ 4. Call it at `/agents/your-agent/messages`.
42
64
 
43
- A flat file like this is enough for most agents. An agent can also be a
44
- *folder* — `agents/your-agent/index.ts` instead of `agents/your-agent.ts`
45
- — which unlocks per-agent `tools/`, `skills/`, and `actauth.yml` defaults,
46
- and lets it have its own subagents (see below). `loopengine`'s own CLI
47
- scaffolds that folder form for you:
65
+ `loopengine`'s own CLI scaffolds this same folder shape for you, if you'd
66
+ rather start empty than copy `example-agent/`:
48
67
 
49
68
  ```bash
50
69
  npx loopengine add-agent your-agent
51
70
  # -> Created agents/your-agent/index.ts
52
71
  ```
53
72
 
73
+ An agent can also be a single flat file — `agents/your-agent.ts` instead
74
+ of a folder — if it's simple enough not to need its own `tools/`,
75
+ `skills/`, or `actauth.yml`. It just can't have subagents (see below),
76
+ since there's no folder for `subagents/` to live under.
77
+
54
78
  ## Composing agents (subagents)
55
79
 
56
80
  An agent's tools can be other agents. Drop a folder under
@@ -77,10 +101,13 @@ reaching for this.
77
101
  ## Project layout
78
102
 
79
103
  ```
80
- agent-registry.ts Auto-discovers agents/*.ts by AgentConfig.name — nothing to edit here
81
- agents/example-agent.ts Starter agent: persona, one tool, one permission rule
82
- adapters/http.ts HTTP API (streaming + non-streaming)
83
- adapters/cli.ts Command-line adapter
104
+ agent-registry.ts Auto-discovers agents/* by AgentConfig.name — nothing to edit here
105
+ agents/example-agent/index.ts Starter agent: persona, one tool
106
+ agents/example-agent/tools/ One tool (get_weather), aggregated in tools/index.ts
107
+ agents/example-agent/actauth.yml One permission rule (allow get_weather), default_decision: ask
108
+ agents/example-agent/skills/ One skill (convert-temperature) — instructions, not a tool call
109
+ adapters/http.ts HTTP API (streaming + non-streaming)
110
+ adapters/cli.ts Command-line adapter
84
111
  ```
85
112
 
86
113
  ## Adding external tools via Composio
@@ -3,51 +3,110 @@
3
3
  //
4
4
  // npm run cli -- --agent example-agent "what's the weather in Boston?"
5
5
  //
6
- // Omit --session for a one-off ask — each call gets its own fresh,
6
+ // Omit --session for a one-off ask — each such call gets its own fresh,
7
7
  // isolated session (a random id, printed to stderr so it can be reused
8
- // with --session <id> later to continue that conversation).
8
+ // with --session <id> later if you want to continue that exact
9
+ // conversation). Pass --session explicitly to have two calls share one
10
+ // ongoing conversation on purpose.
11
+ //
12
+ // npm run cli -- --agent example-agent --json "what's the weather in Boston?"
13
+ //
14
+ // --json prints the same typed LoopEvent stream adapters/http.ts's SSE
15
+ // route sends as one JSON object per line on stdout, ending with a
16
+ // 'done'/'error' event — nothing human-readable mixed in, so it's
17
+ // pipeable straight into `jq` or another process. Without --json, stdout
18
+ // stays human-oriented (just the final answer text) and events go to
19
+ // stderr as a one-line summary each.
20
+ //
21
+ // Owns: parsing argv, loading/saving this session's history, printing the
22
+ // result to stdout. Everything else is runAgent. Uses REDIS_URL if set
23
+ // (see loopengine's own createSessionStore), a local .sessions/ dir
24
+ // otherwise — either way, safe for concurrent invocations against the
25
+ // same --session.
9
26
  import { randomUUID } from 'node:crypto'
10
- import { createSessionStore, runAgent } from 'loopengine'
27
+ import { createSessionStore, runAgent, type LoopEvent } from 'loopengine'
11
28
  import { getEntry, listAgents } from '../agent-registry.js'
12
29
 
13
30
  function parseArgs(argv: string[]) {
14
31
  let agent = ''
15
32
  let session: string | undefined
33
+ let json = false
16
34
  const rest: string[] = []
17
35
  for (let i = 0; i < argv.length; i++) {
18
36
  if (argv[i] === '--agent') agent = argv[++i]
19
37
  else if (argv[i] === '--session') session = argv[++i]
38
+ else if (argv[i] === '--json') json = true
20
39
  else rest.push(argv[i])
21
40
  }
22
- return { agent, session, message: rest.join(' ') }
41
+ return { agent, session, json, message: rest.join(' ') }
42
+ }
43
+
44
+ /** One NDJSON line per event — see this file's own header comment for
45
+ * why --json mode never mixes human-readable text into stdout. */
46
+ function printJsonEvent(event: LoopEvent): void {
47
+ console.log(JSON.stringify(event))
23
48
  }
24
49
 
25
50
  async function main() {
26
- const { agent, session, message } = parseArgs(process.argv.slice(2))
51
+ const { agent, session, json, message } = parseArgs(process.argv.slice(2))
27
52
  const entry = getEntry(agent)
28
53
  if (!entry) {
29
54
  console.error(`unknown agent '${agent}'. available: ${listAgents().join(', ')}`)
30
55
  process.exit(1)
31
56
  }
32
57
  if (!message) {
33
- console.error('usage: cli.ts --agent <name> [--session <id>] "<message>"')
58
+ console.error('usage: cli.ts --agent <name> [--session <id>] [--json] "<message>"')
34
59
  process.exit(1)
35
60
  }
36
61
 
62
+ // No --session means "a fresh, one-off ask," not "join whatever the
63
+ // default conversation happens to be" — a shared 'default' bucket would
64
+ // silently accumulate history across every untagged invocation forever,
65
+ // splicing unrelated one-off asks into a single ever-growing session.
37
66
  const sessionId = session ?? randomUUID()
38
- if (!session) console.error(`[session] ${sessionId} (pass --session ${sessionId} to continue this conversation)`)
67
+ // In --json mode this is the stream's own first line (mirroring
68
+ // adapters/http.ts's SSE 'session' event) — not also duplicated to
69
+ // stderr, so a consumer piping stdout gets exactly one place to learn
70
+ // the generated id from, same as an SSE client does.
71
+ if (json) printJsonEvent({ type: 'session', sessionId })
72
+ else if (!session) console.error(`[session] ${sessionId} (pass --session ${sessionId} to continue this conversation)`)
39
73
 
40
74
  const sessions = createSessionStore()
41
75
  try {
42
- // Namespaced by agent name so the same --session id reused across two
43
- // different agents doesn't read/write the same underlying log.
76
+ // SessionStore itself is agent-agnostic — the same --session id reused
77
+ // across two different agents would otherwise read and write the same
78
+ // underlying log (see adapters/http.ts's identical fix). Namespacing
79
+ // by agent name here is what actually keeps them isolated.
44
80
  const text = await sessions.withSession(`${agent}:${sessionId}`, async (history) => {
45
81
  const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
46
- onEvent: (event, detail) => console.error(`[${event}]`, detail),
82
+ channel: 'cli',
83
+ onEvent: json
84
+ ? printJsonEvent
85
+ : (event) => {
86
+ const { type, ...detail } = event
87
+ console.error(`[${type}]`, detail)
88
+ },
47
89
  })
90
+ // Same synthetic 'done' event adapters/http.ts's streaming route
91
+ // sends once a turn is over — covers a genuine finish and both
92
+ // synthetic stopReasons alike, so a --json consumer always sees
93
+ // exactly one terminal event regardless of how the turn actually
94
+ // ended.
95
+ if (json) printJsonEvent({ type: 'done', text: result.text, ...(result.stopReason ? { stopReason: result.stopReason } : {}) })
48
96
  return { newMessages: result.newMessages, result: result.text }
49
97
  })
50
- console.log(text)
98
+ if (!json) console.log(text)
99
+ } catch (err) {
100
+ // Mirrors adapters/http.ts's own in-band 'error' SSE event — in
101
+ // --json mode the turn may already have written partial output to
102
+ // stdout, so the failure needs to land there too, not just as an
103
+ // uncaught rejection's stack trace on stderr.
104
+ if (json) {
105
+ printJsonEvent({ type: 'error', error: err instanceof Error ? err.message : String(err) })
106
+ process.exitCode = 1
107
+ return
108
+ }
109
+ throw err
51
110
  } finally {
52
111
  await sessions.close()
53
112
  }