mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 00:46:23 +02:00
everything is an agent
This commit is contained in:
parent
2d6a647c70
commit
80dae17fd1
24 changed files with 1261 additions and 1573 deletions
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
20
apps/cli/src/application/assistant/agent.ts
Normal file
20
apps/cli/src/application/assistant/agent.ts
Normal 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,
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
164
apps/cli/src/application/assistant/instructions.ts
Normal file
164
apps/cli/src/application/assistant/instructions.ts
Normal 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.`;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
@ -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 {};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
424
apps/cli/src/application/lib/builtin-tools.ts
Normal file
424
apps/cli/src/application/lib/builtin-tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue