everything is an agent

This commit is contained in:
Ramnique Singh 2025-11-15 01:51:22 +05:30
parent 2d6a647c70
commit 80dae17fd1
24 changed files with 1261 additions and 1573 deletions

View file

@ -1,117 +1,19 @@
import { executeWorkflow, resumeWorkflow } from "./application/lib/exec-workflow.js";
import { streamAgent } from "./application/lib/agent.js";
import { StreamRenderer } from "./application/lib/stream-renderer.js";
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
type ParsedArgs = {
command: "run" | "resume" | "help" | null;
id: string | null;
interactive: boolean;
message: string;
};
function parseArgs(argv: string[]): ParsedArgs {
const args = argv.slice(2);
if (args.length === 0) {
return { command: "help", id: null, interactive: true, message: "" };
}
let command: ParsedArgs["command"] = null;
let id: string | null = null;
let interactive = true;
const messageParts: string[] = [];
if (args[0] !== "run" && args[0] !== "resume") {
command = "help";
return { command, id: null, interactive, message: "" };
}
command = args[0];
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
if (a === "--no-interactive") {
interactive = false;
} else if (a.startsWith("--interactive")) {
const [, value] = a.split("=");
if (value === undefined) {
interactive = true;
} else {
interactive = value !== "false";
}
}
continue;
}
if (!id) {
id = a;
continue;
}
messageParts.push(a);
}
return { command, id, interactive, message: messageParts.join(" ") };
}
function printUsage(): void {
console.log([
"Usage:",
" rowboatx run <workflow_id> [message...] [--interactive | --no-interactive]",
" rowboatx resume <run_id> [message...] [--interactive | --no-interactive]",
"",
"Flags:",
" --interactive Run interactively (default: true)",
" --no-interactive Disable interactive prompts",
].join("\n"));
}
async function promptForResumeInput(): Promise<string> {
const rl = createInterface({ input, output });
try {
const answer = await rl.question("Enter input to resume the run: ");
return answer;
} finally {
rl.close();
}
}
async function render(generator: AsyncGenerator<any, void, unknown>): Promise<void> {
export async function app(opts: {
agent: string;
runId?: string;
input?: string;
}) {
const renderer = new StreamRenderer();
for await (const event of generator) {
for await (const event of streamAgent({
...opts,
interactive: true,
})) {
renderer.render(event);
if (event?.type === "error") {
process.exitCode = 1;
}
}
}
async function main() {
const { command, id, interactive, message } = parseArgs(process.argv);
if (command === "help" || !command) {
printUsage();
return;
}
if (!id) {
printUsage();
process.exitCode = 1;
return;
}
switch (command) {
case "run": {
const initialInput = message ?? "";
await render(executeWorkflow(id, initialInput, interactive));
break;
}
case "resume": {
const resumeInput = message !== "" ? message : (interactive ? await promptForResumeInput() : "");
await render(resumeWorkflow(id, resumeInput, interactive));
break;
}
}
}
main().catch((err) => {
console.error("Failed:", err instanceof Error ? err.message : String(err));
process.exitCode = 1;
});
}

View file

@ -0,0 +1,20 @@
import { Agent, ToolAttachment } from "../entities/agent.js";
import z from "zod";
import { CopilotInstructions } from "./instructions.js";
import { BuiltinTools } from "../lib/builtin-tools.js";
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const [name, tool] of Object.entries(BuiltinTools)) {
tools[name] = {
type: "builtin",
name,
};
}
export const CopilotAgent: z.infer<typeof Agent> = {
name: "rowboatx",
description: "Rowboatx copilot",
instructions: CopilotInstructions,
model: "gpt-4.1",
tools,
}

View file

