mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
server for rowboatx
This commit is contained in:
parent
ae877e70ae
commit
9ad6331fbc
38 changed files with 2223 additions and 1088 deletions
3
apps/cli/.gitignore
vendored
3
apps/cli/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist/
|
||||
.vercel
|
||||
|
|
|
|||
|
|
@ -116,20 +116,4 @@ yargs(hideBin(process.argv))
|
|||
modelConfig();
|
||||
}
|
||||
)
|
||||
.command(
|
||||
"update-state <agent> <run_id>",
|
||||
"Update state for a run",
|
||||
(y) => y
|
||||
.positional("agent", {
|
||||
type: "string",
|
||||
description: "The agent to run",
|
||||
})
|
||||
.positional("run_id", {
|
||||
type: "string",
|
||||
description: "The run id to update",
|
||||
}),
|
||||
(argv) => {
|
||||
updateState(argv.agent, argv.run_id);
|
||||
}
|
||||
)
|
||||
.parse();
|
||||
|
|
|
|||
1073
apps/cli/package-lock.json
generated
1073
apps/cli/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,7 +6,7 @@
|
|||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"copilot": "npm run build && node dist/x.js"
|
||||
"server": "node dist/server.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.9.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -30,9 +29,14 @@
|
|||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@ai-sdk/openai-compatible": "^1.0.27",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/standard-validator": "^0.1.5",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.6",
|
||||
"ai": "^5.0.102",
|
||||
"awilix": "^12.0.5",
|
||||
"hono": "^4.10.7",
|
||||
"hono-openapi": "^1.1.1",
|
||||
"json-schema-to-zod": "^2.6.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
|
|
|
|||
45
apps/cli/src/agents/repo.ts
Normal file
45
apps/cli/src/agents/repo.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { Agent } from "./agents.js";
|
||||
|
||||
export interface IAgentsRepo {
|
||||
list(): Promise<z.infer<typeof Agent>[]>;
|
||||
fetch(id: string): Promise<z.infer<typeof Agent>>;
|
||||
create(agent: z.infer<typeof Agent>): Promise<void>;
|
||||
update(id: string, agent: z.infer<typeof Agent>): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSAgentsRepo implements IAgentsRepo {
|
||||
async list(): Promise<z.infer<typeof Agent>[]> {
|
||||
const result: z.infer<typeof Agent>[] = [];
|
||||
// list all json files in workdir/agents/
|
||||
const agentsDir = path.join(WorkDir, "agents");
|
||||
const files = await fs.readdir(agentsDir);
|
||||
|
||||
for (const file of files) {
|
||||
const contents = await fs.readFile(path.join(agentsDir, file), "utf8");
|
||||
result.push(Agent.parse(JSON.parse(contents)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Agent>> {
|
||||
const contents = await fs.readFile(path.join(WorkDir, "agents", `${id}.json`), "utf8");
|
||||
return Agent.parse(JSON.parse(contents));
|
||||
}
|
||||
|
||||
async create(agent: z.infer<typeof Agent>): Promise<void> {
|
||||
await fs.writeFile(path.join(WorkDir, "agents", `${agent.name}.json`), JSON.stringify(agent, null, 2));
|
||||
}
|
||||
|
||||
async update(id: string, agent: z.infer<typeof Agent>): Promise<void> {
|
||||
await fs.writeFile(path.join(WorkDir, "agents", `${id}.json`), JSON.stringify(agent, null, 2));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await fs.unlink(path.join(WorkDir, "agents", `${id}.json`));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,102 @@
|
|||
import { jsonSchema, ModelMessage, modelMessageSchema } from "ai";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { getModelConfig, WorkDir } from "../config/config.js";
|
||||
import { Agent, ToolAttachment } from "../entities/agent.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { Agent, ToolAttachment } from "./agents.js";
|
||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, 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 { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
|
||||
import { BuiltinTools } from "./builtin-tools.js";
|
||||
import { CopilotAgent } from "../assistant/agent.js";
|
||||
import { isBlocked } from "./command-executor.js";
|
||||
import { execTool } from "../application/lib/exec-tool.js";
|
||||
import { MessageEvent, AskHumanRequestEvent, RunEvent, ToolInvocationEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
|
||||
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||
import { isBlocked } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { getProvider } from "../models/models.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IdGen, IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
|
||||
export interface IAgentRuntime {
|
||||
trigger(runId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class AgentRuntime implements IAgentRuntime {
|
||||
private runsRepo: IRunsRepo;
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
private bus: IBus;
|
||||
private messageQueue: IMessageQueue;
|
||||
private modelConfigRepo: IModelConfigRepo;
|
||||
private runsLock: IRunsLock;
|
||||
|
||||
constructor({
|
||||
runsRepo,
|
||||
idGenerator,
|
||||
bus,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
runsLock,
|
||||
}: {
|
||||
runsRepo: IRunsRepo;
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
bus: IBus;
|
||||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
runsLock: IRunsLock;
|
||||
}) {
|
||||
this.runsRepo = runsRepo;
|
||||
this.idGenerator = idGenerator;
|
||||
this.bus = bus;
|
||||
this.messageQueue = messageQueue;
|
||||
this.modelConfigRepo = modelConfigRepo;
|
||||
this.runsLock = runsLock;
|
||||
}
|
||||
|
||||
async trigger(runId: string): Promise<void> {
|
||||
if (!await this.runsLock.lock(runId)) {
|
||||
console.log(`unable to acquire lock on run ${runId}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
let eventCount = 0;
|
||||
const run = await this.runsRepo.fetch(runId);
|
||||
if (!run) {
|
||||
throw new Error(`Run ${runId} not found`);
|
||||
}
|
||||
const state = new AgentState();
|
||||
for (const event of run.log) {
|
||||
state.ingest(event);
|
||||
}
|
||||
for await (const event of streamAgent({
|
||||
state,
|
||||
idGenerator: this.idGenerator,
|
||||
runId,
|
||||
messageQueue: this.messageQueue,
|
||||
modelConfigRepo: this.modelConfigRepo,
|
||||
})) {
|
||||
eventCount++;
|
||||
if (event.type !== "llm-stream-event") {
|
||||
await this.runsRepo.appendEvents(runId, [event]);
|
||||
}
|
||||
await this.bus.publish(event);
|
||||
}
|
||||
|
||||
// if no events, break
|
||||
if (!eventCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await this.runsLock.release(runId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
|
||||
switch (t.type) {
|
||||
|
|
@ -128,8 +211,8 @@ export class StreamStepMessageBuilder {
|
|||
});
|
||||
break;
|
||||
case "finish-step":
|
||||
this.providerOptions = event.providerOptions;
|
||||
break;
|
||||
this.providerOptions = event.providerOptions;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -171,9 +254,8 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
if (id === "copilot") {
|
||||
return CopilotAgent;
|
||||
}
|
||||
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
|
||||
const agent = fs.readFileSync(agentPath, "utf8");
|
||||
return Agent.parse(JSON.parse(agent));
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
return await repo.fetch(id);
|
||||
}
|
||||
|
||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
||||
|
|
@ -262,10 +344,9 @@ async function buildTools(agent: z.infer<typeof Agent>): Promise<ToolSet> {
|
|||
}
|
||||
|
||||
export class AgentState {
|
||||
logger: RunLogger | null = null;
|
||||
runId: string | null = null;
|
||||
agent: z.infer<typeof Agent> | null = null;
|
||||
agentName: string;
|
||||
agentName: string | null = null;
|
||||
messages: z.infer<typeof MessageList> = [];
|
||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||
subflowStates: Record<string, AgentState> = {};
|
||||
|
|
@ -276,20 +357,6 @@ export class AgentState {
|
|||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
|
||||
constructor(agentName: string, runId?: string) {
|
||||
this.agentName = agentName;
|
||||
this.runId = runId || runIdGenerator.next();
|
||||
this.logger = new RunLogger(this.runId);
|
||||
if (!runId) {
|
||||
this.logger.log({
|
||||
type: "start",
|
||||
runId: this.runId,
|
||||
agentName: this.agentName,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
|
||||
|
|
@ -346,6 +413,9 @@ export class AgentState {
|
|||
ingest(event: z.infer<typeof RunEvent>) {
|
||||
if (event.subflow.length > 0) {
|
||||
const { subflow, ...rest } = event;
|
||||
if (!this.subflowStates[subflow[0]]) {
|
||||
this.subflowStates[subflow[0]] = new AgentState();
|
||||
}
|
||||
this.subflowStates[subflow[0]].ingest({
|
||||
...rest,
|
||||
subflow: subflow.slice(1),
|
||||
|
|
@ -353,6 +423,10 @@ export class AgentState {
|
|||
return;
|
||||
}
|
||||
switch (event.type) {
|
||||
case "start":
|
||||
this.runId = event.runId;
|
||||
this.agentName = event.agentName;
|
||||
break;
|
||||
case "message":
|
||||
this.messages.push(event.message);
|
||||
if (event.message.content instanceof Array) {
|
||||
|
|
@ -371,9 +445,6 @@ export class AgentState {
|
|||
this.lastAssistantMsg = event.message;
|
||||
}
|
||||
break;
|
||||
case "spawn-subflow":
|
||||
this.subflowStates[event.toolCallId] = new AgentState(event.agentName);
|
||||
break;
|
||||
case "tool-permission-request":
|
||||
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
|
||||
break;
|
||||
|
|
@ -406,27 +477,33 @@ export class AgentState {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ingestAndLog(event: z.infer<typeof RunEvent>) {
|
||||
this.ingest(event);
|
||||
this.logger!.log(event);
|
||||
}
|
||||
|
||||
*ingestAndLogAndYield(event: z.infer<typeof RunEvent>): Generator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
this.ingestAndLog(event);
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
// get model config
|
||||
const modelConfig = await getModelConfig();
|
||||
export async function* streamAgent({
|
||||
state,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
}: {
|
||||
state: AgentState,
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
runId: string;
|
||||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
async function* processEvent(event: z.infer<typeof RunEvent>): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
state.ingest(event);
|
||||
yield event;
|
||||
}
|
||||
|
||||
const modelConfig = await modelConfigRepo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
|
||||
// set up agent
|
||||
const agent = await loadAgent(state.agentName);
|
||||
const agent = await loadAgent(state.agentName!);
|
||||
|
||||
// set up tools
|
||||
const tools = await buildTools(agent);
|
||||
|
|
@ -436,9 +513,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
|
||||
let loopCounter = 0;
|
||||
|
||||
console.log('here');
|
||||
|
||||
async function pendingMsgs() {
|
||||
const pendingMsgs = [];
|
||||
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// console.error(`loop counter: ${loopCounter++}`)
|
||||
// if last response is from assistant and text, so exit
|
||||
// if last response is from assistant and text, get any pending msgs
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage
|
||||
&& lastMessage.role === "assistant"
|
||||
|
|
@ -446,8 +530,28 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
|| !lastMessage.content.some(part => part.type === "tool-call")
|
||||
)
|
||||
) {
|
||||
// console.error("Nothing to do, exiting (a.)")
|
||||
return;
|
||||
let pending = 0;
|
||||
while(true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
break;
|
||||
}
|
||||
pending++;
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "message",
|
||||
messageId: msg.messageId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: msg.message,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
// if no msgs found, return
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// execute any pending tool calls
|
||||
|
|
@ -461,7 +565,9 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
|
||||
// if tool has been denied, deny
|
||||
if (state.deniedToolCallIds[toolCallId]) {
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "tool",
|
||||
|
|
@ -480,7 +586,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
}
|
||||
|
||||
// execute approved tool
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "tool-invocation",
|
||||
toolName: toolCall.toolName,
|
||||
input: JSON.stringify(toolCall.arguments),
|
||||
|
|
@ -489,8 +596,14 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
let result: any = null;
|
||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||
let subflowState = state.subflowStates[toolCallId];
|
||||
for await (const event of streamAgent(subflowState)) {
|
||||
yield* state.ingestAndLogAndYield({
|
||||
for await (const event of streamAgent({
|
||||
state: subflowState,
|
||||
idGenerator,
|
||||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
})) {
|
||||
yield *processEvent({
|
||||
...event,
|
||||
subflow: [toolCallId, ...event.subflow],
|
||||
});
|
||||
|
|
@ -508,13 +621,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
};
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-result",
|
||||
toolName: toolCall.toolName,
|
||||
result: result,
|
||||
subflow: [],
|
||||
});
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: resultMsg,
|
||||
subflow: [],
|
||||
|
|
@ -529,10 +645,29 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
}
|
||||
|
||||
// if current message state isn't runnable, exit
|
||||
/*
|
||||
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
|
||||
// console.error("current message state isn't runnable, exiting (c.)")
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
while(true) {
|
||||
const msg = await messageQueue.dequeue(runId);
|
||||
if (!msg) {
|
||||
break;
|
||||
}
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "message",
|
||||
messageId: msg.messageId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: msg.message,
|
||||
},
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
|
||||
// run one LLM turn.
|
||||
// stream agent response and build message
|
||||
|
|
@ -544,7 +679,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
tools,
|
||||
)) {
|
||||
messageBuilder.ingest(event);
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "llm-stream-event",
|
||||
event: event,
|
||||
subflow: [],
|
||||
|
|
@ -553,7 +689,9 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
|
||||
// build and emit final message from agent response
|
||||
const message = messageBuilder.get();
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message,
|
||||
subflow: [],
|
||||
|
|
@ -565,7 +703,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
if (part.type === "tool-call") {
|
||||
const underlyingTool = agent.tools![part.toolName];
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "ask-human-request",
|
||||
toolCallId: part.toolCallId,
|
||||
query: part.arguments.question,
|
||||
|
|
@ -575,7 +714,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||
// if command is blocked, then seek permission
|
||||
if (isBlocked(part.arguments.command)) {
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: part,
|
||||
subflow: [],
|
||||
|
|
@ -583,13 +723,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
|||
}
|
||||
}
|
||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
type: "spawn-subflow",
|
||||
agentName: underlyingTool.name,
|
||||
toolCallId: part.toolCallId,
|
||||
subflow: [],
|
||||
});
|
||||
yield* state.ingestAndLogAndYield({
|
||||
yield *processEvent({
|
||||
runId,
|
||||
messageId: await idGenerator.next(),
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
|
|
@ -1,41 +1,22 @@
|
|||
import { AgentState, streamAgent } from "./application/lib/agent.js";
|
||||
import { AgentState, streamAgent } from "./agents/runtime.js";
|
||||
import { StreamRenderer } from "./application/lib/stream-renderer.js";
|
||||
import { stdin as input, stdout as output } from "node:process";
|
||||
import fs from "fs";
|
||||
import { promises as fsp } from "fs";
|
||||
import path from "path";
|
||||
import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js";
|
||||
import { RunEvent } from "./application/entities/run-events.js";
|
||||
import { WorkDir } from "./config/config.js";
|
||||
import { RunEvent } from "./entities/run-events.js";
|
||||
import { createInterface, Interface } from "node:readline/promises";
|
||||
import { ToolCallPart } from "./application/entities/message.js";
|
||||
import { Agent } from "./application/entities/agent.js";
|
||||
import { McpServerConfig, McpServerDefinition } from "./application/entities/mcp.js";
|
||||
import { Example } from "./application/entities/example.js";
|
||||
import { ToolCallPart } from "./entities/message.js";
|
||||
import { Agent } from "./agents/agents.js";
|
||||
import { McpServerConfig } from "./mcp/mcp.js";
|
||||
import { McpServerDefinition } from "./mcp/mcp.js";
|
||||
import { Example } from "./entities/example.js";
|
||||
import { z } from "zod";
|
||||
import { Flavor } from "./application/entities/models.js";
|
||||
import { Flavor } from "./models/models.js";
|
||||
import { examples } from "./examples/index.js";
|
||||
import { modelMessageSchema } from "ai";
|
||||
|
||||
export async function updateState(agent: string, runId: string) {
|
||||
const state = new AgentState(agent, runId);
|
||||
// If running in a TTY, read run events from stdin line-by-line
|
||||
if (!input.isTTY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rl = createInterface({ input, crlfDelay: Infinity });
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
if (line.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
const event = RunEvent.parse(JSON.parse(line));
|
||||
state.ingestAndLog(event);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
import container from "./di/container.js";
|
||||
import { IModelConfigRepo } from "./models/repo.js";
|
||||
|
||||
function renderGreeting() {
|
||||
const logo = `
|
||||
|
|
@ -61,12 +42,8 @@ export async function app(opts: {
|
|||
input?: string;
|
||||
noInteractive?: boolean;
|
||||
}) {
|
||||
// check if model config is required
|
||||
const c = await getModelConfig();
|
||||
if (!c) {
|
||||
await modelConfig();
|
||||
}
|
||||
|
||||
throw new Error("Not implemented");
|
||||
/*
|
||||
const renderer = new StreamRenderer();
|
||||
const state = new AgentState(opts.agent, opts.runId);
|
||||
|
||||
|
|
@ -172,6 +149,7 @@ export async function app(opts: {
|
|||
} finally {
|
||||
rl?.close();
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
async function getToolCallPermission(
|
||||
|
|
@ -219,7 +197,8 @@ async function getUserInput(
|
|||
|
||||
export async function modelConfig() {
|
||||
// load existing model config
|
||||
const config = await getModelConfig();
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
|
||||
const rl = createInterface({ input, output });
|
||||
try {
|
||||
|
|
@ -333,14 +312,7 @@ export async function modelConfig() {
|
|||
);
|
||||
const model = modelAns.trim() || modelDefault;
|
||||
|
||||
const newConfig = {
|
||||
providers: { ...(config?.providers || {}) },
|
||||
defaults: {
|
||||
provider: providerName!,
|
||||
model,
|
||||
},
|
||||
};
|
||||
await updateModelConfig(newConfig as any);
|
||||
await repo.setDefault(providerName!, model);
|
||||
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -391,24 +363,13 @@ export async function modelConfig() {
|
|||
);
|
||||
const model = modelAns.trim() || modelDefault;
|
||||
|
||||
const mergedProviders = {
|
||||
...(config?.providers || {}),
|
||||
[providerName]: {
|
||||
flavor: selectedFlavor,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
},
|
||||
};
|
||||
const newConfig = {
|
||||
providers: mergedProviders,
|
||||
defaults: {
|
||||
provider: providerName,
|
||||
model,
|
||||
},
|
||||
};
|
||||
|
||||
await updateModelConfig(newConfig as any);
|
||||
await repo.upsert(providerName, {
|
||||
flavor: selectedFlavor,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
await repo.setDefault(providerName, model);
|
||||
renderCurrentModel(providerName, selectedFlavor, model);
|
||||
console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Agent, ToolAttachment } from "../entities/agent.js";
|
||||
import { Agent, ToolAttachment } from "../../agents/agents.js";
|
||||
import z from "zod";
|
||||
import { CopilotInstructions } from "./instructions.js";
|
||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { skillCatalog } from "./skills/index.js";
|
||||
import { WorkDir as BASE_DIR } from "../config/config.js";
|
||||
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}. You can also help the user with general tasks.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { McpServerConfig } from "../entities/mcp.js";
|
||||
import { ModelConfig } from "../entities/models.js";
|
||||
import { z } from "zod";
|
||||
import { homedir } from "os";
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
|
||||
let modelConfig: z.infer<typeof ModelConfig> | null = null;
|
||||
|
||||
const baseMcpConfig: z.infer<typeof McpServerConfig> = {
|
||||
mcpServers: {
|
||||
}
|
||||
};
|
||||
|
||||
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, "agents"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
ensureMcpConfig();
|
||||
}
|
||||
|
||||
function loadMcpServerConfig(): z.infer<typeof McpServerConfig> {
|
||||
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));
|
||||
}
|
||||
|
||||
export async function getModelConfig(): Promise<z.infer<typeof ModelConfig> | null> {
|
||||
if (modelConfig) {
|
||||
return modelConfig;
|
||||
}
|
||||
const configPath = path.join(WorkDir, "config", "models.json");
|
||||
try {
|
||||
const config = await fs.promises.readFile(configPath, "utf8");
|
||||
modelConfig = ModelConfig.parse(JSON.parse(config));
|
||||
return modelConfig;
|
||||
} catch (error) {
|
||||
console.error(`Warning! model config not found!`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateModelConfig(config: z.infer<typeof ModelConfig>) {
|
||||
modelConfig = config;
|
||||
const configPath = path.join(WorkDir, "config", "models.json");
|
||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
const { mcpServers } = loadMcpServerConfig();
|
||||
export const McpServers = mcpServers;
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const StdioMcpServerConfig = z.object({
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const HttpMcpServerConfig = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerDefinition),
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import z from "zod";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
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 { 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";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { McpServerDefinition, McpServerConfig } from "../entities/mcp.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
||||
import { McpServerDefinition } from "../../mcp/mcp.js";
|
||||
|
||||
const BuiltinToolsSchema = z.record(z.string(), z.object({
|
||||
description: z.string(),
|
||||
|
|
@ -310,109 +309,33 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
|
||||
inputSchema: z.object({
|
||||
serverName: z.string().describe('Name/alias for the MCP server'),
|
||||
serverType: z.enum(['stdio', 'http']).describe('Type of MCP server: "stdio" for command-based or "http" for HTTP/SSE-based'),
|
||||
command: z.string().optional().describe('Command to execute (required for stdio type, e.g., "npx", "python", "node")'),
|
||||
args: z.array(z.string()).optional().describe('Command arguments (optional, for stdio type)'),
|
||||
env: z.record(z.string(), z.string()).optional().describe('Environment variables (optional, for stdio type)'),
|
||||
url: z.string().optional().describe('HTTP/SSE endpoint URL (required for http type)'),
|
||||
headers: z.record(z.string(), z.string()).optional().describe('HTTP headers (optional, for http type)'),
|
||||
config: McpServerDefinition,
|
||||
}),
|
||||
execute: async ({ serverName, serverType, command, args, env, url, headers }: {
|
||||
execute: async ({ serverName, config }: {
|
||||
serverName: string;
|
||||
serverType: 'stdio' | 'http';
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
config: z.infer<typeof McpServerDefinition>;
|
||||
}) => {
|
||||
try {
|
||||
// Build server definition based on type
|
||||
let serverDef: any;
|
||||
if (serverType === 'stdio') {
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'For stdio type servers, "command" is required. Example: "npx" or "python"',
|
||||
validationErrors: ['Missing required field: command'],
|
||||
};
|
||||
}
|
||||
serverDef = {
|
||||
type: 'stdio',
|
||||
command,
|
||||
...(args ? { args } : {}),
|
||||
...(env ? { env } : {}),
|
||||
};
|
||||
} else if (serverType === 'http') {
|
||||
if (!url) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'For http type servers, "url" is required. Example: "http://localhost:3000/sse"',
|
||||
validationErrors: ['Missing required field: url'],
|
||||
};
|
||||
}
|
||||
serverDef = {
|
||||
type: 'http',
|
||||
url,
|
||||
...(headers ? { headers } : {}),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid serverType: ${serverType}. Must be "stdio" or "http"`,
|
||||
validationErrors: [`Invalid serverType: ${serverType}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Validate against Zod schema
|
||||
const validationResult = McpServerDefinition.safeParse(serverDef);
|
||||
const validationResult = McpServerDefinition.safeParse(config);
|
||||
if (!validationResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Server definition failed validation. Check the errors below.',
|
||||
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
|
||||
providedDefinition: serverDef,
|
||||
providedDefinition: config,
|
||||
};
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
|
||||
let currentConfig: z.infer<typeof McpServerConfig> = { mcpServers: {} };
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
currentConfig = McpServerConfig.parse(JSON.parse(content));
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to read existing MCP config: ${error.message}`,
|
||||
};
|
||||
}
|
||||
// File doesn't exist, use empty config
|
||||
}
|
||||
|
||||
// Check if server already exists
|
||||
const isUpdate = !!currentConfig.mcpServers[serverName];
|
||||
|
||||
// Add/update server
|
||||
currentConfig.mcpServers[serverName] = validationResult.data;
|
||||
|
||||
// Write back to file
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, JSON.stringify(currentConfig, null, 2), 'utf-8');
|
||||
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.upsert(serverName, config);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `MCP server '${serverName}' ${isUpdate ? 'updated' : 'added'} successfully`,
|
||||
serverName,
|
||||
serverType,
|
||||
isUpdate,
|
||||
configuration: validationResult.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to add MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -421,47 +344,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
listMcpServers: {
|
||||
description: 'List all available MCP servers from the configuration',
|
||||
inputSchema: z.object({}),
|
||||
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
|
||||
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: [],
|
||||
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,
|
||||
};
|
||||
});
|
||||
const result = await listServers();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers,
|
||||
count: servers.length,
|
||||
message: `Found ${servers.length} MCP server(s)`,
|
||||
result,
|
||||
count: Object.keys(result.mcpServers).length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
servers: [],
|
||||
count: 0,
|
||||
message: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -471,69 +364,19 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
description: 'List all available tools from a specific MCP server',
|
||||
inputSchema: z.object({
|
||||
serverName: z.string().describe('Name of the MCP server to query'),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ serverName }: { serverName: string }) => {
|
||||
execute: async ({ serverName, cursor }: { serverName: string, cursor?: 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,
|
||||
}));
|
||||
|
||||
const result = await listTools(serverName, cursor);
|
||||
return {
|
||||
success: true,
|
||||
serverName,
|
||||
tools,
|
||||
count: tools.length,
|
||||
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
|
||||
result,
|
||||
count: result.tools.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -547,108 +390,19 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
arguments: z.record(z.string(), z.any()).optional().describe('Arguments to pass to the tool (as key-value pairs matching the tool\'s input schema). MUST include all required parameters from the tool\'s inputSchema.'),
|
||||
}),
|
||||
execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record<string, any> }) => {
|
||||
let transport: any;
|
||||
let client: any;
|
||||
|
||||
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. Use listMcpServers to see available servers.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Create transport based on config type
|
||||
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
|
||||
client = new Client({
|
||||
name: 'rowboat-copilot',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Get tool list to validate the tool exists and check schema
|
||||
const toolsList = await client.listTools();
|
||||
const toolDef = toolsList.tools.find((t: any) => t.name === toolName);
|
||||
|
||||
if (!toolDef) {
|
||||
await client.close();
|
||||
transport.close();
|
||||
return {
|
||||
success: false,
|
||||
message: `Tool '${toolName}' not found in server '${serverName}'. Use listMcpTools to see available tools.`,
|
||||
availableTools: toolsList.tools.map((t: any) => t.name),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
const inputSchema = toolDef.inputSchema;
|
||||
if (inputSchema && inputSchema.required && Array.isArray(inputSchema.required)) {
|
||||
const missingParams = inputSchema.required.filter((param: string) => !(param in args));
|
||||
if (missingParams.length > 0) {
|
||||
await client.close();
|
||||
transport.close();
|
||||
return {
|
||||
success: false,
|
||||
message: `Missing required parameters: ${missingParams.join(', ')}`,
|
||||
requiredParameters: inputSchema.required,
|
||||
providedArguments: Object.keys(args),
|
||||
toolSchema: inputSchema,
|
||||
hint: `Use listMcpTools to see the full schema for '${toolName}' and ensure all required parameters are included in the arguments field.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Call the tool
|
||||
const result = await client.callTool({
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
});
|
||||
|
||||
// Close connection
|
||||
await client.close();
|
||||
transport.close();
|
||||
|
||||
const result = await executeTool(serverName, toolName, args);
|
||||
return {
|
||||
success: true,
|
||||
serverName,
|
||||
toolName,
|
||||
result: result.content,
|
||||
result,
|
||||
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
|
||||
};
|
||||
} catch (error) {
|
||||
// Ensure cleanup
|
||||
try {
|
||||
if (client) await client.close();
|
||||
if (transport) transport.close();
|
||||
} catch (cleanupError) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
serverName,
|
||||
toolName,
|
||||
error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
38
apps/cli/src/application/lib/bus.ts
Normal file
38
apps/cli/src/application/lib/bus.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { RunEvent } from "../../entities/run-events.js";
|
||||
import z from "zod";
|
||||
|
||||
export interface IBus {
|
||||
publish(event: z.infer<typeof RunEvent>): Promise<void>;
|
||||
|
||||
// subscribe accepts a handler to handle events
|
||||
// and returns a function to unsubscribe
|
||||
subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
|
||||
export class InMemoryBus implements IBus {
|
||||
private subscribers: Map<string, ((event: z.infer<typeof RunEvent>) => Promise<void>)[]> = new Map();
|
||||
|
||||
async publish(event: z.infer<typeof RunEvent>): Promise<void> {
|
||||
console.log(this.subscribers);
|
||||
const pending: Promise<void>[] = [];
|
||||
for (const subscriber of this.subscribers.get(event.runId) || []) {
|
||||
pending.push(subscriber(event));
|
||||
}
|
||||
for (const subscriber of this.subscribers.get('*') || []) {
|
||||
pending.push(subscriber(event));
|
||||
}
|
||||
console.log(pending.length);
|
||||
await Promise.all(pending);
|
||||
}
|
||||
|
||||
async subscribe(runId: string, handler: (event: z.infer<typeof RunEvent>) => Promise<void>): Promise<() => void> {
|
||||
if (!this.subscribers.has(runId)) {
|
||||
this.subscribers.set(runId, []);
|
||||
}
|
||||
this.subscribers.get(runId)!.push(handler);
|
||||
console.log(this.subscribers);
|
||||
return () => {
|
||||
this.subscribers.get(runId)!.splice(this.subscribers.get(runId)!.indexOf(handler), 1);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { exec, execSync } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../config/security.js';
|
||||
import { getSecurityAllowList, SECURITY_CONFIG_PATH } from '../../config/security.js';
|
||||
|
||||
const execPromise = promisify(exec);
|
||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
||||
|
|
|
|||
|
|
@ -1,53 +1,10 @@
|
|||
import { ToolAttachment } from "../entities/agent.js";
|
||||
import { ToolAttachment } from "../../agents/agents.js";
|
||||
import { z } from "zod";
|
||||
import { McpServers } from "../config/config.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 { BuiltinTools } from "./builtin-tools.js";
|
||||
import { executeTool } from "../../mcp/mcp.js";
|
||||
|
||||
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) {
|
||||
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();
|
||||
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,23 @@
|
|||
class RunIdGenerator {
|
||||
export interface IMonotonicallyIncreasingIdGenerator {
|
||||
next(): Promise<string>;
|
||||
}
|
||||
|
||||
export class IdGen implements IMonotonicallyIncreasingIdGenerator {
|
||||
private lastMs = 0;
|
||||
private seq = 0;
|
||||
private readonly pid: string;
|
||||
private readonly hostTag: string;
|
||||
|
||||
constructor(hostTag: string = "") {
|
||||
constructor() {
|
||||
this.pid = String(process.pid).padStart(7, "0");
|
||||
this.hostTag = hostTag ? `-${hostTag}` : "";
|
||||
this.hostTag = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an ISO8601-based, lexicographically sortable id string.
|
||||
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
|
||||
*/
|
||||
next(): string {
|
||||
async next(): Promise<string> {
|
||||
const now = Date.now();
|
||||
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
|
||||
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
|
||||
|
|
@ -27,6 +31,4 @@ class RunIdGenerator {
|
|||
const seqStr = String(this.seq).padStart(3, "0");
|
||||
return `${iso}-${this.pid}${this.hostTag}-${seqStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const runIdGenerator = new RunIdGenerator();
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
44
apps/cli/src/application/lib/message-queue.ts
Normal file
44
apps/cli/src/application/lib/message-queue.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import z from "zod";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js";
|
||||
|
||||
const EnqueuedMessage = z.object({
|
||||
messageId: z.string(),
|
||||
message: z.string(),
|
||||
});
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: string): Promise<string>;
|
||||
dequeue(runId: string): Promise<z.infer<typeof EnqueuedMessage> | null>;
|
||||
}
|
||||
|
||||
export class InMemoryMessageQueue implements IMessageQueue {
|
||||
private store: Record<string, z.infer<typeof EnqueuedMessage>[]> = {};
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
|
||||
constructor({
|
||||
idGenerator,
|
||||
}: {
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
}) {
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: string): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
const id = await this.idGenerator.next();
|
||||
this.store[runId].push({
|
||||
messageId: id,
|
||||
message,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async dequeue(runId: string): Promise<z.infer<typeof EnqueuedMessage> | null> {
|
||||
if (!this.store[runId]) {
|
||||
return null;
|
||||
}
|
||||
return this.store[runId].shift() ?? null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { MessageList } from "../entities/message.js";
|
||||
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
|
||||
import { z } from "zod";
|
||||
import { ToolAttachment } 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 ToolAttachment>>;
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { RunEvent } from "../entities/run-events.js";
|
||||
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
|
||||
import { RunEvent } from "../../entities/run-events.js";
|
||||
import { LlmStepStreamEvent } from "../../entities/llm-step-events.js";
|
||||
|
||||
export interface StreamRendererOptions {
|
||||
showHeaders?: boolean;
|
||||
|
|
|
|||
15
apps/cli/src/config/config.ts
Normal file
15
apps/cli/src/config/config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { homedir } from "os";
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
|
||||
function ensureDirs() {
|
||||
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
|
||||
ensure(WorkDir);
|
||||
ensure(path.join(WorkDir, "agents"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
30
apps/cli/src/di/container.ts
Normal file
30
apps/cli/src/di/container.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { asClass, createContainer, InjectionMode } from "awilix";
|
||||
import { FSModelConfigRepo, IModelConfigRepo } from "../models/repo.js";
|
||||
import { FSMcpConfigRepo, IMcpConfigRepo } from "../mcp/repo.js";
|
||||
import { FSAgentsRepo, IAgentsRepo } from "../agents/repo.js";
|
||||
import { FSRunsRepo, IRunsRepo } from "../runs/repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator, IdGen } from "../application/lib/id-gen.js";
|
||||
import { IMessageQueue, InMemoryMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IBus, InMemoryBus } from "../application/lib/bus.js";
|
||||
import { IRunsLock, InMemoryRunsLock } from "../runs/lock.js";
|
||||
import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
container.register({
|
||||
idGenerator: asClass<IMonotonicallyIncreasingIdGenerator>(IdGen).singleton(),
|
||||
messageQueue: asClass<IMessageQueue>(InMemoryMessageQueue).singleton(),
|
||||
bus: asClass<IBus>(InMemoryBus).singleton(),
|
||||
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
|
||||
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
|
||||
|
||||
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
|
||||
modelConfigRepo: asClass<IModelConfigRepo>(FSModelConfigRepo).singleton(),
|
||||
agentsRepo: asClass<IAgentsRepo>(FSAgentsRepo).singleton(),
|
||||
runsRepo: asClass<IRunsRepo>(FSRunsRepo).singleton(),
|
||||
});
|
||||
|
||||
export default container;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import z from "zod"
|
||||
import { Agent } from "./agent.js"
|
||||
import { McpServerDefinition } from "./mcp.js"
|
||||
import { Agent } from "../agents/agents.js"
|
||||
import { McpServerDefinition } from "../mcp/mcp.js";
|
||||
|
||||
export const Example = z.object({
|
||||
id: z.string(),
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
||||
import { Message, ToolCallPart } from "./message.js";
|
||||
import { Agent } from "./agent.js";
|
||||
import z from "zod";
|
||||
|
||||
const BaseRunEvent = z.object({
|
||||
runId: z.string(),
|
||||
ts: z.iso.datetime().optional(),
|
||||
subflow: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const StartEvent = BaseRunEvent.extend({
|
||||
type: z.literal("start"),
|
||||
runId: z.string(),
|
||||
agentName: z.string(),
|
||||
});
|
||||
|
||||
|
|
@ -27,6 +26,7 @@ export const LlmStreamEvent = BaseRunEvent.extend({
|
|||
|
||||
export const MessageEvent = BaseRunEvent.extend({
|
||||
type: z.literal("message"),
|
||||
messageId: z.string(),
|
||||
message: Message,
|
||||
});
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import twitterPodcast from './twitter-podcast.json' with { type: 'json' };
|
||||
import { Example } from '../application/entities/example.js';
|
||||
import { Example } from '../entities/example.js';
|
||||
import z from 'zod';
|
||||
|
||||
export const examples: Record<string, z.infer<typeof Example>> = {
|
||||
|
|
|
|||
174
apps/cli/src/mcp/mcp.ts
Normal file
174
apps/cli/src/mcp/mcp.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import container from "../di/container.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import z from "zod";
|
||||
import { IMcpConfigRepo } from "./repo.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
export const StdioMcpServerConfig = z.object({
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const HttpMcpServerConfig = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerDefinition),
|
||||
});
|
||||
|
||||
const connectionState = z.enum(["disconnected", "connected", "error"]);
|
||||
|
||||
export const McpServerList = z.object({
|
||||
mcpServers: z.record(z.string(), z.object({
|
||||
config: McpServerDefinition,
|
||||
state: connectionState,
|
||||
error: z.string().nullable(),
|
||||
})),
|
||||
});
|
||||
|
||||
/*
|
||||
inputSchema: {
|
||||
[x: string]: unknown;
|
||||
type: "object";
|
||||
properties?: Record<string, object> | undefined;
|
||||
required?: string[] | undefined;
|
||||
};
|
||||
*/
|
||||
export const Tool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
outputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const ListToolsResponse = z.object({
|
||||
tools: z.array(Tool),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
type mcpState = {
|
||||
state: z.infer<typeof connectionState>,
|
||||
client: Client | null,
|
||||
error: string | null,
|
||||
};
|
||||
const clients: Record<string, mcpState> = {};
|
||||
|
||||
async function getClient(serverName: string): Promise<Client> {
|
||||
if (clients[serverName] && clients[serverName].state === "connected") {
|
||||
return clients[serverName].client!;
|
||||
}
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
const { mcpServers } = await repo.getConfig();
|
||||
const config = mcpServers[serverName];
|
||||
if (!config) {
|
||||
throw new Error(`MCP server ${serverName} not found`);
|
||||
}
|
||||
let transport: Transport | undefined = undefined;
|
||||
try {
|
||||
// create transport
|
||||
if ("command" in config) {
|
||||
transport = new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
transport = new StreamableHTTPClientTransport(new URL(config.url));
|
||||
} catch (error) {
|
||||
// if that fails, try sse transport
|
||||
transport = new SSEClientTransport(new URL(config.url));
|
||||
}
|
||||
}
|
||||
|
||||
if (!transport) {
|
||||
throw new Error(`No transport found for ${serverName}`);
|
||||
}
|
||||
|
||||
// create client
|
||||
const client = new Client({
|
||||
name: 'rowboatx',
|
||||
version: '1.0.0',
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
// store
|
||||
clients[serverName] = {
|
||||
state: "connected",
|
||||
client,
|
||||
error: null,
|
||||
};
|
||||
return client;
|
||||
} catch (error) {
|
||||
clients[serverName] = {
|
||||
state: "error",
|
||||
client: null,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
transport?.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanup() {
|
||||
for (const [serverName, { client }] of Object.entries(clients)) {
|
||||
await client?.transport?.close();
|
||||
await client?.close();
|
||||
delete clients[serverName];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServers(): Promise<z.infer<typeof McpServerList>> {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
const { mcpServers } = await repo.getConfig();
|
||||
const result: z.infer<typeof McpServerList> = {
|
||||
mcpServers: {},
|
||||
};
|
||||
for (const [serverName, config] of Object.entries(mcpServers)) {
|
||||
const state = clients[serverName];
|
||||
result.mcpServers[serverName] = {
|
||||
config,
|
||||
state: state ? state.state : "disconnected",
|
||||
error: state ? state.error : null,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function listTools(serverName: string, cursor?: string): Promise<z.infer<typeof ListToolsResponse>> {
|
||||
const client = await getClient(serverName);
|
||||
const { tools, nextCursor } = await client.listTools({
|
||||
cursor,
|
||||
});
|
||||
return {
|
||||
tools,
|
||||
nextCursor,
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeTool(serverName: string, toolName: string, input: any): Promise<unknown> {
|
||||
const client = await getClient(serverName);
|
||||
const result = await client.callTool({
|
||||
name: toolName,
|
||||
arguments: input,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
45
apps/cli/src/mcp/repo.ts
Normal file
45
apps/cli/src/mcp/repo.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import { McpServerConfig } from "./mcp.js";
|
||||
import { McpServerDefinition } from "./mcp.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export interface IMcpConfigRepo {
|
||||
getConfig(): Promise<z.infer<typeof McpServerConfig>>;
|
||||
upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void>;
|
||||
delete(serverName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSMcpConfigRepo implements IMcpConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch (error) {
|
||||
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof McpServerConfig>> {
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
async upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.mcpServers[serverName] = config;
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
|
||||
async delete(serverName: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
delete conf.mcpServers[serverName];
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,42 @@ import { createAnthropic } from "@ai-sdk/anthropic";
|
|||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { getModelConfig } from "../config/config.js";
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import container from "../di/container.js";
|
||||
import z from "zod";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const providerMap: Record<string, ProviderV2> = {};
|
||||
|
||||
export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
||||
// get model conf
|
||||
const modelConfig = await getModelConfig();
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const modelConfig = await repo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
70
apps/cli/src/models/repo.ts
Normal file
70
apps/cli/src/models/repo.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { ModelConfig, Provider } from "./models.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export interface IModelConfigRepo {
|
||||
getConfig(): Promise<z.infer<typeof ModelConfig>>;
|
||||
upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void>;
|
||||
delete(providerName: string): Promise<void>;
|
||||
setDefault(providerName: string, model: string): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultConfig: z.infer<typeof ModelConfig> = {
|
||||
providers: {
|
||||
"openai": {
|
||||
flavor: "openai",
|
||||
}
|
||||
},
|
||||
defaults: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
}
|
||||
};
|
||||
|
||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch (error) {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(defaultConfig, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return ModelConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
async upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.providers[providerName] = config;
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async delete(providerName: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
delete conf.providers[providerName];
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async setDefault(providerName: string, model: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.defaults = {
|
||||
provider: providerName,
|
||||
model,
|
||||
};
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
}
|
||||
20
apps/cli/src/runs/lock.ts
Normal file
20
apps/cli/src/runs/lock.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export interface IRunsLock {
|
||||
lock(runId: string): Promise<boolean>;
|
||||
release(runId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class InMemoryRunsLock implements IRunsLock {
|
||||
private locks: Record<string, boolean> = {};
|
||||
|
||||
async lock(runId: string): Promise<boolean> {
|
||||
if (this.locks[runId]) {
|
||||
return false;
|
||||
}
|
||||
this.locks[runId] = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
async release(runId: string): Promise<void> {
|
||||
delete this.locks[runId];
|
||||
}
|
||||
}
|
||||
79
apps/cli/src/runs/repo.ts
Normal file
79
apps/cli/src/runs/repo.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Run } from "./runs.js";
|
||||
import z from "zod";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import path from "path";
|
||||
import fsp from "fs/promises";
|
||||
import { RunEvent, StartEvent } from "../entities/run-events.js";
|
||||
|
||||
export const ListRunsResponse = z.object({
|
||||
runs: z.array(Run.pick({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
agentId: true,
|
||||
})),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateRunOptions = Run.pick({
|
||||
agentId: true,
|
||||
});
|
||||
|
||||
export interface IRunsRepo {
|
||||
create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>>;
|
||||
fetch(id: string): Promise<z.infer<typeof Run>>;
|
||||
appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void>;
|
||||
}
|
||||
|
||||
export class FSRunsRepo implements IRunsRepo {
|
||||
private idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
constructor({
|
||||
idGenerator,
|
||||
}: {
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
}) {
|
||||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
|
||||
await fsp.appendFile(
|
||||
path.join(WorkDir, 'runs', `${runId}.jsonl`),
|
||||
events.map(event => JSON.stringify(event)).join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
|
||||
async create(options: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const runId = await this.idGenerator.next();
|
||||
const ts = new Date().toISOString();
|
||||
const start: z.infer<typeof StartEvent> = {
|
||||
type: "start",
|
||||
runId,
|
||||
agentName: options.agentId,
|
||||
subflow: [],
|
||||
ts,
|
||||
};
|
||||
await this.appendEvents(runId, [start]);
|
||||
return {
|
||||
id: runId,
|
||||
createdAt: ts,
|
||||
agentId: options.agentId,
|
||||
log: [start],
|
||||
};
|
||||
}
|
||||
|
||||
async fetch(id: string): Promise<z.infer<typeof Run>> {
|
||||
const contents = await fsp.readFile(path.join(WorkDir, 'runs', `${id}.jsonl`), 'utf8');
|
||||
const events = contents.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.map(line => RunEvent.parse(JSON.parse(line)));
|
||||
if (events.length === 0 || events[0].type !== 'start') {
|
||||
throw new Error('Corrupt run data');
|
||||
}
|
||||
return {
|
||||
id,
|
||||
createdAt: events[0].ts!,
|
||||
agentId: events[0].agentName,
|
||||
log: events,
|
||||
};
|
||||
}
|
||||
}
|
||||
70
apps/cli/src/runs/runs.ts
Normal file
70
apps/cli/src/runs/runs.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import z from "zod";
|
||||
import container from "../di/container.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { AskHumanResponseEvent, RunEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
|
||||
import { CreateRunOptions, IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
|
||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||
subflow: true,
|
||||
toolCallId: true,
|
||||
response: true,
|
||||
});
|
||||
|
||||
export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
||||
subflow: true,
|
||||
toolCallId: true,
|
||||
response: true,
|
||||
});
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
agentId: z.string(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
const run = await repo.create(opts);
|
||||
await bus.publish(run.log[0]);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function authorizePermission(runId: string, ev: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof ToolPermissionResponseEvent> = {
|
||||
...ev,
|
||||
runId,
|
||||
type: "tool-permission-response",
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
}
|
||||
|
||||
export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof AskHumanResponsePayload>): Promise<void> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const event: z.infer<typeof AskHumanResponseEvent> = {
|
||||
...ev,
|
||||
runId,
|
||||
type: "ask-human-response",
|
||||
};
|
||||
await repo.appendEvents(runId, [event]);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
runtime.trigger(runId);
|
||||
}
|
||||
|
||||
export async function stop(runId: string): Promise<void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
653
apps/cli/src/server.ts
Normal file
653
apps/cli/src/server.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import { Hono } from 'hono';
|
||||
import { serve } from '@hono/node-server'
|
||||
import { streamSSE } from 'hono/streaming'
|
||||
import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from 'zod';
|
||||
import container from './di/container.js';
|
||||
import { executeTool, listServers, listTools, ListToolsResponse, McpServerList } from "./mcp/mcp.js";
|
||||
import { McpServerDefinition } from "./mcp/mcp.js";
|
||||
import { IMcpConfigRepo } from './mcp/repo.js';
|
||||
import { IModelConfigRepo } from './models/repo.js';
|
||||
import { ModelConfig, Provider } from "./models/models.js";
|
||||
import { IAgentsRepo } from "./agents/repo.js";
|
||||
import { Agent } from "./agents/agents.js";
|
||||
import { AskHumanResponsePayload, authorizePermission, createMessage, createRun, replyToHumanInputRequest, Run, stop, ToolPermissionAuthorizePayload } from './runs/runs.js';
|
||||
import { IRunsRepo, ListRunsResponse, CreateRunOptions } from './runs/repo.js';
|
||||
import { IBus } from './application/lib/bus.js';
|
||||
import { RunEvent } from './entities/run-events.js';
|
||||
|
||||
let id = 0;
|
||||
|
||||
const routes = new Hono()
|
||||
.get(
|
||||
'/health',
|
||||
describeRoute({
|
||||
summary: 'Health check',
|
||||
description: 'Check if the server is running',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Server is running',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
status: z.literal("ok"),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ status: 'ok' });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/mcp',
|
||||
describeRoute({
|
||||
summary: 'List MCP servers',
|
||||
description: 'List the MCP servers',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Server list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(McpServerList),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await listServers());
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/mcp/:serverName',
|
||||
describeRoute({
|
||||
summary: 'Upsert MCP server',
|
||||
description: 'Add or edit MCP server',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP server added / updated',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
validator('json', McpServerDefinition),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.upsert(c.req.valid('param').serverName, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/mcp/:serverName',
|
||||
describeRoute({
|
||||
summary: 'Delete MCP server',
|
||||
description: 'Delete a MCP server',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP server deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
await repo.delete(c.req.valid('param').serverName);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/mcp/:serverName/tools',
|
||||
describeRoute({
|
||||
summary: 'Get MCP tools',
|
||||
description: 'Get the MCP tools',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'MCP tools',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ListToolsResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('query', z.object({
|
||||
cursor: z.string().optional(),
|
||||
})),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const result = await listTools(c.req.valid('param').serverName, c.req.valid('query').cursor);
|
||||
return c.json(result);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/mcp/:serverName/tools/:toolName/execute',
|
||||
describeRoute({
|
||||
summary: 'Execute MCP tool',
|
||||
description: 'Execute a MCP tool',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Tool executed',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
result: z.any(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
serverName: z.string(),
|
||||
toolName: z.string(),
|
||||
})),
|
||||
validator('json', z.object({
|
||||
input: z.any(),
|
||||
})),
|
||||
async (c) => {
|
||||
const result = await executeTool(
|
||||
c.req.valid('param').serverName,
|
||||
c.req.valid('param').toolName,
|
||||
c.req.valid('json').input
|
||||
);
|
||||
return c.json(result);
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/models',
|
||||
describeRoute({
|
||||
summary: 'Get model config',
|
||||
description: 'Get the current model and provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Model config',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(ModelConfig),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const config = await repo.getConfig();
|
||||
return c.json(config);
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/models/providers/:providerName',
|
||||
describeRoute({
|
||||
summary: 'Upsert provider config',
|
||||
description: 'Add or update a provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Provider upserted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
providerName: z.string(),
|
||||
})),
|
||||
validator('json', Provider),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.upsert(c.req.valid('param').providerName, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/models/providers/:providerName',
|
||||
describeRoute({
|
||||
summary: 'Delete provider config',
|
||||
description: 'Delete a provider configuration',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Provider deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
providerName: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.delete(c.req.valid('param').providerName);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.put(
|
||||
'/models/default',
|
||||
describeRoute({
|
||||
summary: 'Set default model',
|
||||
description: 'Set the default provider and model',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Default set',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
const body = c.req.valid('json');
|
||||
await repo.setDefault(body.provider, body.model);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// GET /agents
|
||||
.get(
|
||||
'/agents',
|
||||
describeRoute({
|
||||
summary: 'List agents',
|
||||
description: 'List all configured agents',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agents list',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.array(Agent)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
const agents = await repo.list();
|
||||
return c.json(agents);
|
||||
}
|
||||
)
|
||||
// POST /agents/new
|
||||
.post(
|
||||
'/agents/new',
|
||||
describeRoute({
|
||||
summary: 'Create agent',
|
||||
description: 'Create a new agent',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', Agent),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.create(c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// GET /agents/<id>
|
||||
.get(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Get agent',
|
||||
description: 'Fetch a specific agent by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Agent),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
const agent = await repo.fetch(c.req.valid('param').id);
|
||||
return c.json(agent);
|
||||
}
|
||||
)
|
||||
// PUT /agents/<id>
|
||||
.put(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Update agent',
|
||||
description: 'Update an existing agent',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent updated',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
validator('json', Agent),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.update(c.req.valid('param').id, c.req.valid('json'));
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
// DELETE /agents/<id>
|
||||
.delete(
|
||||
'/agents/:id',
|
||||
describeRoute({
|
||||
summary: 'Delete agent',
|
||||
description: 'Delete an agent by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Agent deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
id: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||
await repo.delete(c.req.valid('param').id);
|
||||
return c.json({ success: true });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/runs/:runId',
|
||||
describeRoute({
|
||||
summary: 'Get run',
|
||||
description: 'Get a run by id',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Run),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
const run = await repo.fetch(c.req.valid('param').runId);
|
||||
return c.json(run);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/new',
|
||||
describeRoute({
|
||||
summary: 'Create run',
|
||||
description: 'Create a new run',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(Run),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('json', CreateRunOptions),
|
||||
async (c) => {
|
||||
const run = await createRun(c.req.valid('json'));
|
||||
return c.json(run);
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/messages/new',
|
||||
describeRoute({
|
||||
summary: 'Create a new message',
|
||||
description: 'Create a new message',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Message created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
messageId: z.string(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', z.object({
|
||||
message: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const messageId = await createMessage(c.req.valid('param').runId, c.req.valid('json').message);
|
||||
return c.json({
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/permissions/authorize',
|
||||
describeRoute({
|
||||
summary: 'Authorize permission',
|
||||
description: 'Authorize a permission',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Permission authorized',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', ToolPermissionAuthorizePayload),
|
||||
async (c) => {
|
||||
const response = await authorizePermission(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/human-input-requests/:requestId/reply',
|
||||
describeRoute({
|
||||
summary: 'Reply to human input request',
|
||||
description: 'Reply to a human input request',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Human input request replied',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', AskHumanResponsePayload),
|
||||
async (c) => {
|
||||
const response = await replyToHumanInputRequest(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/stop',
|
||||
describeRoute({
|
||||
summary: 'Stop run',
|
||||
description: 'Stop a run',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run stopped',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const response = await stop(c.req.valid('param').runId);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/stream',
|
||||
async (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
|
||||
let id = 0;
|
||||
let unsub: (() => void) | null = null;
|
||||
let aborted = false;
|
||||
|
||||
stream.onAbort(() => {
|
||||
aborted = true;
|
||||
if (unsub) {
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to your bus
|
||||
unsub = await bus.subscribe('*', async (event) => {
|
||||
if (aborted) return;
|
||||
|
||||
console.log('got ev', event);
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: "message",
|
||||
id: String(id++),
|
||||
});
|
||||
});
|
||||
|
||||
// Keep the function alive until the client disconnects
|
||||
while (!aborted) {
|
||||
await stream.sleep(1000); // any interval is fine
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
.get('/sse', async (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
while (true) {
|
||||
const message = `It is ${new Date().toISOString()}`
|
||||
await stream.writeSSE({
|
||||
data: message,
|
||||
event: 'time-update',
|
||||
id: String(id++),
|
||||
})
|
||||
await stream.sleep(1000)
|
||||
}
|
||||
})
|
||||
})
|
||||
;
|
||||
|
||||
const app = new Hono()
|
||||
.route("/", routes)
|
||||
.get(
|
||||
"/openapi.json",
|
||||
openAPIRouteHandler(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Hono",
|
||||
version: "1.0.0",
|
||||
description: "RowboatX API",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// export default app;
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
});
|
||||
|
||||
// GET /skills
|
||||
// POST /skills/new
|
||||
// GET /skills/<id>
|
||||
// PUT /skills/<id>
|
||||
// DELETE /skills/<id>
|
||||
|
||||
// GET /sse
|
||||
Loading…
Add table
Add a link
Reference in a new issue