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

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>;