summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/agent/agent.ts142
-rw-r--r--packages/core/src/index.ts9
-rw-r--r--packages/core/src/llm/provider.ts9
-rw-r--r--packages/core/src/tools/list-files.ts38
-rw-r--r--packages/core/src/tools/read-file.ts33
-rw-r--r--packages/core/src/tools/registry.ts35
-rw-r--r--packages/core/src/tools/write-file.ts33
-rw-r--r--packages/core/src/types/index.ts53
8 files changed, 352 insertions, 0 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
new file mode 100644
index 0000000..a20a4ce
--- /dev/null
+++ b/packages/core/src/agent/agent.ts
@@ -0,0 +1,142 @@
+import type { CoreMessage } from "ai";
+import { streamText } from "ai";
+import { createProvider } from "../llm/provider.js";
+import { createToolRegistry } from "../tools/registry.js";
+import type {
+ AgentConfig,
+ AgentEvent,
+ AgentStatus,
+ ChatMessage,
+ ToolCall,
+ ToolResult,
+} from "../types/index.js";
+
+function toCoreMessages(messages: ChatMessage[]): CoreMessage[] {
+ const result: CoreMessage[] = [];
+ for (const msg of messages) {
+ if (msg.role === "user") {
+ result.push({ role: "user", content: msg.content });
+ } else if (msg.role === "assistant") {
+ result.push({ role: "assistant", content: msg.content });
+ }
+ }
+ return result;
+}
+
+function formatError(err: unknown, config: AgentConfig): string {
+ const context = `[model=${config.model}, baseURL=${config.baseURL}]`;
+
+ if (err instanceof Error) {
+ const cause = err.cause ? ` | cause: ${JSON.stringify(err.cause)}` : "";
+ // AI SDK errors often have statusCode, responseBody, or url properties
+ const extras: string[] = [];
+ const errRecord = err as unknown as Record<string, unknown>;
+ if ("statusCode" in errRecord) extras.push(`status=${errRecord.statusCode}`);
+ if ("url" in errRecord) extras.push(`url=${errRecord.url}`);
+ if ("responseBody" in errRecord) extras.push(`body=${JSON.stringify(errRecord.responseBody)}`);
+ if ("responseHeaders" in errRecord)
+ extras.push(`headers=${JSON.stringify(errRecord.responseHeaders)}`);
+
+ const detail = extras.length > 0 ? ` (${extras.join(", ")})` : "";
+ return `${err.message}${detail}${cause} ${context}`;
+ }
+
+ return `${String(err)} ${context}`;
+}
+
+export class Agent {
+ status: AgentStatus = "idle";
+ messages: ChatMessage[] = [];
+
+ private config: AgentConfig;
+
+ constructor(config: AgentConfig) {
+ this.config = config;
+ }
+
+ async *run(userMessage: string): AsyncGenerator<AgentEvent> {
+ this.status = "running";
+ yield { type: "status", status: "running" };
+
+ this.messages.push({ role: "user", content: userMessage });
+
+ const registry = createToolRegistry(this.config.tools);
+ const providerFactory = createProvider({
+ apiKey: this.config.apiKey,
+ baseURL: this.config.baseURL,
+ });
+
+ try {
+ const result = streamText({
+ model: providerFactory(this.config.model),
+ system: this.config.systemPrompt,
+ messages: toCoreMessages(this.messages),
+ tools: registry.getAISDKTools(),
+ maxSteps: 10,
+ });
+
+ let fullText = "";
+ const toolCalls: ToolCall[] = [];
+ const toolResults: ToolResult[] = [];
+
+ for await (const event of result.fullStream) {
+ if (event.type === "text-delta") {
+ fullText += event.textDelta;
+ yield { type: "text-delta", delta: event.textDelta };
+ } else if (event.type === "tool-call") {
+ const toolCall: ToolCall = {
+ id: event.toolCallId,
+ name: event.toolName,
+ arguments: event.args as Record<string, unknown>,
+ };
+ toolCalls.push(toolCall);
+ yield { type: "tool-call", toolCall };
+ } else if (event.type === "error") {
+ const errorMsg = formatError(event.error, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+ }
+
+ // Tool results are available from completed steps after streaming.
+ // The generic TOOLS type resolves to never[] at compile time, so
+ // we cast through unknown to access the runtime shape.
+ const steps = await result.steps;
+ for (const step of steps) {
+ const stepToolResults = step.toolResults as unknown as Array<{
+ toolCallId: string;
+ result: unknown;
+ }>;
+ for (const tr of stepToolResults) {
+ const toolResult: ToolResult = {
+ toolCallId: tr.toolCallId,
+ result: typeof tr.result === "string" ? tr.result : JSON.stringify(tr.result),
+ isError: false,
+ };
+ toolResults.push(toolResult);
+ yield { type: "tool-result", toolResult };
+ }
+ }
+
+ const assistantMessage: ChatMessage = {
+ role: "assistant",
+ content: fullText,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
+ };
+ this.messages.push(assistantMessage);
+ yield { type: "done", message: assistantMessage };
+ } catch (err) {
+ const errorMsg = formatError(err, this.config);
+ yield { type: "error", error: errorMsg };
+ this.status = "error";
+ yield { type: "status", status: "error" };
+ return;
+ }
+
+ this.status = "idle";
+ yield { type: "status", status: "idle" };
+ }
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
new file mode 100644
index 0000000..b3907fa
--- /dev/null
+++ b/packages/core/src/index.ts
@@ -0,0 +1,9 @@
+// @dispatch/core — Agent runtime, LLM integration, tools
+
+export { Agent } from "./agent/agent.js";
+export { createProvider } from "./llm/provider.js";
+export { createListFilesTool } from "./tools/list-files.js";
+export { createReadFileTool } from "./tools/read-file.js";
+export { createToolRegistry } from "./tools/registry.js";
+export { createWriteFileTool } from "./tools/write-file.js";
+export * from "./types/index.js";
diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts
new file mode 100644
index 0000000..06f7b72
--- /dev/null
+++ b/packages/core/src/llm/provider.ts
@@ -0,0 +1,9 @@
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
+
+export function createProvider(config: { apiKey: string; baseURL: string }) {
+ return createOpenAICompatible({
+ name: "opencode-zen",
+ apiKey: config.apiKey,
+ baseURL: config.baseURL,
+ });
+}
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
new file mode 100644
index 0000000..360ac98
--- /dev/null
+++ b/packages/core/src/tools/list-files.ts
@@ -0,0 +1,38 @@
+import { readdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createListFilesTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "list_files",
+ description: "List files and directories at a path relative to the working directory.",
+ parameters: z.object({
+ path: z
+ .string()
+ .optional()
+ .describe("Path to list, relative to the working directory. Defaults to '.'"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const relPath = (args.path as string | undefined) ?? ".";
+ const absolutePath = resolve(join(workingDirectory, relPath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${relPath}" is outside the working directory.`;
+ }
+
+ try {
+ const entries = await readdir(absolutePath, { withFileTypes: true });
+ if (entries.length === 0) {
+ return "(empty directory)";
+ }
+ return entries
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
+ .join("\n");
+ } catch (err) {
+ return `Error listing files: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
new file mode 100644
index 0000000..476f243
--- /dev/null
+++ b/packages/core/src/tools/read-file.ts
@@ -0,0 +1,33 @@
+import { readFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createReadFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "read_file",
+ description: "Read the contents of a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ return await readFile(absolutePath, "utf8");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return `Error: File "${filePath}" not found.`;
+ }
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
new file mode 100644
index 0000000..4699c93
--- /dev/null
+++ b/packages/core/src/tools/registry.ts
@@ -0,0 +1,35 @@
+import { tool } from "ai";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createToolRegistry(tools: ToolDefinition[]) {
+ const toolMap = new Map<string, ToolDefinition>(tools.map((t) => [t.name, t]));
+
+ return {
+ getTools(): ToolDefinition[] {
+ return [...toolMap.values()];
+ },
+
+ getTool(name: string): ToolDefinition | undefined {
+ return toolMap.get(name);
+ },
+
+ getAISDKTools() {
+ const result: Record<string, ReturnType<typeof tool>> = {};
+ for (const [name, def] of toolMap) {
+ const schema = def.parameters;
+ const t = tool({
+ description: def.description,
+ parameters: schema instanceof z.ZodObject ? schema : z.object({}),
+ execute: async (args) => {
+ return def.execute(args as Record<string, unknown>);
+ },
+ });
+ // The AI SDK tool() overloads cause type narrowing issues when
+ // execute is provided. The runtime value is correct.
+ result[name] = t as unknown as ReturnType<typeof tool>;
+ }
+ return result;
+ },
+ };
+}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
new file mode 100644
index 0000000..23bc72a
--- /dev/null
+++ b/packages/core/src/tools/write-file.ts
@@ -0,0 +1,33 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join, resolve } from "node:path";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+export function createWriteFileTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "write_file",
+ description: "Write content to a file relative to the working directory.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ content: z.string().describe("Content to write to the file"),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const content = args.content as string;
+ const absolutePath = resolve(join(workingDirectory, filePath));
+ const absoluteWorkDir = resolve(workingDirectory);
+
+ if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ try {
+ await mkdir(dirname(absolutePath), { recursive: true });
+ await writeFile(absolutePath, content, "utf8");
+ return `Successfully wrote to "${filePath}".`;
+ } catch (err) {
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
new file mode 100644
index 0000000..a408e7d
--- /dev/null
+++ b/packages/core/src/types/index.ts
@@ -0,0 +1,53 @@
+import type { ZodType } from "zod";
+
+// Message types for the agent conversation
+export type MessageRole = "user" | "assistant" | "tool";
+
+export interface ChatMessage {
+ role: MessageRole;
+ content: string;
+ toolCalls?: ToolCall[];
+ toolResults?: ToolResult[];
+}
+
+export interface ToolCall {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+}
+
+export interface ToolResult {
+ toolCallId: string;
+ result: string;
+ isError: boolean;
+}
+
+// Agent status
+export type AgentStatus = "idle" | "running" | "error";
+
+// Agent events emitted during execution (for WebSocket streaming)
+export type AgentEvent =
+ | { type: "status"; status: AgentStatus }
+ | { type: "text-delta"; delta: string }
+ | { type: "tool-call"; toolCall: ToolCall }
+ | { type: "tool-result"; toolResult: ToolResult }
+ | { type: "error"; error: string }
+ | { type: "done"; message: ChatMessage };
+
+// Tool definition interface
+export interface ToolDefinition {
+ name: string;
+ description: string;
+ parameters: ZodType;
+ execute: (args: Record<string, unknown>) => Promise<string>;
+}
+
+// Agent configuration
+export interface AgentConfig {
+ model: string;
+ apiKey: string;
+ baseURL: string;
+ systemPrompt: string;
+ tools: ToolDefinition[];
+ workingDirectory: string;
+}