commit d8d79efe833eb12cafc6ebf1e09c015d5b3ee80b Author: Adolfo Reyna Date: Fri May 15 14:17:07 2026 -0400 Build extensible agent harness with interactive chat loop diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c97bbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..8acb9a1 --- /dev/null +++ b/README.md @@ -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. diff --git a/extensions/example.ts b/extensions/example.ts new file mode 100644 index 0000000..038e361 --- /dev/null +++ b/extensions/example.ts @@ -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; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e18bdd8 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..615ecf5 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..f5d4f98 --- /dev/null +++ b/src/agent.ts @@ -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 { + 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; + } +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..ca41be2 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,25 @@ +import type { EventBus } from "./events.js"; +import type { CommandDefinition } from "./types.js"; + +export class CommandRegistry { + private readonly commands = new Map(); + + constructor(private readonly events: EventBus) {} + + async register(command: CommandDefinition): Promise { + 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); + } +} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..cdff69f --- /dev/null +++ b/src/events.ts @@ -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 = (payload: EventMap[K]) => void | Promise; + +export class EventBus { + private readonly handlers = new Map>>(); + + on(event: K, handler: EventHandler): () => void { + const handlers = this.handlers.get(event) ?? new Set>(); + handlers.add(handler); + this.handlers.set(event, handlers); + + return () => { + handlers.delete(handler); + }; + } + + async emit(event: K, payload: EventMap[K]): Promise { + const handlers = this.handlers.get(event); + if (!handlers) return; + + for (const handler of handlers) { + await handler(payload); + } + } +} diff --git a/src/extensions.ts b/src/extensions.ts new file mode 100644 index 0000000..685245e --- /dev/null +++ b/src/extensions.ts @@ -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 { + 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 }); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..76d2db9 --- /dev/null +++ b/src/index.ts @@ -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 { + 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 { + 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(); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..aa95b68 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,19 @@ +export type LogData = Record; + +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 {} +} diff --git a/src/model/openai.ts b/src/model/openai.ts new file mode 100644 index 0000000..72eb613 --- /dev/null +++ b/src/model/openai.ts @@ -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 { + 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 + }; + } +} diff --git a/src/tools/builtins.ts b/src/tools/builtins.ts new file mode 100644 index 0000000..8c1e378 --- /dev/null +++ b/src/tools/builtins.ts @@ -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[] { + 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"); + } + } + ]; +} diff --git a/src/tools/registry.ts b/src/tools/registry.ts new file mode 100644 index 0000000..ac38f54 --- /dev/null +++ b/src/tools/registry.ts @@ -0,0 +1,24 @@ +import type { ToolDefinition } from "../types.js"; +import type { EventBus } from "../events.js"; + +export class ToolRegistry { + private readonly tools = new Map>(); + + constructor(private readonly events: EventBus) {} + + async register(tool: ToolDefinition): Promise { + 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[] { + return [...this.tools.values()]; + } + + get(name: string): ToolDefinition | undefined { + return this.tools.get(name); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..561f649 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,59 @@ +import type OpenAI from "openai"; + +export type JsonSchema = Record; + +export type ToolContext = { + cwd: string; +}; + +export type ToolDefinition = { + name: string; + description: string; + parameters: JsonSchema; + execute: (input: TInput, context: ToolContext) => Promise | 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; +} + +export type CommandContext = { + cwd: string; +}; + +export type CommandDefinition = { + name: string; + description: string; + execute: (args: string, context: CommandContext) => Promise | string; +}; + +export type ExtensionContext = { + cwd: string; +}; + +export type ExtensionApi = { + registerTool: (tool: ToolDefinition) => Promise; + registerCommand: (command: CommandDefinition) => Promise; + on: ( + event: K, + handler: import("./events.js").EventHandler + ) => () => void; + addSystemPrompt: (fragment: string) => void; + getContext: () => ExtensionContext; +}; + +export type Extension = (api: ExtensionApi) => void | Promise; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1cc1c88 --- /dev/null +++ b/tsconfig.json @@ -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"] +}