@ -1,731 +0,0 @@
import { streamText, ModelMessage, tool, stepCountIs } from "ai";
import * as readline from "readline/promises";
import { stdin as input, stdout as output } from "process";
import { z } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import * as os from "os";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamRenderer } from "../lib/stream-renderer.js";
import { getProvider } from "../lib/models.js";
import { ModelConfig } from "../config/config.js";
import { executeCommand } from "../lib/command-executor.js";
const rl = readline.createInterface({ input, output });
// Base directory for file operations - dynamically use user's home directory
const BASE_DIR = path.join(os.homedir(), ".rowboat");
// Ensure base directory exists
async function ensureBaseDir() {
try {
await fs.access(BASE_DIR);
} catch {
await fs.mkdir(BASE_DIR, { recursive: true });
console.log(`📁 Created directory: ${BASE_DIR}\n`);
}
}
// Export the main copilot function
export async function startCopilot() {
// Conversation history
const messages: ModelMessage[] = [];
console.log("🤖 Rowboat Copilot - Your Intelligent Workflow Assistant");
console.log(`📂 Working directory: ${BASE_DIR}`);
console.log("💡 I can help you create, manage, and understand workflows.");
console.log("Type 'exit' to quit\n");
// Initialize base directory
await ensureBaseDir();
while (true) {
// Get user input
const userInput = await rl.question("You: ");
// Exit condition
if (userInput.toLowerCase() === "exit" || userInput.toLowerCase() === "quit") {
console.log("\n👋 Goodbye!");
break;
}
// Add user message to history
messages.push({ role: "user", content: userInput });
// Stream AI response
process.stdout.write("\nCopilot: ");
let currentStep = 0;
const provider = getProvider();
const result = streamText({
model: provider(ModelConfig.defaults.model),
messages: messages,
system: `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}.
WORKFLOW KNOWLEDGE:
- Workflows are JSON files that orchestrate multiple agents
- Agents are JSON files defining AI assistants with specific tools and instructions
- Tools can be built-in functions or MCP (Model Context Protocol) integrations
NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files
CORRECT WORKFLOW FORMAT:
{
"name": "workflow_name", // REQUIRED - must match filename
"description": "Description...", // REQUIRED - must be a description of the workflow
"steps": [ // REQUIRED - array of steps
{
"type": "agent", // REQUIRED - always "agent"
"id": "agent_name" // REQUIRED - must match agent filename
},
{
"type": "agent",
"id": "another_agent_name"
}
]
}
CORRECT AGENT FORMAT (with detailed tool structure):
{
"name": "agent_name", // REQUIRED - must match filename
"description": "What agent does", // REQUIRED - must be a description of the agent
"model": "gpt-4.1", // REQUIRED - model to use
"instructions": "Instructions...", // REQUIRED - agent instructions
"tools": { // OPTIONAL - can be empty {} or omitted
"descriptive_tool_name": {
"type": "mcp", // REQUIRED - always "mcp" for MCP tools
"name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server
"description": "What tool does", // REQUIRED - clear description
"mcpServerName": "server_name", // REQUIRED - name from mcp.json config
"inputSchema": { // REQUIRED - full JSON schema
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": "Description of param" // description is optional but helpful
}
},
"required": ["param1"] // OPTIONAL - only include if params are required
}
}
}
}
IMPORTANT NOTES:
- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED)
- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name
- Agents can have empty tools {} if they don't need external tools
- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters
- If all parameters are optional, you can omit the "required" field entirely
- Property descriptions in inputSchema are optional but helpful for clarity
- All other fields marked REQUIRED must always be present
EXAMPLE 1 - Firecrawl Search Tool (with required params):
{
"tools": {
"search": {
"type": "mcp",
"name": "firecrawl_search",
"description": "Search the web",
"mcpServerName": "firecrawl",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "number", "description": "Number of results"},
"sources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["web", "images", "news"]}
},
"required": ["type"]
}
}
},
"required": ["query"]
}
}
}
}
EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array):
{
"tools": {
"text_to_speech": {
"type": "mcp",
"name": "text_to_speech",
"description": "Generate audio from text",
"mcpServerName": "elevenLabs",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string"}
}
}
}
}
}
CRITICAL NAMING AND ORGANIZATION RULES:
- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json "name": "agent_name")
- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json "name": "workflow_name")
- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"})
- All three must be identical: filename, JSON "name" field, and workflow step "id" field
- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json)
- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json)
- NEVER create workflows or agents outside these designated folders
- Always maintain this naming and organizational consistency when creating or updating files
YOUR CAPABILITIES:
1. Explore the directory structure to understand existing workflows/agents
2. Create new workflows and agents following best practices
3. Update existing files intelligently
4. Read and analyze file contents to maintain consistency
5. Suggest improvements and ask clarifying questions when needed
6. Execute shell commands to perform system operations
- Use executeCommand to run bash/shell commands
- Can list files, check system info, run scripts, etc.
- Commands execute in the .rowboat directory by default
7. List and explore MCP (Model Context Protocol) servers and their available tools
- Use listMcpServers to see all configured MCP servers
- Use listMcpTools to see what tools are available in a specific MCP server
- This helps users understand what external integrations they can use in their workflows
MCP INTEGRATION:
- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs)
- MCP configuration is stored in config/mcp.json
- When users ask about available integrations or tools, check MCP servers
- Help users understand which MCP tools they can add to their agents
DELETION RULES:
- When a user asks to delete a WORKFLOW, you MUST:
1. First read/analyze the workflow to identify which agents it uses
2. List those agents to the user
3. Ask the user if they want to delete those agents as well
4. Wait for their response before proceeding with any deletions
5. Only delete what the user confirms
- When a user asks to delete an AGENT, you MUST:
1. First read/analyze the agent to identify which workflows it is used in
2. List those workflows to the user
3. Ask the user if they want to delete/modify those workflows as well
4. Wait for their response before proceeding with any deletions
5. Only delete/modify what the user confirms
COMMUNICATION STYLE:
- Break down complex tasks into clear steps
- Explore existing files/structure before creating new ones
- Explain your reasoning as you work through tasks
- Be proactive in understanding context
- Confirm what you've done and suggest next steps
- Always ask for confirmation before destructive operations!!
Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`,
tools: {
exploreDirectory: tool({
description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'),
maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'),
}),
execute: async ({ subdirectory, maxDepth = 3 }) => {
async function explore(dir: string, depth: number = 0): Promise<any> {
if (depth > maxDepth) return null;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: any = { files: [], directories: {} };
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name);
const size = (await fs.stat(fullPath)).size;
result.files.push({
name: entry.name,
type: ext || 'no-extension',
size: size,
relativePath: path.relative(BASE_DIR, fullPath),
});
} else if (entry.isDirectory()) {
result.directories[entry.name] = await explore(fullPath, depth + 1);
}
}
return result;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const structure = await explore(dirPath);
return {
success: true,
basePath: path.relative(BASE_DIR, dirPath) || '.',
structure,
};
},
}),
readFile: tool({
description: 'Read and parse file contents. For JSON files, provides parsed structure.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'),
}),
execute: async ({ filename }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const content = await fs.readFile(filePath, 'utf-8');
let parsed = null;
let fileType = path.extname(filename);
if (fileType === '.json') {
try {
parsed = JSON.parse(content);
} catch {
parsed = { error: 'Invalid JSON' };
}
}
return {
success: true,
filename,
fileType,
content,
parsed,
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
createFile: tool({
description: 'Create a new file with content. Automatically creates parent directories if needed.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'),
content: z.string().describe('The content to write to the file'),
description: z.string().optional().describe('Optional description of why this file is being created'),
}),
execute: async ({ filename, content, description }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const dir = path.dirname(filePath);
// Ensure directory exists
await fs.mkdir(dir, { recursive: true });
// Write file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' created successfully`,
description: description || 'No description provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
updateFile: tool({
description: 'Update or overwrite the contents of an existing file',
inputSchema: z.object({
filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'),
content: z.string().describe('The new content to write to the file'),
reason: z.string().optional().describe('Optional reason for the update'),
}),
execute: async ({ filename, content, reason }) => {
try {
const filePath = path.join(BASE_DIR, filename);
// Check if file exists
await fs.access(filePath);
// Update file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' updated successfully`,
reason: reason || 'No reason provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
deleteFile: tool({
description: 'Delete a file from the .rowboat directory',
inputSchema: z.object({
filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'),
}),
execute: async ({ filename }) => {
try {
const filePath = path.join(BASE_DIR, filename);
await fs.unlink(filePath);
return {
success: true,
message: `File '${filename}' deleted successfully`,
path: filePath,
};
} catch (error) {
return {
success: false,
message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
listFiles: tool({
description: 'List all files and directories in the .rowboat directory or subdirectory',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'),
}),
execute: async ({ subdirectory }) => {
try {
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => ({
name: entry.name,
type: path.extname(entry.name) || 'no-extension',
relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)),
}));
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
return {
success: true,
path: dirPath,
relativePath: path.relative(BASE_DIR, dirPath) || '.',
files,
directories,
totalFiles: files.length,
totalDirectories: directories.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
analyzeWorkflow: tool({
description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies',
inputSchema: z.object({
workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'),
}),
execute: async ({ workflowName }) => {
try {
const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`;
const filePath = path.join(BASE_DIR, 'workflows', filename);
const content = await fs.readFile(filePath, 'utf-8');
const workflow = JSON.parse(content);
// Extract key information
const analysis = {
name: workflow.name,
description: workflow.description || 'No description',
agentCount: workflow.agents ? workflow.agents.length : 0,
agents: workflow.agents || [],
tools: workflow.tools || {},
structure: workflow,
};
return {
success: true,
filePath: path.relative(BASE_DIR, filePath),
analysis,
};
} catch (error) {
return {
success: false,
message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
listMcpServers: tool({
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
execute: async () => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
success: true,
servers: [],
message: 'No MCP servers configured yet',
};
}
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const servers = Object.keys(config.mcpServers || {}).map(name => {
const server = config.mcpServers[name];
return {
name,
type: 'command' in server ? 'stdio' : 'http',
command: server.command,
url: server.url,
};
});
return {
success: true,
servers,
count: servers.length,
message: `Found ${servers.length} MCP server(s)`,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
listMcpTools: tool({
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
}),
execute: async ({ serverName }) => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration`,
};
}
// Create transport based on config type
let transport;
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
const client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const toolsList = await client.listTools();
// Close connection
client.close();
transport.close();
const tools = toolsList.tools.map((t: any) => ({
name: t.name,
description: t.description || 'No description',
inputSchema: t.inputSchema,
}));
return {
success: true,
serverName,
tools,
count: tools.length,
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
}),
executeCommand: tool({
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'),
}),
execute: async ({ command, cwd }) => {
try {
const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR;
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
command,
workingDir,
};
} catch (error) {
return {
success: false,
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
command,
};
}
},
}),
},
stopWhen: stepCountIs(20),
});
// Initialize renderer with workflow-style output
const renderer = new StreamRenderer({
showHeaders: false,
dimReasoning: true,
jsonIndent: 2,
truncateJsonAt: 500,
});
// Stream and collect response using fullStream
let assistantResponse = "";
const { fullStream } = result;
for await (const event of fullStream) {
switch (event.type) {
case "reasoning-start":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "reasoning-start" }
});
break;
case "reasoning-delta":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "reasoning-delta", delta: event.text }
});
break;
case "reasoning-end":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "reasoning-end" }
});
break;
case "text-start":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "text-start" }
});
break;
case "text-delta":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "text-delta", delta: event.text }
});
assistantResponse += event.text;
break;
case "text-end":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: { type: "text-end" }
});
break;
case "tool-call":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: {
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
input: 'args' in event ? event.args : event.input
}
});
break;
case "tool-result":
// Tool results are not directly rendered in copilot mode
break;
case "finish":
renderer.render({
type: "stream-event",
stepId: "copilot",
event: {
type: "usage",
usage: event.totalUsage
}
});
break;
}
}
console.log();
// Add assistant response to history
messages.push({ role: "assistant", content: assistantResponse });
// Keep only the last 20 messages (10 user + 10 assistant pairs)
if (messages.length > 20) {
messages.splice(0, messages.length - 20);
}
}
rl.close();
}

View file

