Build extensible agent harness with interactive chat loop
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Simple Agent Harness
|
||||||
|
|
||||||
|
A tiny, extension-first AI harness inspired by the same design instinct as pi:
|
||||||
|
keep the core small, make the seams obvious, and add capability through tools.
|
||||||
|
|
||||||
|
## What is here
|
||||||
|
|
||||||
|
- `src/agent.ts` — the agent loop
|
||||||
|
- `src/model/openai.ts` — the OpenAI Responses API adapter
|
||||||
|
- `src/events.ts` — typed lifecycle events
|
||||||
|
- `src/commands.ts` — slash-command registry
|
||||||
|
- `src/tools/registry.ts` — tool registration and lookup
|
||||||
|
- `src/tools/builtins.ts` — two read-only built-in tools
|
||||||
|
- `src/extensions.ts` — project-local extension loader
|
||||||
|
- `src/logger.ts` — ordered lifecycle logging
|
||||||
|
- `extensions/example.ts` — an example extension that registers `echo`
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
export OPENAI_API_KEY="..."
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
That starts a simple terminal chat loop:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Simple Agent Harness
|
||||||
|
Type a message, "/help" for commands, or "/exit" to quit.
|
||||||
|
you>
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also keep using one-shot mode when scripting:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- "List the files in this project."
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SYSTEM_PROMPT="You are a terse coding agent."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
The core is deliberately narrow:
|
||||||
|
|
||||||
|
1. the model adapter asks the model what to do next
|
||||||
|
2. the agent loop executes requested tools
|
||||||
|
3. tool outputs are fed back into the next turn
|
||||||
|
4. extensions add tools, commands, event listeners, and prompt fragments without changing the loop
|
||||||
|
|
||||||
|
The harness also emits ordered trace logs to stderr so you can see the control flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[01] tool.registered {"tool":"list_files"}
|
||||||
|
[02] tool.registered {"tool":"read_file"}
|
||||||
|
[03] extension.loading {"extension":"example.ts"}
|
||||||
|
[04] tool.registered {"tool":"echo"}
|
||||||
|
[05] extension.loaded {"extension":"example.ts"}
|
||||||
|
[06] agent.started {"maxTurns":8}
|
||||||
|
[07] agent.turn.started {"turn":1}
|
||||||
|
[08] model.request {"model":"gpt-5","messages":1,"tools":["list_files","read_file","echo"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Commands run before the model loop. They are a lightweight control plane for
|
||||||
|
things that do not need inference:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- "/help"
|
||||||
|
npm run dev -- "/tools"
|
||||||
|
```
|
||||||
|
|
||||||
|
The core provides:
|
||||||
|
|
||||||
|
- `/help` — show available commands
|
||||||
|
- `/reset` — clear the current conversation history
|
||||||
|
- `/exit` — leave the interactive session
|
||||||
|
|
||||||
|
Extensions can register their own commands.
|
||||||
|
|
||||||
|
## System prompt
|
||||||
|
|
||||||
|
Yes, there is now an explicit system prompt. By default it is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
You are a helpful agent. Use tools when they are useful, and answer clearly.
|
||||||
|
```
|
||||||
|
|
||||||
|
Override it with the `SYSTEM_PROMPT` environment variable. Extensions can also
|
||||||
|
append fragments to the final system prompt with `addSystemPrompt(...)`.
|
||||||
|
|
||||||
|
That gives us a clean path to later add:
|
||||||
|
|
||||||
|
- more providers
|
||||||
|
- write/edit/bash tools
|
||||||
|
- permissions
|
||||||
|
- session persistence
|
||||||
|
- lifecycle hooks
|
||||||
|
- streaming or a TUI
|
||||||
|
|
||||||
|
## Add a tool with an extension
|
||||||
|
|
||||||
|
Create a file in `extensions/` that exports a default function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { Extension } from "../src/types.js";
|
||||||
|
|
||||||
|
const myExtension: Extension = async ({
|
||||||
|
registerTool,
|
||||||
|
registerCommand,
|
||||||
|
on,
|
||||||
|
addSystemPrompt
|
||||||
|
}) => {
|
||||||
|
await registerTool({
|
||||||
|
name: "greet",
|
||||||
|
description: "Greet a person by name.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
required: ["name"],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
execute(input: { name: string }) {
|
||||||
|
return `Hello, ${input.name}!`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerCommand({
|
||||||
|
name: "greet",
|
||||||
|
description: "Greet someone without calling the model.",
|
||||||
|
execute(args) {
|
||||||
|
return `Hello, ${args || "friend"}!`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addSystemPrompt("Prefer the greet tool when a greeting is explicitly requested.");
|
||||||
|
|
||||||
|
on("tool.call.completed", ({ tool }) => {
|
||||||
|
if (tool === "greet") {
|
||||||
|
// Observe tool usage, persist metrics, etc.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default myExtension;
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use either path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev -- "Use the greet tool for Ada."
|
||||||
|
npm run dev -- "/greet Ada"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Intentional omissions
|
||||||
|
|
||||||
|
This first version does **not** include file writes, shell execution, approval
|
||||||
|
gates, or persistent sessions. Those are useful, but adding them after the core
|
||||||
|
shape is visible keeps the harness easier to reason about.
|
||||||
42
extensions/example.ts
Normal file
42
extensions/example.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Extension } from "../src/types.js";
|
||||||
|
|
||||||
|
const exampleExtension: Extension = async ({
|
||||||
|
registerTool,
|
||||||
|
registerCommand,
|
||||||
|
on,
|
||||||
|
addSystemPrompt
|
||||||
|
}) => {
|
||||||
|
await registerTool({
|
||||||
|
name: "echo",
|
||||||
|
description: "Echo text back exactly as provided.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
text: { type: "string" }
|
||||||
|
},
|
||||||
|
required: ["text"],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
execute(input: { text: string }) {
|
||||||
|
return input.text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await registerCommand({
|
||||||
|
name: "tools",
|
||||||
|
description: "Show tools registered by the example extension.",
|
||||||
|
execute() {
|
||||||
|
return "Example extension tools: echo";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addSystemPrompt("The echo tool is available when exact repetition is useful.");
|
||||||
|
|
||||||
|
on("tool.call.completed", ({ tool }) => {
|
||||||
|
if (tool === "echo") {
|
||||||
|
// Placeholder for extension-side behavior such as metrics or persistence.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default exampleExtension;
|
||||||
590
package-lock.json
generated
Normal file
590
package-lock.json
generated
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
{
|
||||||
|
"name": "simple-agent-harness",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "simple-agent-harness",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"openai": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"tsx": "^4.20.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||||
|
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
|
"@esbuild/android-arm": "0.28.0",
|
||||||
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
|
"@esbuild/android-x64": "0.28.0",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/openai": {
|
||||||
|
"version": "5.23.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/openai/-/openai-5.23.2.tgz",
|
||||||
|
"integrity": "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"openai": "bin/cli"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "^8.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ws": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
|
||||||
|
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "simple-agent-harness",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"openai": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"tsx": "^4.20.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/agent.ts
Normal file
89
src/agent.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { AgentMessage, ModelClient } from "./types.js";
|
||||||
|
import { ToolRegistry } from "./tools/registry.js";
|
||||||
|
import type { EventBus } from "./events.js";
|
||||||
|
|
||||||
|
export type AgentOptions = {
|
||||||
|
cwd: string;
|
||||||
|
maxTurns?: number;
|
||||||
|
events?: EventBus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Agent {
|
||||||
|
private readonly history: AgentMessage[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly model: ModelClient,
|
||||||
|
private readonly tools: ToolRegistry,
|
||||||
|
private readonly options: AgentOptions
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async runTurn(prompt: string): Promise<string> {
|
||||||
|
const events = this.options.events;
|
||||||
|
const maxTurns = this.options.maxTurns ?? 20;
|
||||||
|
|
||||||
|
this.history.push({ role: "user", content: prompt });
|
||||||
|
await events?.emit("agent.started", { maxTurns });
|
||||||
|
|
||||||
|
for (let turn = 0; turn < maxTurns; turn += 1) {
|
||||||
|
await events?.emit("agent.turn.started", { turn: turn + 1 });
|
||||||
|
const result = await this.model.respond(this.history, this.tools.list());
|
||||||
|
this.history.push(...(result.output as AgentMessage[]));
|
||||||
|
|
||||||
|
if (result.toolCalls.length === 0) {
|
||||||
|
await events?.emit("agent.completed", { turn: turn + 1 });
|
||||||
|
return result.outputText;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const call of result.toolCalls) {
|
||||||
|
await events?.emit("tool.call.requested", { tool: call.name });
|
||||||
|
const tool = this.tools.get(call.name);
|
||||||
|
|
||||||
|
if (!tool) {
|
||||||
|
await events?.emit("tool.call.unknown", { tool: call.name });
|
||||||
|
this.history.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify({ error: `Unknown tool: ${call.name}` })
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(call.arguments);
|
||||||
|
await events?.emit("tool.call.started", { tool: call.name, input: parsed });
|
||||||
|
const output = await tool.execute(parsed, { cwd: this.options.cwd });
|
||||||
|
await events?.emit("tool.call.completed", { tool: call.name });
|
||||||
|
|
||||||
|
this.history.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify(output)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await events?.emit("tool.call.failed", {
|
||||||
|
tool: call.name,
|
||||||
|
error: error instanceof Error ? error.message : "Tool execution failed"
|
||||||
|
});
|
||||||
|
this.history.push({
|
||||||
|
type: "function_call_output",
|
||||||
|
call_id: call.id,
|
||||||
|
output: JSON.stringify({
|
||||||
|
error: error instanceof Error ? error.message : "Tool execution failed"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await events?.emit("agent.failed", { reason: "max_turns_exceeded", maxTurns });
|
||||||
|
throw new Error(`Agent exceeded max turns (${maxTurns}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHistory(): AgentMessage[] {
|
||||||
|
return [...this.history];
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.history.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/commands.ts
Normal file
25
src/commands.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { EventBus } from "./events.js";
|
||||||
|
import type { CommandDefinition } from "./types.js";
|
||||||
|
|
||||||
|
export class CommandRegistry {
|
||||||
|
private readonly commands = new Map<string, CommandDefinition>();
|
||||||
|
|
||||||
|
constructor(private readonly events: EventBus) {}
|
||||||
|
|
||||||
|
async register(command: CommandDefinition): Promise<void> {
|
||||||
|
if (this.commands.has(command.name)) {
|
||||||
|
throw new Error(`Command already registered: ${command.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commands.set(command.name, command);
|
||||||
|
await this.events.emit("command.registered", { command: command.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): CommandDefinition[] {
|
||||||
|
return [...this.commands.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string): CommandDefinition | undefined {
|
||||||
|
return this.commands.get(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/events.ts
Normal file
47
src/events.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
export type EventMap = {
|
||||||
|
"tool.registered": { tool: string };
|
||||||
|
"command.registered": { command: string };
|
||||||
|
"extension.loading": { extension: string };
|
||||||
|
"extension.loaded": { extension: string };
|
||||||
|
"extensions.skipped": { reason: string };
|
||||||
|
"command.started": { command: string; args: string };
|
||||||
|
"command.completed": { command: string };
|
||||||
|
"command.failed": { command: string; error: string };
|
||||||
|
"agent.started": { maxTurns: number };
|
||||||
|
"agent.turn.started": { turn: number };
|
||||||
|
"agent.completed": { turn: number };
|
||||||
|
"agent.failed": { reason: string; maxTurns: number };
|
||||||
|
"model.request": { model: string; messages: number; tools: string[] };
|
||||||
|
"model.response": { outputItems: number; toolCalls: string[]; hasText: boolean };
|
||||||
|
"tool.call.requested": { tool: string };
|
||||||
|
"tool.call.unknown": { tool: string };
|
||||||
|
"tool.call.started": { tool: string; input: unknown };
|
||||||
|
"tool.call.completed": { tool: string };
|
||||||
|
"tool.call.failed": { tool: string; error: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventName = keyof EventMap;
|
||||||
|
export type EventHandler<K extends EventName> = (payload: EventMap[K]) => void | Promise<void>;
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private readonly handlers = new Map<EventName, Set<EventHandler<any>>>();
|
||||||
|
|
||||||
|
on<K extends EventName>(event: K, handler: EventHandler<K>): () => void {
|
||||||
|
const handlers = this.handlers.get(event) ?? new Set<EventHandler<any>>();
|
||||||
|
handlers.add(handler);
|
||||||
|
this.handlers.set(event, handlers);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit<K extends EventName>(event: K, payload: EventMap[K]): Promise<void> {
|
||||||
|
const handlers = this.handlers.get(event);
|
||||||
|
if (!handlers) return;
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
await handler(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/extensions.ts
Normal file
48
src/extensions.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import type { Extension } from "./types.js";
|
||||||
|
import { ToolRegistry } from "./tools/registry.js";
|
||||||
|
import { CommandRegistry } from "./commands.js";
|
||||||
|
import type { EventBus } from "./events.js";
|
||||||
|
|
||||||
|
export async function loadExtensions(
|
||||||
|
extensionsDir: string,
|
||||||
|
registry: ToolRegistry,
|
||||||
|
commands: CommandRegistry,
|
||||||
|
events: EventBus,
|
||||||
|
cwd: string,
|
||||||
|
promptFragments: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
let entries: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
entries = await readdir(extensionsDir);
|
||||||
|
} catch {
|
||||||
|
await events.emit("extensions.skipped", { reason: "directory_missing" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith(".ts") && !entry.endsWith(".js")) continue;
|
||||||
|
|
||||||
|
await events.emit("extension.loading", { extension: entry });
|
||||||
|
const moduleUrl = pathToFileURL(join(extensionsDir, entry)).href;
|
||||||
|
const imported = (await import(moduleUrl)) as { default?: Extension };
|
||||||
|
|
||||||
|
if (typeof imported.default !== "function") {
|
||||||
|
throw new Error(`Extension ${entry} must export a default function.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await imported.default({
|
||||||
|
registerTool: (tool) => registry.register(tool),
|
||||||
|
registerCommand: (command) => commands.register(command),
|
||||||
|
on: (event, handler) => events.on(event, handler),
|
||||||
|
addSystemPrompt: (fragment) => {
|
||||||
|
promptFragments.push(fragment);
|
||||||
|
},
|
||||||
|
getContext: () => ({ cwd })
|
||||||
|
});
|
||||||
|
await events.emit("extension.loaded", { extension: entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/index.ts
Normal file
151
src/index.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { createInterface } from "node:readline/promises";
|
||||||
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import { Agent } from "./agent.js";
|
||||||
|
import { CommandRegistry } from "./commands.js";
|
||||||
|
import { EventBus } from "./events.js";
|
||||||
|
import { loadExtensions } from "./extensions.js";
|
||||||
|
import { ConsoleLogger } from "./logger.js";
|
||||||
|
import { OpenAIResponsesClient } from "./model/openai.js";
|
||||||
|
import { builtinTools } from "./tools/builtins.js";
|
||||||
|
import { ToolRegistry } from "./tools/registry.js";
|
||||||
|
|
||||||
|
const initialPrompt = process.argv.slice(2).join(" ").trim();
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const logger = new ConsoleLogger();
|
||||||
|
const events = new EventBus();
|
||||||
|
const registry = new ToolRegistry(events);
|
||||||
|
const commands = new CommandRegistry(events);
|
||||||
|
const promptFragments = [
|
||||||
|
process.env.SYSTEM_PROMPT ??
|
||||||
|
"You are a helpful agent. Use tools when they are useful, and answer clearly."
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of [
|
||||||
|
"tool.registered",
|
||||||
|
"command.registered",
|
||||||
|
"extension.loading",
|
||||||
|
"extension.loaded",
|
||||||
|
"extensions.skipped",
|
||||||
|
"command.started",
|
||||||
|
"command.completed",
|
||||||
|
"command.failed",
|
||||||
|
"agent.started",
|
||||||
|
"agent.turn.started",
|
||||||
|
"agent.completed",
|
||||||
|
"agent.failed",
|
||||||
|
"model.request",
|
||||||
|
"model.response",
|
||||||
|
"tool.call.requested",
|
||||||
|
"tool.call.unknown",
|
||||||
|
"tool.call.started",
|
||||||
|
"tool.call.completed",
|
||||||
|
"tool.call.failed"
|
||||||
|
] as const) {
|
||||||
|
events.on(event, (payload) => logger.event(event, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of builtinTools()) {
|
||||||
|
await registry.register(tool);
|
||||||
|
}
|
||||||
|
|
||||||
|
await commands.register({
|
||||||
|
name: "help",
|
||||||
|
description: "Show available commands.",
|
||||||
|
execute() {
|
||||||
|
const lines = commands
|
||||||
|
.list()
|
||||||
|
.map((command) => `/${command.name} — ${command.description}`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return `${lines}\n/exit — Leave the interactive session.`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await commands.register({
|
||||||
|
name: "reset",
|
||||||
|
description: "Clear the current conversation history.",
|
||||||
|
execute() {
|
||||||
|
currentAgent?.reset();
|
||||||
|
return "Conversation reset.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadExtensions(resolve(cwd, "extensions"), registry, commands, events, cwd, promptFragments);
|
||||||
|
|
||||||
|
const systemPrompt = promptFragments.join("\n\n");
|
||||||
|
let currentAgent: Agent | undefined;
|
||||||
|
|
||||||
|
function getAgent(): Agent {
|
||||||
|
currentAgent ??= new Agent(
|
||||||
|
new OpenAIResponsesClient("gpt-5", systemPrompt, events),
|
||||||
|
registry,
|
||||||
|
{
|
||||||
|
cwd,
|
||||||
|
events
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return currentAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(prompt: string): Promise<boolean> {
|
||||||
|
if (!prompt.startsWith("/")) return false;
|
||||||
|
|
||||||
|
const [rawCommand, ...rest] = prompt.slice(1).split(" ");
|
||||||
|
const command = commands.get(rawCommand);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
console.error(`Unknown command: /${rawCommand}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = rest.join(" ").trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await events.emit("command.started", { command: command.name, args });
|
||||||
|
const output = await command.execute(args, { cwd });
|
||||||
|
await events.emit("command.completed", { command: command.name });
|
||||||
|
console.log(output);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Command failed";
|
||||||
|
await events.emit("command.failed", { command: command.name, error: message });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePrompt(prompt: string): Promise<void> {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
|
||||||
|
if (await handleCommand(prompt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const answer = await getAgent().runTurn(prompt);
|
||||||
|
console.log(`assistant> ${answer}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialPrompt) {
|
||||||
|
await handlePrompt(initialPrompt);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = createInterface({ input, output });
|
||||||
|
|
||||||
|
console.log("Simple Agent Harness");
|
||||||
|
console.log('Type a message, "/help" for commands, or "/exit" to quit.');
|
||||||
|
output.write("you> ");
|
||||||
|
|
||||||
|
for await (const prompt of rl) {
|
||||||
|
if (prompt.trim() === "/exit") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handlePrompt(prompt);
|
||||||
|
output.write("you> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.close();
|
||||||
19
src/logger.ts
Normal file
19
src/logger.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type LogData = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface Logger {
|
||||||
|
event(name: string, data?: LogData): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConsoleLogger implements Logger {
|
||||||
|
private sequence = 0;
|
||||||
|
|
||||||
|
event(name: string, data: LogData = {}): void {
|
||||||
|
this.sequence += 1;
|
||||||
|
const suffix = Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : "";
|
||||||
|
console.error(`[${String(this.sequence).padStart(2, "0")}] ${name}${suffix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NullLogger implements Logger {
|
||||||
|
event(): void {}
|
||||||
|
}
|
||||||
60
src/model/openai.ts
Normal file
60
src/model/openai.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
|
import type {
|
||||||
|
AgentMessage,
|
||||||
|
ModelClient,
|
||||||
|
ModelTurn,
|
||||||
|
ToolCall,
|
||||||
|
ToolDefinition
|
||||||
|
} from "../types.js";
|
||||||
|
import type { EventBus } from "../events.js";
|
||||||
|
|
||||||
|
export class OpenAIResponsesClient implements ModelClient {
|
||||||
|
private readonly client = new OpenAI();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly model = "gpt-5",
|
||||||
|
private readonly instructions?: string,
|
||||||
|
private readonly events?: EventBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async respond(input: AgentMessage[], tools: ToolDefinition[]): Promise<ModelTurn> {
|
||||||
|
await this.events?.emit("model.request", {
|
||||||
|
model: this.model,
|
||||||
|
messages: input.length,
|
||||||
|
tools: tools.map((tool) => tool.name)
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.client.responses.create({
|
||||||
|
model: this.model,
|
||||||
|
instructions: this.instructions,
|
||||||
|
input,
|
||||||
|
tools: tools.map((tool) => ({
|
||||||
|
type: "function",
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
parameters: tool.parameters,
|
||||||
|
strict: true
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolCalls: ToolCall[] = response.output
|
||||||
|
.filter((item): item is OpenAI.Responses.ResponseFunctionToolCall => item.type === "function_call")
|
||||||
|
.map((item) => ({
|
||||||
|
id: item.call_id,
|
||||||
|
name: item.name,
|
||||||
|
arguments: item.arguments
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.events?.emit("model.response", {
|
||||||
|
outputItems: response.output.length,
|
||||||
|
toolCalls: toolCalls.map((toolCall) => toolCall.name),
|
||||||
|
hasText: response.output_text.length > 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: response.output,
|
||||||
|
outputText: response.output_text,
|
||||||
|
toolCalls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/tools/builtins.ts
Normal file
46
src/tools/builtins.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { ToolDefinition } from "../types.js";
|
||||||
|
|
||||||
|
export function builtinTools(): ToolDefinition<any>[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "list_files",
|
||||||
|
description: "List files and directories at a relative path.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: "string",
|
||||||
|
description: "Relative path from the current working directory."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
async execute(input: { path: string }, context) {
|
||||||
|
const target = resolve(context.cwd, input.path);
|
||||||
|
return await readdir(target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_file",
|
||||||
|
description: "Read a UTF-8 text file at a relative path.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
path: {
|
||||||
|
type: "string",
|
||||||
|
description: "Relative path from the current working directory."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["path"],
|
||||||
|
additionalProperties: false
|
||||||
|
},
|
||||||
|
async execute(input: { path: string }, context) {
|
||||||
|
const target = resolve(context.cwd, input.path);
|
||||||
|
return await readFile(target, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
24
src/tools/registry.ts
Normal file
24
src/tools/registry.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ToolDefinition } from "../types.js";
|
||||||
|
import type { EventBus } from "../events.js";
|
||||||
|
|
||||||
|
export class ToolRegistry {
|
||||||
|
private readonly tools = new Map<string, ToolDefinition<any>>();
|
||||||
|
|
||||||
|
constructor(private readonly events: EventBus) {}
|
||||||
|
|
||||||
|
async register(tool: ToolDefinition<any>): Promise<void> {
|
||||||
|
if (this.tools.has(tool.name)) {
|
||||||
|
throw new Error(`Tool already registered: ${tool.name}`);
|
||||||
|
}
|
||||||
|
this.tools.set(tool.name, tool);
|
||||||
|
await this.events.emit("tool.registered", { tool: tool.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): ToolDefinition<any>[] {
|
||||||
|
return [...this.tools.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string): ToolDefinition<any> | undefined {
|
||||||
|
return this.tools.get(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/types.ts
Normal file
59
src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type OpenAI from "openai";
|
||||||
|
|
||||||
|
export type JsonSchema = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ToolContext = {
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolDefinition<TInput = unknown> = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: JsonSchema;
|
||||||
|
execute: (input: TInput, context: ToolContext) => Promise<unknown> | unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCall = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentMessage = OpenAI.Responses.ResponseInputItem;
|
||||||
|
|
||||||
|
export type ModelTurn = {
|
||||||
|
output: unknown[];
|
||||||
|
outputText: string;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ModelClient {
|
||||||
|
respond(input: AgentMessage[], tools: ToolDefinition[]): Promise<ModelTurn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommandContext = {
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandDefinition = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
execute: (args: string, context: CommandContext) => Promise<string> | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionContext = {
|
||||||
|
cwd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExtensionApi = {
|
||||||
|
registerTool: (tool: ToolDefinition<any>) => Promise<void>;
|
||||||
|
registerCommand: (command: CommandDefinition) => Promise<void>;
|
||||||
|
on: <K extends import("./events.js").EventName>(
|
||||||
|
event: K,
|
||||||
|
handler: import("./events.js").EventHandler<K>
|
||||||
|
) => () => void;
|
||||||
|
addSystemPrompt: (fragment: string) => void;
|
||||||
|
getContext: () => ExtensionContext;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Extension = (api: ExtensionApi) => void | Promise<void>;
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "extensions/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user