mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-26 08:56:22 +02:00
set up basic workflow execution
This commit is contained in:
parent
7758139893
commit
c004bc5eb6
24 changed files with 794 additions and 298 deletions
|
|
@ -152,7 +152,7 @@ export async function startCopilot(): Promise<void> {
|
|||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
console.log("Rowboat Copilot (type 'exit' to quit)");
|
||||
console.log("XRowboat Copilot (type 'exit' to quit)");
|
||||
|
||||
const debugMode = process.argv.includes("--debug") || process.env.COPILOT_DEBUG === "1";
|
||||
const conversationHistory: ConversationMessage[] = [];
|
||||
|
|
|
|||
|
|
@ -407,13 +407,23 @@ export async function executeCommand(cmd: ChatCommandT): Promise<CommandOutcome>
|
|||
}
|
||||
case "list_mcp_servers": {
|
||||
const config = readMcpConfig();
|
||||
const servers = config.mcpServers;
|
||||
const servers = Object.keys(config.mcpServers);
|
||||
|
||||
const list: string[] = [];
|
||||
for (const server of servers) {
|
||||
if ('url' in config.mcpServers[server]) {
|
||||
list.push(`${server} → ${config.mcpServers[server].url}`);
|
||||
} else {
|
||||
list.push(`${server} → ${config.mcpServers[server].command}`);
|
||||
}
|
||||
}
|
||||
|
||||
return asCommandOutcome({
|
||||
headline:
|
||||
servers.length === 0
|
||||
? "No MCP servers configured."
|
||||
: `Found ${servers.length} MCP server${servers.length === 1 ? "" : "s"}.`,
|
||||
list: servers.map((server) => `${server.name} → ${server.url}`),
|
||||
list,
|
||||
data: servers,
|
||||
});
|
||||
}
|
||||
|
|
@ -427,16 +437,14 @@ export async function executeCommand(cmd: ChatCommandT): Promise<CommandOutcome>
|
|||
});
|
||||
}
|
||||
const config = readMcpConfig();
|
||||
const withoutExisting = config.mcpServers.filter(
|
||||
(server) => server.name !== serverConfig.name
|
||||
);
|
||||
const updated = {
|
||||
mcpServers: [...withoutExisting, { ...serverConfig }],
|
||||
config.mcpServers[serverConfig.name] = {
|
||||
url: serverConfig.url,
|
||||
headers: {},
|
||||
};
|
||||
writeMcpConfig(updated);
|
||||
writeMcpConfig(config);
|
||||
return asCommandOutcome({
|
||||
headline: `MCP server "${serverConfig.name}" saved.`,
|
||||
data: updated.mcpServers,
|
||||
data: config.mcpServers,
|
||||
});
|
||||
}
|
||||
case "remove_mcp_server": {
|
||||
|
|
@ -449,16 +457,14 @@ export async function executeCommand(cmd: ChatCommandT): Promise<CommandOutcome>
|
|||
});
|
||||
}
|
||||
const config = readMcpConfig();
|
||||
const remaining = config.mcpServers.filter(
|
||||
(server) => server.name !== name
|
||||
);
|
||||
const removed = remaining.length !== config.mcpServers.length;
|
||||
writeMcpConfig({ mcpServers: remaining });
|
||||
delete config.mcpServers[name];
|
||||
writeMcpConfig(config);
|
||||
const removed = name in config.mcpServers;
|
||||
return asCommandOutcome({
|
||||
headline: removed
|
||||
? `MCP server "${name}" removed.`
|
||||
: `MCP server "${name}" was not registered.`,
|
||||
data: remaining,
|
||||
data: config.mcpServers,
|
||||
});
|
||||
}
|
||||
case "run_workflow": {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function mcpConfigPath(): string {
|
|||
|
||||
export function readMcpConfig(): z.infer<typeof McpServerConfig> {
|
||||
const p = mcpConfigPath();
|
||||
if (!fs.existsSync(p)) return { mcpServers: [] };
|
||||
if (!fs.existsSync(p)) return { mcpServers: {} };
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(raw));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,19 +7,45 @@ import { homedir } from "os";
|
|||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
|
||||
const baseMcpConfig = {
|
||||
mcpServers: {
|
||||
firecrawl: {
|
||||
command: "npx",
|
||||
args: ["-y", "supergateway", "--stdio", "npx -y firecrawl-mcp"],
|
||||
env: {
|
||||
FIRECRAWL_API_KEY: "fc-aaacee4bdd164100a4d83af85bef6fdc",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
url: "http://localhost:3000",
|
||||
headers: {
|
||||
"Authorization": "Bearer test",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function ensureMcpConfig() {
|
||||
const configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(configPath, JSON.stringify(baseMcpConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
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, "mcp"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
ensureMcpConfig();
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
|
||||
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
|
||||
const configPath = path.join(WorkDir, "mcp", "servers.json");
|
||||
if (!fs.existsSync(configPath)) return { mcpServers: [] };
|
||||
const configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
if (!fs.existsSync(configPath)) return { mcpServers: {} };
|
||||
const config = fs.readFileSync(configPath, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,34 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const BaseAgentTool = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const BuiltinAgentTool = BaseAgentTool.extend({
|
||||
type: z.literal("builtin"),
|
||||
});
|
||||
|
||||
export const McpAgentTool = BaseAgentTool.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 AgentTool = z.discriminatedUnion("type", [
|
||||
BuiltinAgentTool,
|
||||
McpAgentTool,
|
||||
WorkflowAgentTool,
|
||||
]);
|
||||
|
||||
export const Agent = z.object({
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
description: z.string(),
|
||||
instructions: z.string(),
|
||||
tools: z.record(z.string(), AgentTool).optional(),
|
||||
});
|
||||
|
|
|
|||
56
apps/cli/src/application/entities/llm-step-event.ts
Normal file
56
apps/cli/src/application/entities/llm-step-event.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const LlmStepStreamReasoningStartEvent = z.object({
|
||||
type: z.literal("reasoning-start"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamReasoningDeltaEvent = z.object({
|
||||
type: z.literal("reasoning-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamReasoningEndEvent = z.object({
|
||||
type: z.literal("reasoning-end"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextStartEvent = z.object({
|
||||
type: z.literal("text-start"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextDeltaEvent = z.object({
|
||||
type: z.literal("text-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamTextEndEvent = z.object({
|
||||
type: z.literal("text-end"),
|
||||
});
|
||||
|
||||
export const LlmStepStreamToolCallEvent = z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.any(),
|
||||
});
|
||||
|
||||
export const LlmStepStreamUsageEvent = z.object({
|
||||
type: z.literal("usage"),
|
||||
usage: z.object({
|
||||
inputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalTokens: z.number().optional(),
|
||||
reasoningTokens: z.number().optional(),
|
||||
cachedInputTokens: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const LlmStepStreamEvent = z.union([
|
||||
LlmStepStreamReasoningStartEvent,
|
||||
LlmStepStreamReasoningDeltaEvent,
|
||||
LlmStepStreamReasoningEndEvent,
|
||||
LlmStepStreamTextStartEvent,
|
||||
LlmStepStreamTextDeltaEvent,
|
||||
LlmStepStreamTextEndEvent,
|
||||
LlmStepStreamToolCallEvent,
|
||||
LlmStepStreamUsageEvent,
|
||||
]);
|
||||
|
|
@ -1,8 +1,16 @@
|
|||
import z from "zod";
|
||||
|
||||
const StdioMcpServerConfig = z.object({
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
const HttpMcpServerConfig = z.object({
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.array(z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
})),
|
||||
mcpServers: z.record(z.string(), z.union([StdioMcpServerConfig, HttpMcpServerConfig])),
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ export const ToolCallPart = z.object({
|
|||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
arguments: z.string(),
|
||||
arguments: z.any(),
|
||||
});
|
||||
|
||||
export const AssistantContentPart = z.union([
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const ReasoningStartEvent = z.object({
|
||||
type: z.literal("reasoning-start"),
|
||||
});
|
||||
|
||||
export const ReasoningDeltaEvent = z.object({
|
||||
type: z.literal("reasoning-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const ReasoningEndEvent = z.object({
|
||||
type: z.literal("reasoning-end"),
|
||||
});
|
||||
|
||||
export const TextStartEvent = z.object({
|
||||
type: z.literal("text-start"),
|
||||
});
|
||||
|
||||
export const TextDeltaEvent = z.object({
|
||||
type: z.literal("text-delta"),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
export const TextEndEvent = z.object({
|
||||
type: z.literal("text-end"),
|
||||
});
|
||||
|
||||
export const ToolCallEvent = z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.any(),
|
||||
});
|
||||
|
||||
export const UsageEvent = z.object({
|
||||
type: z.literal("usage"),
|
||||
usage: z.object({
|
||||
inputTokens: z.number().optional(),
|
||||
outputTokens: z.number().optional(),
|
||||
totalTokens: z.number().optional(),
|
||||
reasoningTokens: z.number().optional(),
|
||||
cachedInputTokens: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const StreamEvent = z.union([
|
||||
ReasoningStartEvent,
|
||||
ReasoningDeltaEvent,
|
||||
ReasoningEndEvent,
|
||||
TextStartEvent,
|
||||
TextDeltaEvent,
|
||||
TextEndEvent,
|
||||
ToolCallEvent,
|
||||
UsageEvent,
|
||||
]);
|
||||
69
apps/cli/src/application/entities/workflow-event.ts
Normal file
69
apps/cli/src/application/entities/workflow-event.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "./llm-step-event.js";
|
||||
import { Workflow } from "./workflow.js";
|
||||
import { Message } from "./message.js";
|
||||
|
||||
export const WorkflowStreamStartEvent = z.object({
|
||||
type: z.literal("workflow-start"),
|
||||
workflowId: z.string(),
|
||||
workflow: Workflow,
|
||||
background: z.boolean(),
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepStartEvent = z.object({
|
||||
type: z.literal("workflow-step-start"),
|
||||
stepId: z.string(),
|
||||
stepType: z.enum(["agent", "function"]),
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepStreamEventEvent = z.object({
|
||||
type: z.literal("workflow-step-stream-event"),
|
||||
stepId: z.string(),
|
||||
event: LlmStepStreamEvent,
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepMessageEvent = z.object({
|
||||
type: z.literal("workflow-step-message"),
|
||||
stepId: z.string(),
|
||||
message: Message,
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepToolInvocationEvent = z.object({
|
||||
type: z.literal("workflow-step-tool-invocation"),
|
||||
stepId: z.string(),
|
||||
toolName: z.string(),
|
||||
input: z.string(),
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepToolResultEvent = z.object({
|
||||
type: z.literal("workflow-step-tool-result"),
|
||||
stepId: z.string(),
|
||||
toolName: z.string(),
|
||||
result: z.any(),
|
||||
});
|
||||
|
||||
export const WorkflowStreamStepEndEvent = z.object({
|
||||
type: z.literal("workflow-step-end"),
|
||||
stepId: z.string(),
|
||||
});
|
||||
|
||||
export const WorkflowStreamEndEvent = z.object({
|
||||
type: z.literal("workflow-end"),
|
||||
});
|
||||
|
||||
export const WorkflowStreamErrorEvent = z.object({
|
||||
type: z.literal("workflow-error"),
|
||||
error: z.string(),
|
||||
});
|
||||
|
||||
export const WorkflowStreamEvent = z.union([
|
||||
WorkflowStreamStartEvent,
|
||||
WorkflowStreamStepStartEvent,
|
||||
WorkflowStreamStepStreamEventEvent,
|
||||
WorkflowStreamStepMessageEvent,
|
||||
WorkflowStreamStepToolInvocationEvent,
|
||||
WorkflowStreamStepToolResultEvent,
|
||||
WorkflowStreamStepEndEvent,
|
||||
WorkflowStreamEndEvent,
|
||||
WorkflowStreamErrorEvent,
|
||||
]);
|
||||
|
|
@ -10,7 +10,7 @@ const FunctionStep = z.object({
|
|||
id: z.string(),
|
||||
});
|
||||
|
||||
const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]);
|
||||
export const Step = z.discriminatedUnion("type", [AgentStep, FunctionStep]);
|
||||
|
||||
export const Workflow = z.object({
|
||||
name: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { Node, NodeOutputT } from "../nodes/node.js";
|
||||
import { z } from "zod";
|
||||
import { Step, StepOutputT } from "../lib/step.js";
|
||||
import { AgentTool } from "../entities/agent.js";
|
||||
|
||||
export class GetDate implements Node {
|
||||
async* execute(): NodeOutputT {
|
||||
export class GetDate implements Step {
|
||||
async* execute(): StepOutputT {
|
||||
yield {
|
||||
type: "text-start",
|
||||
};
|
||||
|
|
@ -13,4 +15,8 @@ export class GetDate implements Node {
|
|||
type: "text-end",
|
||||
};
|
||||
}
|
||||
|
||||
tools(): Record<string, z.infer<typeof AgentTool>> {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,58 @@
|
|||
import { Message } from "../entities/message.js";
|
||||
import { Message, MessageList } from "../entities/message.js";
|
||||
import { z } from "zod";
|
||||
import { Node, NodeInputT, NodeOutputT } from "./node.js";
|
||||
import { Step, StepInputT, StepOutputT } from "./step.js";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
|
||||
import { Agent } from "../entities/agent.js";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText, tool, Tool, ToolSet, jsonSchema } from "ai";
|
||||
import { Agent, AgentTool } from "../entities/agent.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { loadWorkflow } from "./utils.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 {
|
||||
switch (t.type) {
|
||||
case "mcp":
|
||||
return tool({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: jsonSchema(t.inputSchema),
|
||||
});
|
||||
case "workflow":
|
||||
const workflow = loadWorkflow(t.name);
|
||||
if (!workflow) {
|
||||
throw new Error(`Workflow ${t.name} not found`);
|
||||
}
|
||||
return tool({
|
||||
name: t.name,
|
||||
description: workflow.description,
|
||||
inputSchema: z.object({
|
||||
message: z.string().describe("The message to send to the workflow"),
|
||||
}),
|
||||
});
|
||||
case "builtin":
|
||||
switch (t.name) {
|
||||
case "bash":
|
||||
return BashTool;
|
||||
default:
|
||||
throw new Error(`Unknown builtin tool: ${t.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
|
|
@ -51,34 +97,72 @@ function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[
|
|||
content: msg.content,
|
||||
});
|
||||
break;
|
||||
case "tool":
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
output: {
|
||||
type: "text",
|
||||
value: msg.content,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class AgentNode implements Node {
|
||||
export class AgentNode implements Step {
|
||||
private id: string;
|
||||
private background: boolean;
|
||||
private agent: z.infer<typeof Agent>;
|
||||
|
||||
constructor(id: string) {
|
||||
constructor(id: string, background: boolean) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
private loadAgent(id: string): z.infer<typeof Agent> {
|
||||
this.background = background;
|
||||
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
|
||||
const agent = fs.readFileSync(agentPath, "utf8");
|
||||
return Agent.parse(JSON.parse(agent));
|
||||
this.agent = Agent.parse(JSON.parse(agent));
|
||||
}
|
||||
|
||||
tools(): Record<string, z.infer<typeof AgentTool>> {
|
||||
return this.agent.tools ?? {};
|
||||
}
|
||||
|
||||
async* execute(input: NodeInputT): NodeOutputT {
|
||||
const agent = this.loadAgent(this.id);
|
||||
const { fullStream } = await streamText({
|
||||
model: openai(agent.model),
|
||||
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 ?? {})) {
|
||||
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 { fullStream } = streamText({
|
||||
model: openai("gpt-4.1"),
|
||||
// model: google("gemini-2.5-pro"),
|
||||
messages: convertFromMessages(input),
|
||||
system: agent.instructions,
|
||||
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 {
|
||||
102
apps/cli/src/application/lib/exec-tool.ts
Normal file
102
apps/cli/src/application/lib/exec-tool.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { tool, Tool } from "ai";
|
||||
import { AgentTool } 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";
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof AgentTool> & { type: "mcp" }, input: any): Promise<any> {
|
||||
// load mcp configuration from the tool
|
||||
const mcpConfig = McpServers[agentTool.mcpServerName];
|
||||
if (!mcpConfig) {
|
||||
throw new Error(`MCP server ${agentTool.mcpServerName} not found`);
|
||||
}
|
||||
|
||||
// create transport
|
||||
let transport: Transport;
|
||||
if ("command" in mcpConfig) {
|
||||
transport = new StdioClientTransport({
|
||||
command: mcpConfig.command,
|
||||
args: mcpConfig.args,
|
||||
env: mcpConfig.env,
|
||||
});
|
||||
} else {
|
||||
// first try streamable http transport
|
||||
try {
|
||||
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
|
||||
} catch (error) {
|
||||
// if that fails, try sse transport
|
||||
transport = new SSEClientTransport(new URL(mcpConfig.url));
|
||||
}
|
||||
}
|
||||
|
||||
if (!transport) {
|
||||
throw new Error(`No transport found for ${agentTool.mcpServerName}`);
|
||||
}
|
||||
|
||||
// create client
|
||||
const client = new Client({
|
||||
name: 'rowboatx',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
// call tool
|
||||
const result = await client.callTool({ name: agentTool.name, arguments: input });
|
||||
client.close();
|
||||
transport.close();
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function execWorkflowTool(agentTool: z.infer<typeof AgentTool> & { type: "workflow" }, input: any): Promise<any> {
|
||||
let lastMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
for await (const event of executeWorkflow(agentTool.name, input.message)) {
|
||||
if (event.type === "workflow-step-message" && event.message.role === "assistant") {
|
||||
lastMsg = event.message;
|
||||
}
|
||||
if (event.type === "workflow-error") {
|
||||
throw new Error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!lastMsg) {
|
||||
throw new Error("No message received from workflow");
|
||||
}
|
||||
if (typeof lastMsg.content === "string") {
|
||||
return lastMsg.content;
|
||||
}
|
||||
return lastMsg.content.reduce((acc, part) => {
|
||||
if (part.type === "text") {
|
||||
acc += part.text;
|
||||
}
|
||||
return acc;
|
||||
}, "");
|
||||
}
|
||||
|
||||
export async function execTool(agentTool: z.infer<typeof AgentTool>, input: any): Promise<any> {
|
||||
switch (agentTool.type) {
|
||||
case "mcp":
|
||||
return execMcpTool(agentTool, input);
|
||||
case "workflow":
|
||||
return execWorkflowTool(agentTool, input);
|
||||
case "builtin":
|
||||
return execBashTool(agentTool, input);
|
||||
}
|
||||
}
|
||||
215
apps/cli/src/application/lib/exec-workflow.ts
Normal file
215
apps/cli/src/application/lib/exec-workflow.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { loadWorkflow } from "./utils.js";
|
||||
import { randomId } from "./random-id.js";
|
||||
import { MessageList, AssistantMessage, AssistantContentPart, Message, ToolMessage } 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 { FunctionsRegistry } from "../registry/functions.js";
|
||||
import { WorkflowStreamEvent } from "../entities/workflow-event.js";
|
||||
import { execTool } from "./exec-tool.js";
|
||||
|
||||
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", `${workflowId}`, `${runId}.jsonl`);
|
||||
this.fileHandle = fs.createWriteStream(this.logFile, {
|
||||
flags: "a",
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
log(message: z.infer<typeof Message>) {
|
||||
this.fileHandle.write(JSON.stringify(message) + "\n");
|
||||
}
|
||||
|
||||
close() {
|
||||
this.fileHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
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, background: boolean = false): AsyncGenerator<z.infer<typeof WorkflowStreamEvent>, void, unknown> {
|
||||
try {
|
||||
const workflow = loadWorkflow(id);
|
||||
const runId = await randomId();
|
||||
|
||||
yield {
|
||||
type: "workflow-start",
|
||||
workflowId: id,
|
||||
workflow: workflow,
|
||||
background: background,
|
||||
};
|
||||
|
||||
const logger = new RunLogger(id, runId);
|
||||
|
||||
const messages: z.infer<typeof MessageList> = [{
|
||||
role: "user",
|
||||
content: input ?? ""
|
||||
}];
|
||||
|
||||
try {
|
||||
let stepIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const step = workflow.steps[stepIndex];
|
||||
const node = step.type === "agent" ? new AgentNode(step.id, background) : loadFunction(step.id);
|
||||
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 {
|
||||
type: "workflow-step-stream-event",
|
||||
stepId: step.id,
|
||||
event: event,
|
||||
};
|
||||
}
|
||||
|
||||
// build and emit final message from agent response
|
||||
const msg = messageBuilder.get();
|
||||
logger.log(msg);
|
||||
messages.push(msg);
|
||||
yield {
|
||||
type: "workflow-step-message",
|
||||
stepId: step.id,
|
||||
message: msg,
|
||||
};
|
||||
|
||||
// if the agent response contains tool calls, execute them
|
||||
const tools = node.tools();
|
||||
let hasToolCalls = false;
|
||||
if (msg.content instanceof Array) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "tool-call") {
|
||||
hasToolCalls = true;
|
||||
if (!(part.toolName in tools)) {
|
||||
throw new Error(`Tool ${part.toolName} not found`);
|
||||
}
|
||||
yield {
|
||||
type: "workflow-step-tool-invocation",
|
||||
stepId: step.id,
|
||||
toolName: part.toolName,
|
||||
input: part.arguments,
|
||||
}
|
||||
const result = await execTool(tools[part.toolName], part.arguments);
|
||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||
role: "tool",
|
||||
content: JSON.stringify(result),
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
};
|
||||
logger.log(resultMsg);
|
||||
messages.push(resultMsg);
|
||||
yield {
|
||||
type: "workflow-step-tool-result",
|
||||
stepId: step.id,
|
||||
toolName: part.toolName,
|
||||
result: result,
|
||||
};
|
||||
yield {
|
||||
type: "workflow-step-message",
|
||||
stepId: step.id,
|
||||
message: resultMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
logger.close();
|
||||
}
|
||||
|
||||
// console.log('\n\n', JSON.stringify(messages, null, 2));
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: "workflow-error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
yield {
|
||||
type: "workflow-end",
|
||||
};
|
||||
}
|
||||
}
|
||||
13
apps/cli/src/application/lib/step.ts
Normal file
13
apps/cli/src/application/lib/step.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { MessageList } from "../entities/message.js";
|
||||
import { LlmStepStreamEvent } from "../entities/llm-step-event.js";
|
||||
import { z } from "zod";
|
||||
import { AgentTool } from "../entities/agent.js";
|
||||
|
||||
export type StepInputT = z.infer<typeof MessageList>;
|
||||
export type StepOutputT = AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown>;
|
||||
|
||||
export interface Step {
|
||||
execute(input: StepInputT): StepOutputT;
|
||||
|
||||
tools(): Record<string, z.infer<typeof AgentTool>>;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { StreamEvent } from "../entities/stream-event.js";
|
||||
import { WorkflowStreamEvent } from "../entities/workflow-event.js";
|
||||
import { LlmStepStreamEvent } from "../entities/llm-step-event.js";
|
||||
|
||||
export interface StreamRendererOptions {
|
||||
showHeaders?: boolean;
|
||||
|
|
@ -23,7 +24,48 @@ export class StreamRenderer {
|
|||
};
|
||||
}
|
||||
|
||||
render(event: z.infer<typeof StreamEvent>) {
|
||||
render(event: z.infer<typeof WorkflowStreamEvent>) {
|
||||
switch (event.type) {
|
||||
case "workflow-start": {
|
||||
this.onWorkflowStart(event.workflowId, event.background);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-start": {
|
||||
this.onStepStart(event.stepId, event.stepType);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-stream-event": {
|
||||
this.renderLlmEvent(event.event);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-message": {
|
||||
// this.onStepMessage(event.stepId, event.message);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-tool-invocation": {
|
||||
this.onStepToolInvocation(event.stepId, event.toolName, event.input);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-tool-result": {
|
||||
this.onStepToolResult(event.stepId, event.toolName, event.result);
|
||||
break;
|
||||
}
|
||||
case "workflow-step-end": {
|
||||
this.onStepEnd(event.stepId);
|
||||
break;
|
||||
}
|
||||
case "workflow-end": {
|
||||
this.onWorkflowEnd();
|
||||
break;
|
||||
}
|
||||
case "workflow-error": {
|
||||
this.onWorkflowError(event.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderLlmEvent(event: z.infer<typeof LlmStepStreamEvent>) {
|
||||
switch (event.type) {
|
||||
case "reasoning-start":
|
||||
this.onReasoningStart();
|
||||
|
|
@ -52,6 +94,58 @@ export class StreamRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
private onWorkflowStart(workflowId: string, background: boolean) {
|
||||
this.write("\n");
|
||||
this.write(this.bold(`▶ Workflow ${workflowId}`));
|
||||
if (background) this.write(this.dim(" (background)"));
|
||||
this.write("\n");
|
||||
}
|
||||
|
||||
private onWorkflowEnd() {
|
||||
this.write(this.bold("\n■ Workflow complete\n"));
|
||||
}
|
||||
|
||||
private onWorkflowError(error: string) {
|
||||
this.write(this.red(`\n✖ Workflow error: ${error}\n`));
|
||||
}
|
||||
|
||||
private onStepStart(stepId: string, stepType: "agent" | "function") {
|
||||
this.write("\n");
|
||||
this.write(this.cyan(`─ Step ${stepId} [${stepType}]`));
|
||||
this.write("\n");
|
||||
}
|
||||
|
||||
private onStepEnd(stepId: string) {
|
||||
this.write(this.dim(`✓ Step ${stepId} finished\n`));
|
||||
}
|
||||
|
||||
private onStepMessage(stepId: string, message: any) {
|
||||
const role = message?.role ?? "message";
|
||||
const content = message?.content;
|
||||
this.write(this.bold(`${role}: `));
|
||||
if (typeof content === "string") {
|
||||
this.write(content + "\n");
|
||||
} else {
|
||||
const pretty = this.truncate(JSON.stringify(message, null, this.options.jsonIndent));
|
||||
this.write(this.dim("\n" + this.indent(pretty) + "\n"));
|
||||
}
|
||||
}
|
||||
|
||||
private onStepToolInvocation(stepId: string, 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");
|
||||
} else {
|
||||
this.write("\n");
|
||||
}
|
||||
}
|
||||
|
||||
private onStepToolResult(stepId: string, 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");
|
||||
}
|
||||
|
||||
private onReasoningStart() {
|
||||
if (this.reasoningActive) return;
|
||||
this.reasoningActive = true;
|
||||
|
|
@ -146,6 +240,10 @@ export class StreamRenderer {
|
|||
private cyan(text: string): string {
|
||||
return "\x1b[36m" + text + "\x1b[0m";
|
||||
}
|
||||
|
||||
private red(text: string): string {
|
||||
return "\x1b[31m" + text + "\x1b[0m";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
10
apps/cli/src/application/lib/utils.ts
Normal file
10
apps/cli/src/application/lib/utils.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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,10 +0,0 @@
|
|||
import { MessageList } from "../entities/message.js";
|
||||
import { StreamEvent } from "../entities/stream-event.js";
|
||||
import { z } from "zod";
|
||||
|
||||
export type NodeInputT = z.infer<typeof MessageList>;
|
||||
export type NodeOutputT = AsyncGenerator<z.infer<typeof StreamEvent>, void, unknown>;
|
||||
|
||||
export interface Node {
|
||||
execute(input: NodeInputT): NodeOutputT;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { GetDate } from "../functions/get_date.js";
|
||||
import { Node } from "../nodes/node.js";
|
||||
import { Step } from "../lib/step.js";
|
||||
|
||||
export const FunctionsRegistry: Record<string, Node> = {
|
||||
export const FunctionsRegistry: Record<string, Step> = {
|
||||
get_date: new GetDate(),
|
||||
} as const;
|
||||
Loading…
Add table
Add a link
Reference in a new issue