@ -0,0 +1,164 @@
import { WorkDir as BASE_DIR } from "../config/config.js";
export const CopilotInstructions = `You are an intelligent workflow assistant helping users manage their workflows in ${BASE_DIR}.
WORKFLOW KNOWLEDGE:
- Workflows are JSON files that orchestrate multiple agents
- Agents are JSON files defining AI assistants with specific tools and instructions
- Tools can be built-in functions or MCP (Model Context Protocol) integrations
NOTE: Comments with // in the formats below are for explanation only - do NOT include them in actual JSON files
CORRECT WORKFLOW FORMAT:
{
"name": "workflow_name", // REQUIRED - must match filename
"description": "Description...", // REQUIRED - must be a description of the workflow
"steps": [ // REQUIRED - array of steps
{
"type": "agent", // REQUIRED - always "agent"
"id": "agent_name" // REQUIRED - must match agent filename
},
{
"type": "agent",
"id": "another_agent_name"
}
]
}
CORRECT AGENT FORMAT (with detailed tool structure):
{
"name": "agent_name", // REQUIRED - must match filename
"description": "What agent does", // REQUIRED - must be a description of the agent
"model": "gpt-4.1", // REQUIRED - model to use
"instructions": "Instructions...", // REQUIRED - agent instructions
"tools": { // OPTIONAL - can be empty {} or omitted
"descriptive_tool_name": {
"type": "mcp", // REQUIRED - always "mcp" for MCP tools
"name": "actual_mcp_tool_name", // REQUIRED - exact tool name from MCP server
"description": "What tool does", // REQUIRED - clear description
"mcpServerName": "server_name", // REQUIRED - name from mcp.json config
"inputSchema": { // REQUIRED - full JSON schema
"type": "object",
"properties": {
"param1": {
"type": "string",
"description": "Description of param" // description is optional but helpful
}
},
"required": ["param1"] // OPTIONAL - only include if params are required
}
}
}
}
IMPORTANT NOTES:
- Agent tools need: type, name, description, mcpServerName, and inputSchema (all REQUIRED)
- Tool keys in agents should be descriptive (like "search", "fetch", "analyze") not the exact tool name
- Agents can have empty tools {} if they don't need external tools
- The "required" array in inputSchema is OPTIONAL - only include it if the tool has required parameters
- If all parameters are optional, you can omit the "required" field entirely
- Property descriptions in inputSchema are optional but helpful for clarity
- All other fields marked REQUIRED must always be present
EXAMPLE 1 - Firecrawl Search Tool (with required params):
{
"tools": {
"search": {
"type": "mcp",
"name": "firecrawl_search",
"description": "Search the web",
"mcpServerName": "firecrawl",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"},
"limit": {"type": "number", "description": "Number of results"},
"sources": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {"type": "string", "enum": ["web", "images", "news"]}
},
"required": ["type"]
}
}
},
"required": ["query"]
}
}
}
}
EXAMPLE 2 - ElevenLabs Text-to-Speech (without required array):
{
"tools": {
"text_to_speech": {
"type": "mcp",
"name": "text_to_speech",
"description": "Generate audio from text",
"mcpServerName": "elevenLabs",
"inputSchema": {
"type": "object",
"properties": {
"text": {"type": "string"}
}
}
}
}
}
CRITICAL NAMING AND ORGANIZATION RULES:
- Agent filenames MUST match the "name" field in their JSON (e.g., agent_name.json "name": "agent_name")
- Workflow filenames MUST match the "name" field in their JSON (e.g., workflow_name.json "name": "workflow_name")
- When referencing agents in workflow steps, the "id" field MUST match the agent's name (e.g., {"type": "agent", "id": "agent_name"})
- All three must be identical: filename, JSON "name" field, and workflow step "id" field
- ALL workflows MUST be placed in the "workflows/" folder (e.g., workflows/workflow_name.json)
- ALL agents MUST be placed in the "agents/" folder (e.g., agents/agent_name.json)
- NEVER create workflows or agents outside these designated folders
- Always maintain this naming and organizational consistency when creating or updating files
YOUR CAPABILITIES:
1. Explore the directory structure to understand existing workflows/agents
2. Create new workflows and agents following best practices
3. Update existing files intelligently
4. Read and analyze file contents to maintain consistency
5. Suggest improvements and ask clarifying questions when needed
6. Execute shell commands to perform system operations
- Use executeCommand to run bash/shell commands
- Can list files, check system info, run scripts, etc.
- Commands execute in the .rowboat directory by default
7. List and explore MCP (Model Context Protocol) servers and their available tools
- Use listMcpServers to see all configured MCP servers
- Use listMcpTools to see what tools are available in a specific MCP server
- This helps users understand what external integrations they can use in their workflows
MCP INTEGRATION:
- MCP servers provide external tools that agents can use (e.g., web scraping, database access, APIs)
- MCP configuration is stored in config/mcp.json
- When users ask about available integrations or tools, check MCP servers
- Help users understand which MCP tools they can add to their agents
DELETION RULES:
- When a user asks to delete a WORKFLOW, you MUST:
1. First read/analyze the workflow to identify which agents it uses
2. List those agents to the user
3. Ask the user if they want to delete those agents as well
4. Wait for their response before proceeding with any deletions
5. Only delete what the user confirms
- When a user asks to delete an AGENT, you MUST:
1. First read/analyze the agent to identify which workflows it is used in
2. List those workflows to the user
3. Ask the user if they want to delete/modify those workflows as well
4. Wait for their response before proceeding with any deletions
5. Only delete/modify what the user confirms
COMMUNICATION STYLE:
- Break down complex tasks into clear steps
- Explore existing files/structure before creating new ones
- Explain your reasoning as you work through tasks
- Be proactive in understanding context
- Confirm what you've done and suggest next steps
- Always ask for confirmation before destructive operations!!
Always use relative paths (no ${BASE_DIR} prefix) when calling tools.`;

View file

