Build extensible agent harness with interactive chat loop

This commit is contained in:
Adolfo Reyna
2026-05-15 14:17:07 -04:00
commit d8d79efe83
16 changed files with 1398 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.env

164
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}