create-loopengine 0.1.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/LICENSE +21 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +63 -0
- package/package.json +33 -0
- package/template/.env.example +10 -0
- package/template/README.md +49 -0
- package/template/adapters/cli.ts +56 -0
- package/template/adapters/http.ts +161 -0
- package/template/agent-registry.ts +24 -0
- package/template/agents/example-agent.ts +26 -0
- package/template/package.json +19 -0
- package/template/tsconfig.json +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evan Yan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface ScaffoldOptions {
|
|
3
|
+
name: string;
|
|
4
|
+
/** Defaults to `./<name>`. */
|
|
5
|
+
destinationDir?: string;
|
|
6
|
+
templateDir?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function scaffold(options: ScaffoldOptions): string;
|
|
9
|
+
export declare function main(argv?: string[]): void;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
// Template ships in `template/` next to `dist/`, not inside it — it's a
|
|
6
|
+
// real, buildable project (its own package.json/tsconfig), not TypeScript
|
|
7
|
+
// source belonging to this CLI.
|
|
8
|
+
const DEFAULT_TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'template');
|
|
9
|
+
export function scaffold(options) {
|
|
10
|
+
const { name } = options;
|
|
11
|
+
const templateDir = options.templateDir ?? DEFAULT_TEMPLATE_DIR;
|
|
12
|
+
const destination = options.destinationDir ?? name;
|
|
13
|
+
if (existsSync(destination)) {
|
|
14
|
+
throw new Error(`${destination} already exists — remove it or choose a different name.`);
|
|
15
|
+
}
|
|
16
|
+
mkdirSync(destination, { recursive: true });
|
|
17
|
+
cpSync(templateDir, destination, { recursive: true });
|
|
18
|
+
const pkgPath = join(destination, 'package.json');
|
|
19
|
+
const pkg = readFileSync(pkgPath, 'utf8').replace('__PROJECT_NAME__', name);
|
|
20
|
+
writeFileSync(pkgPath, pkg);
|
|
21
|
+
const readmePath = join(destination, 'README.md');
|
|
22
|
+
const readme = readFileSync(readmePath, 'utf8').replace('__PROJECT_NAME__', name);
|
|
23
|
+
writeFileSync(readmePath, readme);
|
|
24
|
+
return destination;
|
|
25
|
+
}
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
return { name: argv[0] ?? 'my-agents' };
|
|
28
|
+
}
|
|
29
|
+
export function main(argv = process.argv.slice(2)) {
|
|
30
|
+
try {
|
|
31
|
+
const { name } = parseArgs(argv);
|
|
32
|
+
const destination = scaffold({ name });
|
|
33
|
+
console.log(`Created ${destination}/`);
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(` cd ${destination}`);
|
|
36
|
+
console.log(' npm install');
|
|
37
|
+
console.log(' cp .env.example .env # fill in ANTHROPIC_API_KEY');
|
|
38
|
+
console.log(' npm run dev');
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// A package-manager shim (npx, node_modules/.bin) invokes this file through
|
|
46
|
+
// a symlink, not its real path — process.argv[1] is the symlink's path,
|
|
47
|
+
// while Node resolves import.meta.url to the real path when loading an ES
|
|
48
|
+
// module. Comparing the raw argv[1] against the resolved module URL fails
|
|
49
|
+
// in exactly that case and main() silently never runs — realpath argv[1]
|
|
50
|
+
// first so both sides are resolved. (Same bug, same fix, as skillgarden's
|
|
51
|
+
// CLI — see its git history for the live symlink repro.)
|
|
52
|
+
function isMain() {
|
|
53
|
+
if (!process.argv[1])
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (isMain())
|
|
63
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-loopengine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/evanyan22/create-loopengine.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"create-loopengine": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"template",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc && chmod +x dist/cli.js",
|
|
24
|
+
"prepublishOnly": "npm run build",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.10.0",
|
|
29
|
+
"tsx": "^4.19.0",
|
|
30
|
+
"typescript": "^5.7.0",
|
|
31
|
+
"vitest": "^2.1.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ANTHROPIC_API_KEY=
|
|
2
|
+
# Using OpenAI instead? Swap agents/example-agent.ts's createModelCall for
|
|
3
|
+
# createOpenAIModelCall({ model: '...' }) and set this instead:
|
|
4
|
+
# OPENAI_API_KEY=
|
|
5
|
+
|
|
6
|
+
PORT=8787
|
|
7
|
+
|
|
8
|
+
# Optional — omit for local, file-based sessions under .sessions/. Set this
|
|
9
|
+
# once you run more than one server instance against the same sessions.
|
|
10
|
+
# REDIS_URL=redis://localhost:6379
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
An agent server built with [loopengine](https://www.npmjs.com/package/loopengine).
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
cp .env.example .env # fill in ANTHROPIC_API_KEY
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm run dev # HTTP server on :8787
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -X POST localhost:8787/agents/example-agent/messages \
|
|
20
|
+
-H 'content-type: application/json' \
|
|
21
|
+
-d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or from the command line directly, no server needed:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm run cli -- --agent example-agent "what is the weather in Boston?"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Add another agent
|
|
31
|
+
|
|
32
|
+
1. Copy `agents/example-agent.ts` to `agents/your-agent.ts` and edit its
|
|
33
|
+
`systemPrompt`, `tools`, and `rules`.
|
|
34
|
+
2. Register it in `agent-registry.ts` — one line, same shape as
|
|
35
|
+
`example-agent`.
|
|
36
|
+
3. Call it at `/agents/your-agent/messages`.
|
|
37
|
+
|
|
38
|
+
## Project layout
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
agent-registry.ts Maps agent name -> {config, createModelCall}
|
|
42
|
+
agents/example-agent.ts Starter agent: persona, one tool, one permission rule
|
|
43
|
+
adapters/http.ts HTTP API (streaming + non-streaming)
|
|
44
|
+
adapters/cli.ts Command-line adapter
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
See [loopengine's own README](https://github.com/evanyan22/loopengine) for
|
|
48
|
+
the full `AgentConfig` surface, permission rules, skills, and how to wire a
|
|
49
|
+
real model.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Channel adapter #1: command line.
|
|
3
|
+
//
|
|
4
|
+
// npm run cli -- --agent example-agent "what's the weather in Boston?"
|
|
5
|
+
//
|
|
6
|
+
// Omit --session for a one-off ask — each call gets its own fresh,
|
|
7
|
+
// isolated session (a random id, printed to stderr so it can be reused
|
|
8
|
+
// with --session <id> later to continue that conversation).
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { createSessionStore, runAgent } from 'loopengine'
|
|
11
|
+
import { getEntry, listAgents } from '../agent-registry.js'
|
|
12
|
+
|
|
13
|
+
function parseArgs(argv: string[]) {
|
|
14
|
+
let agent = ''
|
|
15
|
+
let session: string | undefined
|
|
16
|
+
const rest: string[] = []
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
if (argv[i] === '--agent') agent = argv[++i]
|
|
19
|
+
else if (argv[i] === '--session') session = argv[++i]
|
|
20
|
+
else rest.push(argv[i])
|
|
21
|
+
}
|
|
22
|
+
return { agent, session, message: rest.join(' ') }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function main() {
|
|
26
|
+
const { agent, session, message } = parseArgs(process.argv.slice(2))
|
|
27
|
+
const entry = getEntry(agent)
|
|
28
|
+
if (!entry) {
|
|
29
|
+
console.error(`unknown agent '${agent}'. available: ${listAgents().join(', ')}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
if (!message) {
|
|
33
|
+
console.error('usage: cli.ts --agent <name> [--session <id>] "<message>"')
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const sessionId = session ?? randomUUID()
|
|
38
|
+
if (!session) console.error(`[session] ${sessionId} (pass --session ${sessionId} to continue this conversation)`)
|
|
39
|
+
|
|
40
|
+
const sessions = createSessionStore()
|
|
41
|
+
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.
|
|
44
|
+
const text = await sessions.withSession(`${agent}:${sessionId}`, async (history) => {
|
|
45
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
46
|
+
onEvent: (event, detail) => console.error(`[${event}]`, detail),
|
|
47
|
+
})
|
|
48
|
+
return { newMessages: result.newMessages, result: result.text }
|
|
49
|
+
})
|
|
50
|
+
console.log(text)
|
|
51
|
+
} finally {
|
|
52
|
+
await sessions.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Channel adapter #2: HTTP API.
|
|
2
|
+
//
|
|
3
|
+
// npm run dev
|
|
4
|
+
//
|
|
5
|
+
// curl -X POST localhost:8787/agents/example-agent/messages \
|
|
6
|
+
// -H 'content-type: application/json' \
|
|
7
|
+
// -d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
|
|
8
|
+
//
|
|
9
|
+
// # same request, but as it happens: one SSE event per loop step
|
|
10
|
+
// curl -N -X POST localhost:8787/agents/example-agent/messages/stream \
|
|
11
|
+
// -H 'content-type: application/json' \
|
|
12
|
+
// -d '{"sessionId":"s1","message":"what is the weather in Boston?"}'
|
|
13
|
+
//
|
|
14
|
+
// Owns: routing by agent name, request/response shape, and (via
|
|
15
|
+
// SessionStore.withSession) making sure two concurrent requests for the
|
|
16
|
+
// same session don't race on read-modify-write of that session's history.
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
18
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
|
19
|
+
import { createSessionStore, runAgent, type AgentConfig } from 'loopengine'
|
|
20
|
+
import { getEntry, type RegistryEntry } from '../agent-registry.js'
|
|
21
|
+
|
|
22
|
+
const sessions = createSessionStore()
|
|
23
|
+
|
|
24
|
+
// Deriving a session key from something richer than a plain client-supplied
|
|
25
|
+
// id (a customer's email, a Slack channel, ...) is business logic specific
|
|
26
|
+
// to what an agent is for — see AgentConfig.sessionIdFor. This is only the
|
|
27
|
+
// agent-agnostic fallback: a missing sessionId just means "no ongoing
|
|
28
|
+
// conversation to resume," so one is generated rather than treated as an error.
|
|
29
|
+
function defaultSessionIdFor(body: Record<string, unknown>): string | undefined {
|
|
30
|
+
const sessionId = body.sessionId
|
|
31
|
+
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sessionIdFor(config: AgentConfig, body: Record<string, unknown>): string | undefined {
|
|
35
|
+
if (config.sessionIdFor) return config.sessionIdFor(body)
|
|
36
|
+
return defaultSessionIdFor(body) ?? randomUUID()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown>> {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
let data = ''
|
|
42
|
+
req.on('data', (chunk) => (data += chunk))
|
|
43
|
+
req.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
resolve(data ? JSON.parse(data) : {})
|
|
46
|
+
} catch (err) {
|
|
47
|
+
reject(err)
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
req.on('error', reject)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ParsedRequest {
|
|
55
|
+
entry: RegistryEntry
|
|
56
|
+
message: string
|
|
57
|
+
rawSessionId: string
|
|
58
|
+
storageSessionId: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type ParseResult = { ok: true; value: ParsedRequest } | { ok: false; status: number; error: string }
|
|
62
|
+
|
|
63
|
+
async function parseRequest(req: IncomingMessage, agentName: string): Promise<ParseResult> {
|
|
64
|
+
const entry = getEntry(agentName)
|
|
65
|
+
if (!entry) return { ok: false, status: 404, error: `unknown agent '${agentName}'` }
|
|
66
|
+
|
|
67
|
+
const body = await readJsonBody(req)
|
|
68
|
+
const message = String(body.message ?? '')
|
|
69
|
+
const rawSessionId = sessionIdFor(entry.config, body)
|
|
70
|
+
if (!message) return { ok: false, status: 400, error: 'message is required' }
|
|
71
|
+
if (!rawSessionId) return { ok: false, status: 400, error: 'could not derive a session id from the request body' }
|
|
72
|
+
|
|
73
|
+
// Namespaced by agent name so two agents given the same sessionId don't
|
|
74
|
+
// read/write the same underlying log.
|
|
75
|
+
return { ok: true, value: { entry, message, rawSessionId, storageSessionId: `${agentName}:${rawSessionId}` } }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeSseEvent(res: ServerResponse, event: string, data: unknown): void {
|
|
79
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function handleMessages(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
83
|
+
const parsed = await parseRequest(req, agentName)
|
|
84
|
+
if (!parsed.ok) {
|
|
85
|
+
res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const { entry, message, rawSessionId, storageSessionId } = parsed.value
|
|
89
|
+
|
|
90
|
+
const text = await sessions.withSession(storageSessionId, async (history) => {
|
|
91
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history)
|
|
92
|
+
return { newMessages: result.newMessages, result: result.text }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ text, sessionId: rawSessionId }))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function handleMessagesStream(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
99
|
+
const parsed = await parseRequest(req, agentName)
|
|
100
|
+
if (!parsed.ok) {
|
|
101
|
+
res.writeHead(parsed.status, { 'content-type': 'application/json' }).end(JSON.stringify({ error: parsed.error }))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const { entry, message, rawSessionId, storageSessionId } = parsed.value
|
|
105
|
+
|
|
106
|
+
res.writeHead(200, {
|
|
107
|
+
'content-type': 'text/event-stream',
|
|
108
|
+
'cache-control': 'no-cache',
|
|
109
|
+
connection: 'keep-alive',
|
|
110
|
+
})
|
|
111
|
+
writeSseEvent(res, 'session', { sessionId: rawSessionId })
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await sessions.withSession(storageSessionId, async (history) => {
|
|
115
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
116
|
+
onEvent: (event, detail) => writeSseEvent(res, event, detail),
|
|
117
|
+
})
|
|
118
|
+
writeSseEvent(res, 'done', { text: result.text })
|
|
119
|
+
return { newMessages: result.newMessages, result: result.text }
|
|
120
|
+
})
|
|
121
|
+
} catch (err) {
|
|
122
|
+
writeSseEvent(res, 'error', { error: String(err) })
|
|
123
|
+
} finally {
|
|
124
|
+
res.end()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const server = createServer(async (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const streamMatch = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages\/stream$/)
|
|
131
|
+
if (streamMatch) {
|
|
132
|
+
await handleMessagesStream(req, res, decodeURIComponent(streamMatch[1]))
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const match = req.method === 'POST' && req.url?.match(/^\/agents\/([^/]+)\/messages$/)
|
|
137
|
+
if (!match) {
|
|
138
|
+
res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not found' }))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await handleMessages(req, res, decodeURIComponent(match[1]))
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (res.headersSent) {
|
|
145
|
+
res.end()
|
|
146
|
+
} else {
|
|
147
|
+
res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: String(err) }))
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const port = Number(process.env.PORT ?? 8787)
|
|
153
|
+
server.listen(port, () => console.log(`agent API listening on :${port}`))
|
|
154
|
+
|
|
155
|
+
async function shutdown() {
|
|
156
|
+
server.close()
|
|
157
|
+
await sessions.close()
|
|
158
|
+
process.exit(0)
|
|
159
|
+
}
|
|
160
|
+
process.on('SIGTERM', shutdown)
|
|
161
|
+
process.on('SIGINT', shutdown)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Every adapter resolves "which agent" through this — the thing that
|
|
2
|
+
// makes agents pluggable. Defining a new agent means adding one line here;
|
|
3
|
+
// no adapter changes needed.
|
|
4
|
+
import type { AgentConfig, ModelCall } from 'loopengine'
|
|
5
|
+
import { config as exampleAgentConfig, createModelCall as createExampleAgentModelCall } from './agents/example-agent.js'
|
|
6
|
+
|
|
7
|
+
export interface RegistryEntry {
|
|
8
|
+
config: AgentConfig
|
|
9
|
+
/** Factory, not a shared instance — a fresh ModelCall per request/session. */
|
|
10
|
+
createModelCall: () => ModelCall
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const entries: Record<string, RegistryEntry> = {
|
|
14
|
+
'example-agent': { config: exampleAgentConfig, createModelCall: createExampleAgentModelCall },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listAgents(): string[] {
|
|
18
|
+
return Object.keys(entries)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** undefined for an unknown agent name. */
|
|
22
|
+
export function getEntry(name: string): RegistryEntry | undefined {
|
|
23
|
+
return entries[name]
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// A starting point, not a template to copy verbatim: real persona, real
|
|
2
|
+
// tool, real permission rule. Add more agents by copying this file and
|
|
3
|
+
// registering the new one in agent-registry.ts.
|
|
4
|
+
import { createAnthropicModelCall, type AgentConfig, type ModelCall } from 'loopengine'
|
|
5
|
+
|
|
6
|
+
export const config: AgentConfig = {
|
|
7
|
+
name: 'example-agent',
|
|
8
|
+
systemPrompt: 'You are a helpful assistant.',
|
|
9
|
+
tools: [
|
|
10
|
+
{
|
|
11
|
+
name: 'get_weather',
|
|
12
|
+
description: 'Look up the weather for a city',
|
|
13
|
+
input_schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] },
|
|
14
|
+
execute: async (input) => `Sunny in ${input.city}`,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
rules: [{ scopePattern: 'default/production/example-agent', tool: 'get_weather', decision: 'allow' }],
|
|
18
|
+
defaultDecision: 'ask',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Reads ANTHROPIC_API_KEY from the env. Swap for
|
|
22
|
+
// createOpenAIModelCall({ model: '...' }) to use OpenAI instead — see
|
|
23
|
+
// node_modules/loopengine/README.md's "Wiring a real model" section.
|
|
24
|
+
export function createModelCall(): ModelCall {
|
|
25
|
+
return createAnthropicModelCall({ model: 'claude-sonnet-5' })
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"dev": "tsx adapters/http.ts",
|
|
9
|
+
"cli": "tsx adapters/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"loopengine": "^0.0.3"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^22.10.0",
|
|
16
|
+
"tsx": "^4.19.0",
|
|
17
|
+
"typescript": "^5.7.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "dist"
|
|
11
|
+
},
|
|
12
|
+
"include": ["*.ts", "adapters/*.ts", "agents/*.ts"]
|
|
13
|
+
}
|