@ -34,7 +34,7 @@ const baseModelConfig: z.infer<typeof ModelConfigT> = {
},
defaults: {
provider: "openai",
model: "gpt-4.1",
model: "gpt-5",
}
};
@ -55,7 +55,6 @@ function ensureModelConfig() {
function ensureDirs() {
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
ensure(WorkDir);
ensure(path.join(WorkDir, "workflows"));
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
ensureMcpConfig();

View file

@ -1,28 +1,28 @@
import { z } from "zod";
export const BaseAgentTool = z.object({
export const BaseTool = z.object({
name: z.string(),
});
export const BuiltinAgentTool = BaseAgentTool.extend({
export const BuiltinTool = BaseTool.extend({
type: z.literal("builtin"),
});
export const McpAgentTool = BaseAgentTool.extend({
export const McpTool = BaseTool.extend({
type: z.literal("mcp"),
description: z.string(),
inputSchema: z.any(),
mcpServerName: z.string(),
});
export const WorkflowAgentTool = BaseAgentTool.extend({
type: z.literal("workflow"),
export const AgentAsATool = BaseTool.extend({
type: z.literal("agent"),
});
export const AgentTool = z.discriminatedUnion("type", [
BuiltinAgentTool,
McpAgentTool,
WorkflowAgentTool,
export const ToolAttachment = z.discriminatedUnion("type", [
BuiltinTool,
McpTool,
AgentAsATool,
]);
export const Agent = z.object({
@ -31,5 +31,5 @@ export const Agent = z.object({
model: z.string().optional(),
description: z.string(),
instructions: z.string(),
tools: z.record(z.string(), AgentTool).optional(),
tools: z.record(z.string(), ToolAttachment).optional(),
});

View file

@ -1,7 +1,7 @@
import { z } from "zod";
import { LlmStepStreamEvent } from "./llm-step-event.js";
import { Workflow } from "./workflow.js";
import { LlmStepStreamEvent } from "./llm-step-events.js";
import { Message } from "./message.js";
import { Agent } from "./agent.js";
const BaseRunEvent = z.object({
ts: z.iso.datetime().optional(),
@ -10,47 +10,39 @@ const BaseRunEvent = z.object({
export const RunStartEvent = BaseRunEvent.extend({
type: z.literal("start"),
runId: z.string(),
workflowId: z.string(),
workflow: Workflow,
agentId: z.string(),
agent: Agent,
interactive: z.boolean(),
});
export const RunStepStartEvent = BaseRunEvent.extend({
type: z.literal("step-start"),
stepIndex: z.number(),
stepId: z.string(),
stepType: z.enum(["agent", "function"]),
});
export const RunStreamEvent = BaseRunEvent.extend({
type: z.literal("stream-event"),
stepId: z.string(),
event: LlmStepStreamEvent,
});
export const RunMessageEvent = BaseRunEvent.extend({
type: z.literal("message"),
stepId: z.string(),
message: Message,
});
export const RunToolInvocationEvent = BaseRunEvent.extend({
type: z.literal("tool-invocation"),
stepId: z.string(),
toolName: z.string(),
input: z.string(),
});
export const RunToolResultEvent = BaseRunEvent.extend({
type: z.literal("tool-result"),
stepId: z.string(),
toolName: z.string(),
result: z.any(),
});
export const RunStepEndEvent = BaseRunEvent.extend({
type: z.literal("step-end"),
stepIndex: z.number(),
});
export const RunEndEvent = BaseRunEvent.extend({

View file

@ -1,21 +0,0 @@
import { z } from "zod";
const AgentStep = z.object({
type: z.literal("agent"),
id: z.string(),
});
const FunctionStep = z.object({
type: z.literal("function"),
id: z.string(),
});
export const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]);
export const Workflow = z.object({
name: z.string(),
description: z.string(),
steps: z.array(Step),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
});

View file

@ -1,22 +0,0 @@
import { z } from "zod";
import { Step, StepOutputT } from "../lib/step.js";
import { AgentTool } from "../entities/agent.js";
export class GetDate implements Step {
async* execute(): StepOutputT {
yield {
type: "text-start",
};
yield {
type: "text-delta",
delta: 'The current date is ' + new Date().toISOString(),
};
yield {
type: "text-end",
};
}
tools(): Record<string, z.infer<typeof AgentTool>> {
return {};
}
}

View file

@ -1,29 +1,22 @@
import { Message, MessageList } from "../entities/message.js";
import { z } from "zod";
import { Step, StepInputT, StepOutputT } from "./step.js";
import { ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai";
import { Agent, AgentTool } from "../entities/agent.js";
import { ModelConfig, WorkDir } from "../config/config.js";
import { jsonSchema, ModelMessage } from "ai";
import fs from "fs";
import path from "path";
import { loadWorkflow } from "./utils.js";
import { ModelConfig, WorkDir } from "../config/config.js";
import { Agent, ToolAttachment } from "../entities/agent.js";
import { createInterface, Interface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import { AssistantContentPart, AssistantMessage, Message, MessageList, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.js";
import { runIdGenerator } from "./run-id-gen.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
import { z } from "zod";
import { getProvider } from "./models.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { execTool } from "./exec-tool.js";
import { RunEvent } from "../entities/run-events.js";
import { CopilotAgent } from "../assistant/agent.js";
import { BuiltinTools } from "./builtin-tools.js";
const BashTool = tool({
description: "Run a command in the shell",
inputSchema: z.object({
command: z.string(),
}),
});
const AskHumanTool = tool({
description: "Ask the human for input",
inputSchema: z.object({
question: z.string(),
}),
});
function mapAgentTool(t: z.infer<typeof AgentTool>): Tool {
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
switch (t.type) {
case "mcp":
return tool({
@ -31,31 +24,136 @@ function mapAgentTool(t: z.infer<typeof AgentTool>): Tool {
description: t.description,
inputSchema: jsonSchema(t.inputSchema),
});
case "workflow":
const workflow = loadWorkflow(t.name);
if (!workflow) {
throw new Error(`Workflow ${t.name} not found`);
case "agent":
const agent = await loadAgent(t.name);
if (!agent) {
throw new Error(`Agent ${t.name} not found`);
}
return tool({
name: t.name,
description: workflow.description,
description: agent.description,
inputSchema: z.object({
message: z.string().describe("The message to send to the workflow"),
}),
});
case "builtin":
switch (t.name) {
case "bash":
return BashTool;
case "ask-human":
return AskHumanTool;
default:
throw new Error(`Unknown builtin tool: ${t.name}`);
const match = BuiltinTools[t.name];
if (!match) {
throw new Error(`Unknown builtin tool: ${t.name}`);
}
return tool({
description: match.description,
inputSchema: match.inputSchema,
});
}
}
function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
export class RunLogger {
private logFile: string;
private fileHandle: fs.WriteStream;
ensureRunsDir() {
const runsDir = path.join(WorkDir, "runs");
if (!fs.existsSync(runsDir)) {
fs.mkdirSync(runsDir, { recursive: true });
}
}
constructor(runId: string) {
this.ensureRunsDir();
this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
this.fileHandle = fs.createWriteStream(this.logFile, {
flags: "a",
encoding: "utf8",
});
}
log(event: z.infer<typeof RunEvent>) {
if (event.type !== "stream-event") {
this.fileHandle.write(JSON.stringify(event) + "\n");
}
}
close() {
this.fileHandle.close();
}
}
export class LogAndYield {
private logger: RunLogger
constructor(logger: RunLogger) {
this.logger = logger;
}
async *logAndYield(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const ev = {
...event,
ts: new Date().toISOString(),
}
this.logger.log(ev);
yield ev;
}
}
export class StreamStepMessageBuilder {
private parts: z.infer<typeof AssistantContentPart>[] = [];
private textBuffer: string = "";
private reasoningBuffer: string = "";
flushBuffers() {
// skip reasoning
// if (this.reasoningBuffer) {
// this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
// this.reasoningBuffer = "";
// }
if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = "";
}
}
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
switch (event.type) {
case "reasoning-start":
case "reasoning-end":
case "text-start":
case "text-end":
this.flushBuffers();
break;
case "reasoning-delta":
this.reasoningBuffer += event.delta;
break;
case "text-delta":
this.textBuffer += event.delta;
break;
case "tool-call":
this.parts.push({
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
arguments: event.input,
});
break;
}
}
get(): z.infer<typeof AssistantMessage> {
this.flushBuffers();
return {
role: "assistant",
content: this.parts,
};
}
}
export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
const agent = fs.readFileSync(agentPath, "utf8");
return Agent.parse(JSON.parse(agent));
}
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
const result: ModelMessage[] = [];
for (const msg of messages) {
switch (msg.role) {
@ -119,100 +217,275 @@ function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[
return result;
}
export class AgentNode implements Step {
private id: string;
private asTool: boolean;
private agent: z.infer<typeof Agent>;
constructor(id: string, asTool: boolean) {
this.id = id;
this.asTool = asTool;
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
const agent = fs.readFileSync(agentPath, "utf8");
this.agent = Agent.parse(JSON.parse(agent));
}
export async function* streamAgent(opts: {
agent: string;
runId?: string;
input?: string;
interactive?: boolean;
}) {
const messages: z.infer<typeof MessageList> = [];
tools(): Record<string, z.infer<typeof AgentTool>> {
return this.agent.tools ?? {};
}
async* execute(input: StepInputT): StepOutputT {
// console.log("\n\n\t>>>>\t\tinput", JSON.stringify(input));
const tools: ToolSet = {};
// if (!this.background) {
// tools["ask-human"] = AskHumanTool;
// }
for (const [name, tool] of Object.entries(this.agent.tools ?? {})) {
if (this.asTool && name === "ask-human") {
continue;
}
try {
tools[name] = mapAgentTool(tool);
} catch (error) {
console.error(`Error mapping tool ${name}:`, error);
continue;
}
}
// console.log("\n\n\t>>>>\t\ttools", JSON.stringify(tools, null, 2));
const provider = getProvider(this.agent.provider);
const { fullStream } = streamText({
model: provider(this.agent.model || ModelConfig.defaults.model),
messages: convertFromMessages(input),
system: this.agent.instructions,
stopWhen: stepCountIs(1),
tools,
});
for await (const event of fullStream) {
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
switch (event.type) {
case "reasoning-start":
yield {
type: "reasoning-start",
};
break;
case "reasoning-delta":
yield {
type: "reasoning-delta",
delta: event.text,
};
break;
case "reasoning-end":
yield {
type: "reasoning-end",
};
break;
case "text-start":
yield {
type: "text-start",
};
break;
case "text-delta":
yield {
type: "text-delta",
delta: event.text,
};
break;
case "tool-call":
yield {
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
};
break;
case "finish":
yield {
type: "usage",
usage: event.totalUsage,
};
break;
default:
// console.warn("Unknown event type", event);
// load existing and assemble state if required
if (opts.runId) {
console.error("loading run", opts.runId);
let stream: fs.ReadStream | null = null;
let rl: Interface | null = null;
try {
const logFile = path.join(WorkDir, "runs", `${opts.runId}.jsonl`);
stream = fs.createReadStream(logFile, { encoding: "utf8" });
rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
const parsed = JSON.parse(line);
const event = RunEvent.parse(parsed);
switch (event.type) {
case "message":
messages.push(event.message);
break;
}
}
} finally {
stream?.close();
}
}
}
// create runId if not present
if (!opts.runId) {
opts.runId = runIdGenerator.next();
}
// load agent data
let agent: z.infer<typeof Agent> | null = null;
if (opts.agent === "copilot") {
agent = CopilotAgent;
} else {
agent = await loadAgent(opts.agent);
}
if (!agent) {
throw new Error("unable to load agent");
}
// set up tools
const tools: ToolSet = {};
for (const [name, tool] of Object.entries(agent.tools ?? {})) {
try {
tools[name] = await mapAgentTool(tool);
} catch (error) {
console.error(`Error mapping tool ${name}:`, error);
continue;
}
}
// set up
const logger = new RunLogger(opts.runId);
const ly = new LogAndYield(logger);
const provider = getProvider(agent.provider);
const model = provider(agent.model || ModelConfig.defaults.model);
// get first input if needed
let rl: Interface | null = null;
if (opts.interactive) {
rl = createInterface({ input, output });
}
if (opts.input) {
const m: z.infer<typeof UserMessage> = {
role: "user",
content: opts.input,
};
messages.push(m);
yield *ly.logAndYield({
type: "message",
message: m,
});
}
try {
// loop b/w user and agent
while (true) {
// get input in interactive mode when last message is not user
if (opts.interactive && (messages.length === 0 || messages[messages.length - 1].role !== "user")) {
const input = await rl!.question("You: ");
// Exit condition
if (["q", "quit", "exit"].includes(input.toLowerCase())) {
console.log("\n👋 Goodbye!");
return;
}
const m: z.infer<typeof UserMessage> = {
role: "user",
content: input,
};
messages.push(m);
yield* ly.logAndYield({
type: "message",
message: m,
});
}
// inner loop to handle tool calls
while (true) {
// stream agent response and build message
const messageBuilder = new StreamStepMessageBuilder();
for await (const event of streamLlm(
model,
messages,
agent.instructions,
tools,
)) {
messageBuilder.ingest(event);
yield* ly.logAndYield({
type: "stream-event",
event: event,
});
}
// build and emit final message from agent response
const msg = messageBuilder.get();
messages.push(msg);
yield* ly.logAndYield({
type: "message",
message: msg,
});
// handle tool calls
const mappedToolCalls: z.infer<typeof MappedToolCall>[] = [];
let msgToolCallParts: z.infer<typeof ToolCallPart>[] = [];
if (msg.content instanceof Array) {
msgToolCallParts = msg.content.filter(part => part.type === "tool-call");
}
const hasToolCalls = msgToolCallParts.length > 0;
console.log(msgToolCallParts);
// validate and map tool calls
for (const part of msgToolCallParts) {
const agentTool = tools[part.toolName];
if (!agentTool) {
throw new Error(`Tool ${part.toolName} not found`);
}
mappedToolCalls.push({
toolCall: part,
agentTool: agent.tools![part.toolName],
});
}
for (const call of mappedToolCalls) {
const { agentTool, toolCall } = call;
yield* ly.logAndYield({
type: "tool-invocation",
toolName: toolCall.toolName,
input: JSON.stringify(toolCall.arguments),
});
const result = await execTool(agentTool, toolCall.arguments);
const resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",
content: JSON.stringify(result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
messages.push(resultMsg);
yield* ly.logAndYield({
type: "tool-result",
toolName: toolCall.toolName,
result: result,
});
yield* ly.logAndYield({
type: "message",
message: resultMsg,
});
}
// if the agent response had tool calls, replay this agent
if (hasToolCalls) {
continue;
}
// otherwise, break
break;
}
// if not interactive, return
if (!opts.interactive) {
break;
}
}
} finally {
rl?.close();
logger.close();
}
}
async function* streamLlm(
model: LanguageModel,
messages: z.infer<typeof MessageList>,
instructions: string,
tools: ToolSet,
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
const { fullStream } = streamText({
model,
messages: convertFromMessages(messages),
system: instructions,
tools,
stopWhen: stepCountIs(1),
providerOptions: {
openai: {
reasoningEffort: "low",
reasoningSummary: "auto",
},
}
});
for await (const event of fullStream) {
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
switch (event.type) {
case "reasoning-start":
yield {
type: "reasoning-start",
};
break;
case "reasoning-delta":
yield {
type: "reasoning-delta",
delta: event.text,
};
break;
case "reasoning-end":
yield {
type: "reasoning-end",
};
break;
case "text-start":
yield {
type: "text-start",
};
break;
case "text-delta":
yield {
type: "text-delta",
delta: event.text,
};
break;
case "tool-call":
yield {
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
};
break;
case "finish":
yield {
type: "usage",
usage: event.totalUsage,
};
break;
default:
// console.warn("Unknown event type", event);
continue;
}
}
}
export const MappedToolCall = z.object({
toolCall: ToolCallPart,
agentTool: ToolAttachment,
});

View file

@ -0,0 +1,424 @@
import { z, ZodType } from "zod";
import * as fs from "fs/promises";
import * as path from "path";
import { WorkDir as BASE_DIR } from "../config/config.js";
import { executeCommand } from "./command-executor.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Client } from "@modelcontextprotocol/sdk/client";
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
inputSchema: z.custom<ZodType>(),
execute: z.function({
input: z.any(),
output: z.promise(z.any()),
}),
}));
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
exploreDirectory: {
description: 'Recursively explore directory structure to understand existing workflows, agents, and file organization',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Subdirectory to explore (optional, defaults to root)'),
maxDepth: z.number().optional().describe('Maximum depth to traverse (default: 3)'),
}),
execute: async ({ subdirectory, maxDepth = 3 }: { subdirectory?: string, maxDepth?: number }) => {
async function explore(dir: string, depth: number = 0): Promise<any> {
if (depth > maxDepth) return null;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
const result: any = { files: [], directories: {} };
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile()) {
const ext = path.extname(entry.name);
const size = (await fs.stat(fullPath)).size;
result.files.push({
name: entry.name,
type: ext || 'no-extension',
size: size,
relativePath: path.relative(BASE_DIR, fullPath),
});
} else if (entry.isDirectory()) {
result.directories[entry.name] = await explore(fullPath, depth + 1);
}
}
return result;
} catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' };
}
}
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const structure = await explore(dirPath);
return {
success: true,
basePath: path.relative(BASE_DIR, dirPath) || '.',
structure,
};
},
},
readFile: {
description: 'Read and parse file contents. For JSON files, provides parsed structure.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to read (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const content = await fs.readFile(filePath, 'utf-8');
let parsed = null;
let fileType = path.extname(filename);
if (fileType === '.json') {
try {
parsed = JSON.parse(content);
} catch {
parsed = { error: 'Invalid JSON' };
}
}
return {
success: true,
filename,
fileType,
content,
parsed,
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to read file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
createFile: {
description: 'Create a new file with content. Automatically creates parent directories if needed.',
inputSchema: z.object({
filename: z.string().describe('The name of the file to create (relative to .rowboat directory)'),
content: z.string().describe('The content to write to the file'),
description: z.string().optional().describe('Optional description of why this file is being created'),
}),
execute: async ({ filename, content, description }: { filename: string, content: string, description?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
const dir = path.dirname(filePath);
// Ensure directory exists
await fs.mkdir(dir, { recursive: true });
// Write file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' created successfully`,
description: description || 'No description provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to create file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
updateFile: {
description: 'Update or overwrite the contents of an existing file',
inputSchema: z.object({
filename: z.string().describe('The name of the file to update (relative to .rowboat directory)'),
content: z.string().describe('The new content to write to the file'),
reason: z.string().optional().describe('Optional reason for the update'),
}),
execute: async ({ filename, content, reason }: { filename: string, content: string, reason?: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
// Check if file exists
await fs.access(filePath);
// Update file
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
message: `File '${filename}' updated successfully`,
reason: reason || 'No reason provided',
path: filePath,
size: content.length,
};
} catch (error) {
return {
success: false,
message: `Failed to update file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
deleteFile: {
description: 'Delete a file from the .rowboat directory',
inputSchema: z.object({
filename: z.string().describe('The name of the file to delete (relative to .rowboat directory)'),
}),
execute: async ({ filename }: { filename: string }) => {
try {
const filePath = path.join(BASE_DIR, filename);
await fs.unlink(filePath);
return {
success: true,
message: `File '${filename}' deleted successfully`,
path: filePath,
};
} catch (error) {
return {
success: false,
message: `Failed to delete file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listFiles: {
description: 'List all files and directories in the .rowboat directory or subdirectory',
inputSchema: z.object({
subdirectory: z.string().optional().describe('Optional subdirectory to list (relative to .rowboat directory)'),
}),
execute: async ({ subdirectory }: { subdirectory?: string }) => {
try {
const dirPath = subdirectory ? path.join(BASE_DIR, subdirectory) : BASE_DIR;
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const files = entries
.filter(entry => entry.isFile())
.map(entry => ({
name: entry.name,
type: path.extname(entry.name) || 'no-extension',
relativePath: path.relative(BASE_DIR, path.join(dirPath, entry.name)),
}));
const directories = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
return {
success: true,
path: dirPath,
relativePath: path.relative(BASE_DIR, dirPath) || '.',
files,
directories,
totalFiles: files.length,
totalDirectories: directories.length,
};
} catch (error) {
return {
success: false,
message: `Failed to list files: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
analyzeWorkflow: {
description: 'Read and analyze a workflow file to understand its structure, agents, and dependencies',
inputSchema: z.object({
workflowName: z.string().describe('Name of the workflow file to analyze (with or without .json extension)'),
}),
execute: async ({ workflowName }: { workflowName: string }) => {
try {
const filename = workflowName.endsWith('.json') ? workflowName : `${workflowName}.json`;
const filePath = path.join(BASE_DIR, 'workflows', filename);
const content = await fs.readFile(filePath, 'utf-8');
const workflow = JSON.parse(content);
// Extract key information
const analysis = {
name: workflow.name,
description: workflow.description || 'No description',
agentCount: workflow.agents ? workflow.agents.length : 0,
agents: workflow.agents || [],
tools: workflow.tools || {},
structure: workflow,
};
return {
success: true,
filePath: path.relative(BASE_DIR, filePath),
analysis,
};
} catch (error) {
return {
success: false,
message: `Failed to analyze workflow: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listMcpServers: {
description: 'List all available MCP servers from the configuration',
inputSchema: z.object({}),
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
// Check if config exists
try {
await fs.access(configPath);
} catch {
return {
success: true,
servers: [],
count: 0,
message: 'No MCP servers configured yet',
};
}
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const servers = Object.keys(config.mcpServers || {}).map(name => {
const server = config.mcpServers[name];
return {
name,
type: 'command' in server ? 'stdio' : 'http',
command: server.command,
url: server.url,
};
});
return {
success: true,
servers,
count: servers.length,
message: `Found ${servers.length} MCP server(s)`,
};
} catch (error) {
return {
success: false,
servers: [],
count: 0,
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
listMcpTools: {
description: 'List all available tools from a specific MCP server',
inputSchema: z.object({
serverName: z.string().describe('Name of the MCP server to query'),
}),
execute: async ({ serverName }: { serverName: string }) => {
try {
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
const content = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(content);
const mcpConfig = config.mcpServers[serverName];
if (!mcpConfig) {
return {
success: false,
message: `MCP server '${serverName}' not found in configuration`,
};
}
// Create transport based on config type
let transport;
if ('command' in mcpConfig) {
transport = new StdioClientTransport({
command: mcpConfig.command,
args: mcpConfig.args || [],
env: mcpConfig.env || {},
});
} else {
try {
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
} catch {
transport = new SSEClientTransport(new URL(mcpConfig.url));
}
}
// Create and connect client
const client = new Client({
name: 'rowboat-copilot',
version: '1.0.0',
});
await client.connect(transport);
// List available tools
const toolsList = await client.listTools();
// Close connection
client.close();
transport.close();
const tools = toolsList.tools.map((t: any) => ({
name: t.name,
description: t.description || 'No description',
inputSchema: t.inputSchema,
}));
return {
success: true,
serverName,
tools,
count: tools.length,
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
};
} catch (error) {
return {
success: false,
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
},
},
executeCommand: {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to .rowboat directory)'),
}),
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
try {
const workingDir = cwd ? path.join(BASE_DIR, cwd) : BASE_DIR;
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
command,
workingDir,
};
} catch (error) {
return {
success: false,
message: `Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`,
command,
};
}
},
},
};

View file

@ -1,20 +1,16 @@
import { tool, Tool } from "ai";
import { AgentTool } from "../entities/agent.js";
import { ToolAttachment } from "../entities/agent.js";
import { z } from "zod";
import { McpServers } from "../config/config.js";
import { getMcpClient } from "./mcp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { Client } from "@modelcontextprotocol/sdk/client";
import { executeCommand } from "./command-executor.js";
import { loadWorkflow } from "./utils.js";
import { AssistantMessage } from "../entities/message.js";
import { executeWorkflow } from "./exec-workflow.js";
import readline from "readline";
import { BuiltinTools } from "./builtin-tools.js";
import { streamAgent } from "./agent.js";
async function execMcpTool(agentTool: z.infer<typeof AgentTool> & { type: "mcp" }, input: any): Promise<any> {
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: any): Promise<any> {
// load mcp configuration from the tool
const mcpConfig = McpServers[agentTool.mcpServerName];
if (!mcpConfig) {
@ -57,34 +53,12 @@ async function execMcpTool(agentTool: z.infer<typeof AgentTool> & { type: "mcp"
return result;
}
async function execBashTool(agentTool: z.infer<typeof AgentTool>, input: any): Promise<any> {
const result = await executeCommand(input.command as string);
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
};
}
export async function execAskHumanTool(agentTool: z.infer<typeof AgentTool>, question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
let p = new Promise<string>((resolve, reject) => {
rl.question(`>> Provide answer to: ${question}:\n\n`, (answer) => {
resolve(answer);
rl.close();
});
});
const answer = await p;
return answer;
}
async function execWorkflowTool(agentTool: z.infer<typeof AgentTool> & { type: "workflow" }, input: any): Promise<any> {
async function execAgentTool(agentTool: z.infer<typeof ToolAttachment> & { type: "agent" }, input: any): Promise<any> {
let lastMsg: z.infer<typeof AssistantMessage> | null = null;
for await (const event of executeWorkflow(agentTool.name, input.message)) {
for await (const event of streamAgent({
agent: agentTool.name,
input: JSON.stringify(input),
})) {
if (event.type === "message" && event.message.role === "assistant") {
lastMsg = event.message;
}
@ -94,7 +68,7 @@ async function execWorkflowTool(agentTool: z.infer<typeof AgentTool> & { type: "
}
if (!lastMsg) {
throw new Error("No message received from workflow");
throw new Error("No message received from agent");
}
if (typeof lastMsg.content === "string") {
return lastMsg.content;
@ -107,18 +81,17 @@ async function execWorkflowTool(agentTool: z.infer<typeof AgentTool> & { type: "
}, "");
}
export async function execTool(agentTool: z.infer<typeof AgentTool>, input: any): Promise<any> {
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: any): Promise<any> {
switch (agentTool.type) {
case "mcp":
return execMcpTool(agentTool, input);
case "workflow":
return execWorkflowTool(agentTool, input);
case "agent":
return execAgentTool(agentTool, input);
case "builtin":
switch (agentTool.name) {
case "bash":
return execBashTool(agentTool, input);
default:
throw new Error(`Unknown builtin tool: ${agentTool.name}`);
const builtinTool = BuiltinTools[agentTool.name];
if (!builtinTool || !builtinTool.execute) {
throw new Error(`Unsupported builtin tool: ${agentTool.name}`);
}
return builtinTool.execute(input);
}
}

View file

@ -1,449 +0,0 @@
import { loadWorkflow } from "./utils.js";
import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage, ToolCallPart } from "../entities/message.js";
import { LlmStepStreamEvent } from "../entities/llm-step-event.js";
import { AgentNode } from "./agent.js";
import { z } from "zod";
import path from "path";
import { WorkDir } from "../config/config.js";
import fs from "fs";
import { createInterface, Interface } from "node:readline/promises";
import { FunctionsRegistry } from "../registry/functions.js";
import { RunEvent } from "../entities/workflow-event.js";
import { execAskHumanTool, execTool } from "./exec-tool.js";
import { AgentTool } from "../entities/agent.js";
import { runIdGenerator } from "./run-id-gen.js";
import { Workflow } from "../entities/workflow.js";
const MappedToolCall = z.object({
toolCall: ToolCallPart,
agentTool: AgentTool,
});
const State = z.object({
stepIndex: z.number(),
messages: MessageList,
workflow: Workflow.nullable(),
pendingToolCallId: z.string().nullable(),
});
class StateBuilder {
private state: z.infer<typeof State> = {
stepIndex: 0,
messages: [],
workflow: null,
pendingToolCallId: null,
};
ingest(event: z.infer<typeof RunEvent>) {
switch (event.type) {
case "start":
this.state.workflow = event.workflow;
break;
case "step-start":
this.state.stepIndex = event.stepIndex;
break;
case "message":
this.state.messages.push(event.message);
this.state.pendingToolCallId = null;
break;
case "pause-for-human-input":
this.state.pendingToolCallId = event.toolCallId;
break;
}
}
get(): z.infer<typeof State> {
return this.state;
}
}
class RunLogger {
private logFile: string;
private fileHandle: fs.WriteStream;
ensureRunsDir(workflowId: string) {
const runsDir = path.join(WorkDir, "runs", workflowId);
if (!fs.existsSync(runsDir)) {
fs.mkdirSync(runsDir, { recursive: true });
}
}
constructor(workflowId: string, runId: string) {
this.ensureRunsDir(workflowId);
this.logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
this.fileHandle = fs.createWriteStream(this.logFile, {
flags: "a",
encoding: "utf8",
});
}
log(event: z.infer<typeof RunEvent>) {
this.fileHandle.write(JSON.stringify(event) + "\n");
}
close() {
this.fileHandle.close();
}
}
class LogAndYield {
private logger: RunLogger
constructor(logger: RunLogger) {
this.logger = logger;
}
async *logAndYield(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const ev = {
...event,
ts: new Date().toISOString(),
}
this.logger.log(ev);
yield ev;
}
}
class StreamStepMessageBuilder {
private parts: z.infer<typeof AssistantContentPart>[] = [];
private textBuffer: string = "";
private reasoningBuffer: string = "";
flushBuffers() {
if (this.reasoningBuffer) {
this.parts.push({ type: "reasoning", text: this.reasoningBuffer });
this.reasoningBuffer = "";
}
if (this.textBuffer) {
this.parts.push({ type: "text", text: this.textBuffer });
this.textBuffer = "";
}
}
ingest(event: z.infer<typeof LlmStepStreamEvent>) {
switch (event.type) {
case "reasoning-start":
case "reasoning-end":
case "text-start":
case "text-end":
this.flushBuffers();
break;
case "reasoning-delta":
this.reasoningBuffer += event.delta;
break;
case "text-delta":
this.textBuffer += event.delta;
break;
case "tool-call":
this.parts.push({
type: "tool-call",
toolCallId: event.toolCallId,
toolName: event.toolName,
arguments: event.input,
});
break;
}
}
get(): z.infer<typeof AssistantMessage> {
this.flushBuffers();
return {
role: "assistant",
content: this.parts,
};
}
}
function loadFunction(id: string) {
const func = FunctionsRegistry[id];
if (!func) {
throw new Error(`Function ${id} not found`);
}
return func;
}
export async function* executeWorkflow(id: string, input: string, interactive: boolean = true, asTool: boolean = false): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const runId = runIdGenerator.next();
yield* runFromState({
id,
runId,
state: {
stepIndex: 0,
messages: [{
role: "user",
content: input,
}],
workflow: null,
pendingToolCallId: null,
},
interactive,
asTool,
});
}
export async function* resumeWorkflow(runId: string, input: string, interactive: boolean = false): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
// read a run.jsonl file line by line and build state
const builder = new StateBuilder();
let rl: Interface | null = null;
let stream: fs.ReadStream | null = null;
try {
const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
stream = fs.createReadStream(logFile, { encoding: "utf8" });
rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
// console.error('processing line', line);
const parsed = JSON.parse(line);
// console.error('parsed');
const event = RunEvent.parse(parsed);
// console.error('zod parsed');
builder.ingest(event);
}
} catch (error) {
// console.error("Failed to resume workflow:", error);
// yield {
// type: "error",
// error: error instanceof Error ? error.message : String(error),
// };
} finally {
rl?.close();
stream?.close();
}
const { workflow, messages, stepIndex, pendingToolCallId } = builder.get();
if (!workflow) {
throw new Error(`Workflow not found for run ${runId}`);
}
if (!pendingToolCallId) {
throw new Error(`No pending tool call found for run ${runId}`);
}
const stepId = workflow.steps[stepIndex].id;
// append user input as message
const logger = new RunLogger(workflow.name, runId);
const ly = new LogAndYield(logger);
yield *ly.logAndYield({
type: "resume"
});
// append user input as message
const resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",
content: JSON.stringify(input),
toolCallId: pendingToolCallId,
toolName: "ask-human",
};
messages.push(resultMsg);
yield* ly.logAndYield({
type: "tool-result",
stepId,
toolName: "ask-human",
result: input,
});
yield* ly.logAndYield({
type: "message",
stepId,
message: resultMsg,
});
yield* runFromState({
id: workflow.name,
runId,
state: {
stepIndex,
messages,
workflow,
pendingToolCallId,
},
interactive,
asTool: false,
});
}
async function* runFromState(opts: {
id: string;
runId: string;
state: z.infer<typeof State>;
interactive: boolean;
asTool: boolean;
}) {
const { id, runId, state, interactive, asTool } = opts;
let stepIndex = state.stepIndex;
let messages = [...state.messages];
let workflow = state.workflow;
const logger = new RunLogger(id, runId);
const ly = new LogAndYield(logger);
try {
if (!workflow) {
workflow = loadWorkflow(id);
yield* ly.logAndYield({
type: "start",
runId,
workflowId: id,
workflow,
interactive,
});
}
while (true) {
const step = workflow.steps[stepIndex];
const node = step.type === "agent" ? new AgentNode(step.id, asTool) : loadFunction(step.id);
yield* ly.logAndYield({
type: "step-start",
stepIndex,
stepId: step.id,
stepType: step.type,
});
const messageBuilder = new StreamStepMessageBuilder();
// stream response from agent
for await (const event of node.execute(messages)) {
// console.log(" - event", JSON.stringify(event));
messageBuilder.ingest(event);
yield* ly.logAndYield({
type: "stream-event",
stepId: step.id,
event: event,
});
}
// build and emit final message from agent response
const msg = messageBuilder.get();
messages.push(msg);
yield* ly.logAndYield({
type: "message",
stepId: step.id,
message: msg,
});
// handle tool calls
const tools = node.tools();
const mappedToolCalls: z.infer<typeof MappedToolCall>[] = [];
let msgToolCallParts: z.infer<typeof ToolCallPart>[] = [];
if (msg.content instanceof Array) {
msgToolCallParts = msg.content.filter(part => part.type === "tool-call");
}
const hasToolCalls = msgToolCallParts.length > 0;
// validate and map tool calls
for (const part of msgToolCallParts) {
const agentTool = tools[part.toolName];
if (!agentTool) {
throw new Error(`Tool ${part.toolName} not found`);
}
mappedToolCalls.push({
toolCall: part,
agentTool: agentTool,
});
}
// first, exec all tool calls other than ask-human
for (const call of mappedToolCalls) {
const { agentTool, toolCall } = call;
if (agentTool.type === "builtin" && agentTool.name === "ask-human") {
continue;
}
yield* ly.logAndYield({
type: "tool-invocation",
stepId: step.id,
toolName: toolCall.toolName,
input: JSON.stringify(toolCall.arguments),
});
const result = await execTool(agentTool, toolCall.arguments);
const resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",
content: JSON.stringify(result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
messages.push(resultMsg);
yield* ly.logAndYield({
type: "tool-result",
stepId: step.id,
toolName: toolCall.toolName,
result: result,
});
yield* ly.logAndYield({
type: "message",
stepId: step.id,
message: resultMsg,
});
}
// handle ask-tool call execution
for (const call of mappedToolCalls) {
const { agentTool, toolCall } = call;
if (agentTool.type !== "builtin" || agentTool.name !== "ask-human") {
continue;
}
yield* ly.logAndYield({
type: "tool-invocation",
stepId: step.id,
toolName: toolCall.toolName,
input: JSON.stringify(toolCall.arguments),
});
// if running in background mode, exit here
if (!interactive) {
yield* ly.logAndYield({
type: "pause-for-human-input",
toolCallId: toolCall.toolCallId,
});
return;
}
const result = await execAskHumanTool(agentTool, toolCall.arguments.question as string);
const resultMsg: z.infer<typeof ToolMessage> = {
role: "tool",
content: JSON.stringify(result),
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
};
messages.push(resultMsg);
yield* ly.logAndYield({
type: "tool-result",
stepId: step.id,
toolName: toolCall.toolName,
result: result,
});
yield* ly.logAndYield({
type: "message",
stepId: step.id,
message: resultMsg,
});
}
yield* ly.logAndYield({
type: "step-end",
stepIndex,
});
// if the agent response had tool calls, replay this agent
if (hasToolCalls) {
continue;
}
// otherwise, move to the next step
stepIndex++;
if (stepIndex >= workflow.steps.length) {
yield* ly.logAndYield({
type: "end",
});
break;
}
}
// console.log('\n\n', JSON.stringify(messages, null, 2));
} catch (error) {
yield* ly.logAndYield({
type: "error",
error: error instanceof Error ? error.message : String(error),
});
} finally {
logger.close();
}
}

View file

@ -1,7 +1,7 @@
import { MessageList } from "../entities/message.js";
import { LlmStepStreamEvent } from "../entities/llm-step-event.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
import { z } from "zod";
import { AgentTool } from "../entities/agent.js";
import { ToolAttachment } from "../entities/agent.js";
export type StepInputT = z.infer<typeof MessageList>;
export type StepOutputT = AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown>;
@ -9,5 +9,5 @@ export type StepOutputT = AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, voi
export interface Step {
execute(input: StepInputT): StepOutputT;
tools(): Record<string, z.infer<typeof AgentTool>>;
tools(): Record<string, z.infer<typeof ToolAttachment>>;
}

View file

@ -1,6 +1,6 @@
import { z } from "zod";
import { RunEvent } from "../entities/workflow-event.js";
import { LlmStepStreamEvent } from "../entities/llm-step-event.js";
import { RunEvent } from "../entities/run-events.js";
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
export interface StreamRendererOptions {
showHeaders?: boolean;
@ -27,11 +27,11 @@ export class StreamRenderer {
render(event: z.infer<typeof RunEvent>) {
switch (event.type) {
case "start": {
this.onWorkflowStart(event.workflowId, event.runId, event.interactive);
this.onStart(event.agentId, event.runId, event.interactive);
break;
}
case "step-start": {
this.onStepStart(event.stepIndex, event.stepId, event.stepType);
this.onStepStart();
break;
}
case "stream-event": {
@ -43,23 +43,23 @@ export class StreamRenderer {
break;
}
case "tool-invocation": {
this.onStepToolInvocation(event.stepId, event.toolName, event.input);
this.onStepToolInvocation(event.toolName, event.input);
break;
}
case "tool-result": {
this.onStepToolResult(event.stepId, event.toolName, event.result);
this.onStepToolResult(event.toolName, event.result);
break;
}
case "step-end": {
this.onStepEnd(event.stepIndex);
this.onStepEnd();
break;
}
case "end": {
this.onWorkflowEnd();
this.onEnd();
break;
}
case "error": {
this.onWorkflowError(event.error);
this.onError(event.error);
break;
}
}
@ -94,29 +94,29 @@ export class StreamRenderer {
}
}
private onWorkflowStart(workflowId: string, runId: string, interactive: boolean) {
private onStart(workflowId: string, runId: string, interactive: boolean) {
this.write("\n");
this.write(this.bold(`▶ Workflow ${workflowId} (run ${runId})`));
if (!interactive) this.write(this.dim(" (--no-interactive)"));
this.write("\n");
}
private onWorkflowEnd() {
private onEnd() {
this.write(this.bold("\n■ Workflow complete\n"));
}
private onWorkflowError(error: string) {
private onError(error: string) {
this.write(this.red(`\n✖ Workflow error: ${error}\n`));
}
private onStepStart(stepIndex: number, stepId: string, stepType: "agent" | "function") {
private onStepStart() {
this.write("\n");
this.write(this.cyan(`─ Step ${stepIndex} [${stepType}]`));
this.write(this.cyan(`─ Step started`));
this.write("\n");
}
private onStepEnd(stepIndex: number) {
this.write(this.dim(`✓ Step ${stepIndex} finished\n`));
private onStepEnd() {
this.write(this.dim(`✓ Step finished\n`));
}
private onStepMessage(stepIndex: number, message: any) {
@ -131,7 +131,7 @@ export class StreamRenderer {
}
}
private onStepToolInvocation(stepId: string, toolName: string, input: string) {
private onStepToolInvocation(toolName: string, input: string) {
this.write(this.cyan(`\n→ Tool invoke ${toolName}`));
if (input && input.length) {
this.write("\n" + this.dim(this.indent(this.truncate(input))) + "\n");
@ -140,7 +140,7 @@ export class StreamRenderer {
}
}
private onStepToolResult(stepId: string, toolName: string, result: unknown) {
private onStepToolResult(toolName: string, result: unknown) {
const res = this.truncate(JSON.stringify(result, null, this.options.jsonIndent));
this.write(this.cyan(`\n← Tool result ${toolName}\n`));
this.write(this.dim(this.indent(res)) + "\n");

View file

@ -1,10 +0,0 @@
import fs from "fs";
import path from "path";
import { WorkDir } from "../config/config.js";
import { Workflow } from "../entities/workflow.js";
export function loadWorkflow(id: string) {
const workflowPath = path.join(WorkDir, "workflows", `${id}.json`);
const workflow = fs.readFileSync(workflowPath, "utf8");
return Workflow.parse(JSON.parse(workflow));
}

View file

@ -1,6 +0,0 @@
import { GetDate } from "../functions/get_date.js";
import { Step } from "../lib/step.js";
export const FunctionsRegistry: Record<string, Step> = {
get_date: new GetDate(),
} as const;

View file

@ -1,8 +0,0 @@
import { startCopilot } from "./application/assistant/chat.js";
export const start = () => {
startCopilot().catch((err) => {
console.error("Failed to run copilot:", err);
process.exitCode = 1;
});
}