194 lines
4.9 KiB
Markdown
194 lines
4.9 KiB
Markdown
# 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>
|
|
```
|
|
|
|
Interactive responses stream into the terminal as they are generated, and the
|
|
session keeps conversation history until you reset it. The current session is
|
|
saved to `.harness/session.json` and reloaded the next time the app starts.
|
|
|
|
The interactive mode is a small full-screen TUI: the transcript scrolls above,
|
|
while the input composer stays pinned to the bottom of the terminal. Press
|
|
`Ctrl+J` to insert a newline while composing.
|
|
|
|
```text
|
|
┌ transcript
|
|
│ system> Loaded 3 conversation items...
|
|
│ you> ...
|
|
│ assistant> ...
|
|
└────────────────────────────────
|
|
you> input stays here
|
|
```
|
|
|
|
For multiline input:
|
|
|
|
```text
|
|
you> summarize this:
|
|
line two
|
|
line three
|
|
```
|
|
|
|
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
|
|
- `/history` — show current session details
|
|
- `/new` — start a fresh empty conversation
|
|
- `/reset` — clear the current conversation history
|
|
- `/save` — persist the current conversation immediately
|
|
- `/logs [on|off]` — toggle lifecycle traces
|
|
- `/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 version still does **not** include file writes, shell execution, or approval
|
|
gates. Those are useful, but adding them after the core shape is visible keeps
|
|
the harness easier to reason about.
|