mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
first commit
This commit is contained in:
parent
476654af80
commit
6014437479
20 changed files with 2231 additions and 0 deletions
2
apps/cli/.gitignore
vendored
Normal file
2
apps/cli/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
1465
apps/cli/package-lock.json
generated
Normal file
1465
apps/cli/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
apps/cli/package.json
Normal file
27
apps/cli/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "cli",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "rm -rf dist && tsc"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"ai": "^5.0.78",
|
||||
"nanoid": "^5.1.6",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
}
|
||||
185
apps/cli/src/app.ts
Normal file
185
apps/cli/src/app.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir, McpServers } from "./application/config/config.js";
|
||||
import { Workflow } from "./application/entities/workflow.js";
|
||||
import { FunctionsRegistry } from "./application/registry/functions.js";
|
||||
import { AgentNode } from "./application/nodes/agent.js";
|
||||
import { MessageList, AssistantContentPart } from "./application/entities/message.js";
|
||||
import { z } from "zod";
|
||||
import { getMcpClient } from "./application/lib/mcp.js";
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { google } from "@ai-sdk/google";
|
||||
import { StreamRenderer } from "./application/lib/stream-renderer.js";
|
||||
import { StreamEvent } from "./application/entities/stream-event.js";
|
||||
import { AssistantMessage, Message } from "./application/entities/message.js";
|
||||
import { randomId } from "./application/lib/random-id.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 StreamEvent>) {
|
||||
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 loadWorkflow(id: string) {
|
||||
const workflowPath = path.join(WorkDir, "workflows", `${id}.json`);
|
||||
const workflow = fs.readFileSync(workflowPath, "utf8");
|
||||
return Workflow.parse(JSON.parse(workflow));
|
||||
}
|
||||
|
||||
function loadFunction(id: string) {
|
||||
const func = FunctionsRegistry[id];
|
||||
if (!func) {
|
||||
throw new Error(`Function ${id} not found`);
|
||||
}
|
||||
return func;
|
||||
}
|
||||
|
||||
async function callMcpTool(serverName: string, toolName: string, args: Record<string, unknown>) {
|
||||
const server = McpServers.find(server => server.name === serverName);
|
||||
if (!server) {
|
||||
throw new Error(`MCP server ${serverName} not found`);
|
||||
}
|
||||
const client = await getMcpClient(server.url, server.name);
|
||||
const response = await client.callTool({ name: toolName, arguments: args });
|
||||
return response;
|
||||
}
|
||||
|
||||
async function executeWorkflow(id: string) {
|
||||
const workflow = loadWorkflow(id);
|
||||
// console.log("got", JSON.stringify(workflow));
|
||||
|
||||
const runId = await randomId();
|
||||
const logger = new RunLogger(id, runId);
|
||||
|
||||
const input: z.infer<typeof MessageList> = [{
|
||||
role: "user",
|
||||
content: "What is the current date?"
|
||||
}];
|
||||
const msgs: z.infer<typeof MessageList> = [...input];
|
||||
|
||||
try {
|
||||
const renderer = new StreamRenderer();
|
||||
|
||||
for await (const step of workflow.steps) {
|
||||
const node = step.type === "agent" ? new AgentNode(step.id) : loadFunction(step.id);
|
||||
const messageBuilder = new StreamStepMessageBuilder();
|
||||
for await (const event of node.execute(msgs)) {
|
||||
// console.log(" - event", JSON.stringify(event));
|
||||
messageBuilder.ingest(event);
|
||||
renderer.render(event);
|
||||
}
|
||||
const msg = messageBuilder.get();
|
||||
logger.log(msg);
|
||||
msgs.push(msg);
|
||||
}
|
||||
} finally {
|
||||
logger.close();
|
||||
}
|
||||
|
||||
console.log('\n\n', JSON.stringify(msgs, null, 2));
|
||||
}
|
||||
|
||||
async function streamEventTest() {
|
||||
const { fullStream } = streamText({
|
||||
model: openai("gpt-5"),
|
||||
system: "You are a helpful assistant that reasons about the world. Provide a reason for invoking any tools",
|
||||
messages: [{ role: "user", content: "what is the current date and time?" }],
|
||||
tools: {
|
||||
getDate: {
|
||||
description: "Get the current date",
|
||||
inputSchema: z.object({
|
||||
format: z.enum(["long", "short"]).default("long"),
|
||||
}),
|
||||
},
|
||||
getTime: {
|
||||
description: "Get the current time",
|
||||
inputSchema: z.object({
|
||||
format: z.enum(["long", "short"]).default("long"),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const renderer = new StreamRenderer();
|
||||
for await (const event of fullStream) {
|
||||
renderer.render(event as any);
|
||||
}
|
||||
}
|
||||
|
||||
// streamEventTest();
|
||||
|
||||
executeWorkflow("example_workflow");
|
||||
16
apps/cli/src/application/config/config.ts
Normal file
16
apps/cli/src/application/config/config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { McpServerConfig } from "../entities/mcp.js";
|
||||
import { z } from "zod";
|
||||
|
||||
export const WorkDir = "/Users/ramnique/work/rb/rowboat/apps/cli/.rowboat"
|
||||
|
||||
|
||||
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
|
||||
const configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
const config = fs.readFileSync(configPath, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
const { mcpServers } = loadMcpServerConfig();
|
||||
export const McpServers = mcpServers;
|
||||
7
apps/cli/src/application/entities/agent.ts
Normal file
7
apps/cli/src/application/entities/agent.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from "zod";
|
||||
export const Agent = z.object({
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
description: z.string(),
|
||||
instructions: z.string(),
|
||||
});
|
||||
8
apps/cli/src/application/entities/mcp.ts
Normal file
8
apps/cli/src/application/entities/mcp.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import z from "zod";
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.array(z.object({
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
})),
|
||||
});
|
||||
58
apps/cli/src/application/entities/message.ts
Normal file
58
apps/cli/src/application/entities/message.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const TextPart = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const ReasoningPart = z.object({
|
||||
type: z.literal("reasoning"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallPart = z.object({
|
||||
type: z.literal("tool-call"),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
arguments: z.string(),
|
||||
});
|
||||
|
||||
export const AssistantContentPart = z.union([
|
||||
TextPart,
|
||||
ReasoningPart,
|
||||
ToolCallPart,
|
||||
]);
|
||||
|
||||
export const UserMessage = z.object({
|
||||
role: z.literal("user"),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const AssistantMessage = z.object({
|
||||
role: z.literal("assistant"),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(AssistantContentPart),
|
||||
]),
|
||||
});
|
||||
|
||||
export const SystemMessage = z.object({
|
||||
role: z.literal("system"),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolMessage = z.object({
|
||||
role: z.literal("tool"),
|
||||
content: z.string(),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
});
|
||||
|
||||
export const Message = z.discriminatedUnion("role", [
|
||||
AssistantMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
UserMessage,
|
||||
]);
|
||||
|
||||
export const MessageList = z.array(Message);
|
||||
56
apps/cli/src/application/entities/stream-event.ts
Normal file
56
apps/cli/src/application/entities/stream-event.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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,
|
||||
]);
|
||||
21
apps/cli/src/application/entities/workflow.ts
Normal file
21
apps/cli/src/application/entities/workflow.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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(),
|
||||
});
|
||||
|
||||
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(),
|
||||
});
|
||||
16
apps/cli/src/application/functions/get_date.ts
Normal file
16
apps/cli/src/application/functions/get_date.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Node, NodeOutputT } from "../nodes/node.js";
|
||||
|
||||
export class GetDate implements Node {
|
||||
async* execute(): NodeOutputT {
|
||||
yield {
|
||||
type: "text-start",
|
||||
};
|
||||
yield {
|
||||
type: "text-delta",
|
||||
delta: 'The current date is ' + new Date().toISOString(),
|
||||
};
|
||||
yield {
|
||||
type: "text-end",
|
||||
};
|
||||
}
|
||||
}
|
||||
31
apps/cli/src/application/lib/mcp.ts
Normal file
31
apps/cli/src/application/lib/mcp.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
|
||||
export async function getMcpClient(serverUrl: string, serverName: string): Promise<Client> {
|
||||
let client: Client | undefined = undefined;
|
||||
const baseUrl = new URL(serverUrl);
|
||||
|
||||
// Try to connect using Streamable HTTP transport
|
||||
try {
|
||||
client = new Client({
|
||||
name: 'streamable-http-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
console.log(`[MCP] Connected using Streamable HTTP transport to ${serverName}`);
|
||||
return client;
|
||||
} catch (error) {
|
||||
// If that fails with a 4xx error, try the older SSE transport
|
||||
console.log(`[MCP] Streamable HTTP connection failed, falling back to SSE transport for ${serverName}`);
|
||||
client = new Client({
|
||||
name: 'sse-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const sseTransport = new SSEClientTransport(baseUrl);
|
||||
await client.connect(sseTransport);
|
||||
console.log(`[MCP] Connected using SSE transport to ${serverName}`);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
7
apps/cli/src/application/lib/random-id.ts
Normal file
7
apps/cli/src/application/lib/random-id.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { customAlphabet } from 'nanoid';
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz-';
|
||||
const nanoid = customAlphabet(alphabet, 7);
|
||||
|
||||
export async function randomId(): Promise<string> {
|
||||
return nanoid();
|
||||
}
|
||||
151
apps/cli/src/application/lib/stream-renderer.ts
Normal file
151
apps/cli/src/application/lib/stream-renderer.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { z } from "zod";
|
||||
import { StreamEvent } from "../entities/stream-event.js";
|
||||
|
||||
export interface StreamRendererOptions {
|
||||
showHeaders?: boolean;
|
||||
dimReasoning?: boolean;
|
||||
jsonIndent?: number;
|
||||
truncateJsonAt?: number;
|
||||
}
|
||||
|
||||
export class StreamRenderer {
|
||||
private options: Required<StreamRendererOptions>;
|
||||
private reasoningActive = false;
|
||||
private textActive = false;
|
||||
|
||||
constructor(options?: StreamRendererOptions) {
|
||||
this.options = {
|
||||
showHeaders: true,
|
||||
dimReasoning: true,
|
||||
jsonIndent: 2,
|
||||
truncateJsonAt: 500,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
render(event: z.infer<typeof StreamEvent>) {
|
||||
switch (event.type) {
|
||||
case "reasoning-start":
|
||||
this.onReasoningStart();
|
||||
break;
|
||||
case "reasoning-delta":
|
||||
this.onReasoningDelta(event.delta);
|
||||
break;
|
||||
case "reasoning-end":
|
||||
this.onReasoningEnd();
|
||||
break;
|
||||
case "text-start":
|
||||
this.onTextStart();
|
||||
break;
|
||||
case "text-delta":
|
||||
this.onTextDelta(event.delta);
|
||||
break;
|
||||
case "text-end":
|
||||
this.onTextEnd();
|
||||
break;
|
||||
case "tool-call":
|
||||
this.onToolCall(event.toolCallId, event.toolName, event.input);
|
||||
break;
|
||||
case "usage":
|
||||
this.onUsage(event.usage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onReasoningStart() {
|
||||
if (this.reasoningActive) return;
|
||||
this.reasoningActive = true;
|
||||
if (this.options.showHeaders) {
|
||||
this.write("\n");
|
||||
this.write(this.dim("Reasoning: "));
|
||||
}
|
||||
}
|
||||
|
||||
private onReasoningDelta(delta: string) {
|
||||
if (!this.reasoningActive) this.onReasoningStart();
|
||||
this.write(this.options.dimReasoning ? this.dim(delta) : delta);
|
||||
}
|
||||
|
||||
private onReasoningEnd() {
|
||||
if (!this.reasoningActive) return;
|
||||
this.reasoningActive = false;
|
||||
this.write(this.dim("\n"));
|
||||
}
|
||||
|
||||
private onTextStart() {
|
||||
if (this.textActive) return;
|
||||
this.textActive = true;
|
||||
if (this.options.showHeaders) {
|
||||
this.write("\n");
|
||||
this.write(this.bold("Assistant: "));
|
||||
}
|
||||
}
|
||||
|
||||
private onTextDelta(delta: string) {
|
||||
if (!this.textActive) this.onTextStart();
|
||||
this.write(delta);
|
||||
}
|
||||
|
||||
private onTextEnd() {
|
||||
if (!this.textActive) return;
|
||||
this.textActive = false;
|
||||
this.write("\n");
|
||||
}
|
||||
|
||||
private onToolCall(toolCallId: string, toolName: string, input: unknown) {
|
||||
const inputStr = this.truncate(JSON.stringify(input, null, this.options.jsonIndent));
|
||||
this.write("\n");
|
||||
this.write(this.cyan(`→ Tool call ${toolName} (${toolCallId})`));
|
||||
this.write("\n");
|
||||
this.write(this.dim(this.indent(inputStr)));
|
||||
this.write("\n");
|
||||
}
|
||||
|
||||
private onUsage(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
if (usage.inputTokens !== undefined) parts.push(`input=${usage.inputTokens}`);
|
||||
if (usage.outputTokens !== undefined) parts.push(`output=${usage.outputTokens}`);
|
||||
if (usage.reasoningTokens !== undefined) parts.push(`reasoning=${usage.reasoningTokens}`);
|
||||
if (usage.cachedInputTokens !== undefined) parts.push(`cached=${usage.cachedInputTokens}`);
|
||||
if (usage.totalTokens !== undefined) parts.push(`total=${usage.totalTokens}`);
|
||||
const line = parts.join(", ");
|
||||
this.write(this.dim(`\nUsage: ${line}\n`));
|
||||
}
|
||||
|
||||
// Formatting helpers
|
||||
private write(text: string) {
|
||||
process.stdout.write(text);
|
||||
}
|
||||
|
||||
private indent(text: string): string {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => (line.length ? ` ${line}` : line))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
private truncate(text: string): string {
|
||||
if (text.length <= this.options.truncateJsonAt) return text;
|
||||
return text.slice(0, this.options.truncateJsonAt) + "…";
|
||||
}
|
||||
|
||||
private bold(text: string): string {
|
||||
return "\x1b[1m" + text + "\x1b[0m";
|
||||
}
|
||||
|
||||
private dim(text: string): string {
|
||||
return "\x1b[2m" + text + "\x1b[0m";
|
||||
}
|
||||
|
||||
private cyan(text: string): string {
|
||||
return "\x1b[36m" + text + "\x1b[0m";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
130
apps/cli/src/application/nodes/agent.ts
Normal file
130
apps/cli/src/application/nodes/agent.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { Message } from "../entities/message.js";
|
||||
import { z } from "zod";
|
||||
import { Node, NodeInputT, NodeOutputT } from "./node.js";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
|
||||
import { Agent } from "../entities/agent.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
switch (msg.role) {
|
||||
case "assistant":
|
||||
if (typeof msg.content === 'string') {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content,
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: msg.content.map(part => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return part;
|
||||
case 'reasoning':
|
||||
return part;
|
||||
case 'tool-call':
|
||||
return {
|
||||
type: 'tool-call',
|
||||
toolCallId: part.toolCallId,
|
||||
toolName: part.toolName,
|
||||
input: part.arguments,
|
||||
};
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "system":
|
||||
result.push({
|
||||
role: "system",
|
||||
content: msg.content,
|
||||
});
|
||||
break;
|
||||
case "user":
|
||||
result.push({
|
||||
role: "user",
|
||||
content: msg.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export class AgentNode implements Node {
|
||||
private id: string;
|
||||
|
||||
constructor(id: string) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
private loadAgent(id: string): z.infer<typeof Agent> {
|
||||
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
|
||||
const agent = fs.readFileSync(agentPath, "utf8");
|
||||
return Agent.parse(JSON.parse(agent));
|
||||
}
|
||||
|
||||
async* execute(input: NodeInputT): NodeOutputT {
|
||||
const agent = this.loadAgent(this.id);
|
||||
const { fullStream } = await streamText({
|
||||
model: openai(agent.model),
|
||||
messages: convertFromMessages(input),
|
||||
system: agent.instructions,
|
||||
stopWhen: stepCountIs(1),
|
||||
});
|
||||
|
||||
for await (const event of fullStream) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/cli/src/application/nodes/node.ts
Normal file
10
apps/cli/src/application/nodes/node.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
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;
|
||||
}
|
||||
6
apps/cli/src/application/registry/functions.ts
Normal file
6
apps/cli/src/application/registry/functions.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { GetDate } from "../functions/get_date.js";
|
||||
import { Node } from "../nodes/node.js";
|
||||
|
||||
export const FunctionsRegistry: Record<string, Node> = {
|
||||
get_date: new GetDate(),
|
||||
} as const;
|
||||
0
apps/cli/src/application/registry/tools.ts
Normal file
0
apps/cli/src/application/registry/tools.ts
Normal file
15
apps/cli/todo.md
Normal file
15
apps/cli/todo.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
runtime
|
||||
---
|
||||
o stream out responses
|
||||
o terminal logging
|
||||
o file logging
|
||||
- accept initial user input from CLI
|
||||
- mcp tool calls (http + stdio)
|
||||
- human input support
|
||||
- bash tool support
|
||||
- cli wrapper (node commander)
|
||||
|
||||
|
||||
rowboat agent
|
||||
---
|
||||
- create agent
|
||||
20
apps/cli/tsconfig.json
Normal file
20
apps/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"lib": ["esnext"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue