Build extensible agent harness with interactive chat loop
This commit is contained in:
89
src/agent.ts
Normal file
89
src/agent.ts
Normal 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
25
src/commands.ts
Normal 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
47
src/events.ts
Normal 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
48
src/extensions.ts
Normal 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
151
src/index.ts
Normal 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
19
src/logger.ts
Normal 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
60
src/model/openai.ts
Normal 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
46
src/tools/builtins.ts
Normal 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
24
src/tools/registry.ts
Normal 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
59
src/types.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user