Build extensible agent harness with interactive chat loop
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user