mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-27 20:29:44 +02:00
server for rowboatx
This commit is contained in:
parent
ae877e70ae
commit
9ad6331fbc
38 changed files with 2223 additions and 1088 deletions
1
apps/cli/.gitignore
vendored
1
apps/cli/.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.vercel
|
||||||
|
|
|
||||||
|
|
@ -116,20 +116,4 @@ yargs(hideBin(process.argv))
|
||||||
modelConfig();
|
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();
|
.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": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"build": "rm -rf dist && tsc",
|
"build": "rm -rf dist && tsc",
|
||||||
"copilot": "npm run build && node dist/x.js"
|
"server": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|
@ -21,7 +21,6 @@
|
||||||
"description": "",
|
"description": "",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.9.1",
|
"@types/node": "^24.9.1",
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
@ -30,9 +29,14 @@
|
||||||
"@ai-sdk/openai": "^2.0.53",
|
"@ai-sdk/openai": "^2.0.53",
|
||||||
"@ai-sdk/openai-compatible": "^1.0.27",
|
"@ai-sdk/openai-compatible": "^1.0.27",
|
||||||
"@ai-sdk/provider": "^2.0.0",
|
"@ai-sdk/provider": "^2.0.0",
|
||||||
|
"@hono/node-server": "^1.19.6",
|
||||||
|
"@hono/standard-validator": "^0.1.5",
|
||||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||||
"@openrouter/ai-sdk-provider": "^1.2.6",
|
"@openrouter/ai-sdk-provider": "^1.2.6",
|
||||||
"ai": "^5.0.102",
|
"ai": "^5.0.102",
|
||||||
|
"awilix": "^12.0.5",
|
||||||
|
"hono": "^4.10.7",
|
||||||
|
"hono-openapi": "^1.1.1",
|
||||||
"json-schema-to-zod": "^2.6.1",
|
"json-schema-to-zod": "^2.6.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"ollama-ai-provider-v2": "^1.5.4",
|
"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 { jsonSchema, ModelMessage, modelMessageSchema } from "ai";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getModelConfig, WorkDir } from "../config/config.js";
|
import { WorkDir } from "../config/config.js";
|
||||||
import { Agent, ToolAttachment } from "../entities/agent.js";
|
import { Agent, ToolAttachment } from "./agents.js";
|
||||||
import { AssistantContentPart, AssistantMessage, Message, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessage } from "../entities/message.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 { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getProvider } from "./models.js";
|
|
||||||
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
|
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
|
||||||
import { execTool } from "./exec-tool.js";
|
import { execTool } from "../application/lib/exec-tool.js";
|
||||||
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
|
import { MessageEvent, AskHumanRequestEvent, RunEvent, ToolInvocationEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent } from "../entities/run-events.js";
|
||||||
import { BuiltinTools } from "./builtin-tools.js";
|
import { BuiltinTools } from "../application/lib/builtin-tools.js";
|
||||||
import { CopilotAgent } from "../assistant/agent.js";
|
import { CopilotAgent } from "../application/assistant/agent.js";
|
||||||
import { isBlocked } from "./command-executor.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> {
|
export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
|
||||||
switch (t.type) {
|
switch (t.type) {
|
||||||
|
|
@ -128,8 +211,8 @@ export class StreamStepMessageBuilder {
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "finish-step":
|
case "finish-step":
|
||||||
this.providerOptions = event.providerOptions;
|
this.providerOptions = event.providerOptions;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,9 +254,8 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
||||||
if (id === "copilot") {
|
if (id === "copilot") {
|
||||||
return CopilotAgent;
|
return CopilotAgent;
|
||||||
}
|
}
|
||||||
const agentPath = path.join(WorkDir, "agents", `${id}.json`);
|
const repo = container.resolve<IAgentsRepo>('agentsRepo');
|
||||||
const agent = fs.readFileSync(agentPath, "utf8");
|
return await repo.fetch(id);
|
||||||
return Agent.parse(JSON.parse(agent));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelMessage[] {
|
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 {
|
export class AgentState {
|
||||||
logger: RunLogger | null = null;
|
|
||||||
runId: string | null = null;
|
runId: string | null = null;
|
||||||
agent: z.infer<typeof Agent> | null = null;
|
agent: z.infer<typeof Agent> | null = null;
|
||||||
agentName: string;
|
agentName: string | null = null;
|
||||||
messages: z.infer<typeof MessageList> = [];
|
messages: z.infer<typeof MessageList> = [];
|
||||||
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
lastAssistantMsg: z.infer<typeof AssistantMessage> | null = null;
|
||||||
subflowStates: Record<string, AgentState> = {};
|
subflowStates: Record<string, AgentState> = {};
|
||||||
|
|
@ -276,20 +357,6 @@ export class AgentState {
|
||||||
allowedToolCallIds: Record<string, true> = {};
|
allowedToolCallIds: Record<string, true> = {};
|
||||||
deniedToolCallIds: 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>[] {
|
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||||
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
|
for (const [id, subflowState] of Object.entries(this.subflowStates)) {
|
||||||
|
|
@ -346,6 +413,9 @@ export class AgentState {
|
||||||
ingest(event: z.infer<typeof RunEvent>) {
|
ingest(event: z.infer<typeof RunEvent>) {
|
||||||
if (event.subflow.length > 0) {
|
if (event.subflow.length > 0) {
|
||||||
const { subflow, ...rest } = event;
|
const { subflow, ...rest } = event;
|
||||||
|
if (!this.subflowStates[subflow[0]]) {
|
||||||
|
this.subflowStates[subflow[0]] = new AgentState();
|
||||||
|
}
|
||||||
this.subflowStates[subflow[0]].ingest({
|
this.subflowStates[subflow[0]].ingest({
|
||||||
...rest,
|
...rest,
|
||||||
subflow: subflow.slice(1),
|
subflow: subflow.slice(1),
|
||||||
|
|
@ -353,6 +423,10 @@ export class AgentState {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
|
case "start":
|
||||||
|
this.runId = event.runId;
|
||||||
|
this.agentName = event.agentName;
|
||||||
|
break;
|
||||||
case "message":
|
case "message":
|
||||||
this.messages.push(event.message);
|
this.messages.push(event.message);
|
||||||
if (event.message.content instanceof Array) {
|
if (event.message.content instanceof Array) {
|
||||||
|
|
@ -371,9 +445,6 @@ export class AgentState {
|
||||||
this.lastAssistantMsg = event.message;
|
this.lastAssistantMsg = event.message;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "spawn-subflow":
|
|
||||||
this.subflowStates[event.toolCallId] = new AgentState(event.agentName);
|
|
||||||
break;
|
|
||||||
case "tool-permission-request":
|
case "tool-permission-request":
|
||||||
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
|
this.pendingToolPermissionRequests[event.toolCall.toolCallId] = event;
|
||||||
break;
|
break;
|
||||||
|
|
@ -406,27 +477,33 @@ export class AgentState {
|
||||||
break;
|
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> {
|
export async function* streamAgent({
|
||||||
// get model config
|
state,
|
||||||
const modelConfig = await getModelConfig();
|
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) {
|
if (!modelConfig) {
|
||||||
throw new Error("Model config not found");
|
throw new Error("Model config not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// set up agent
|
// set up agent
|
||||||
const agent = await loadAgent(state.agentName);
|
const agent = await loadAgent(state.agentName!);
|
||||||
|
|
||||||
// set up tools
|
// set up tools
|
||||||
const tools = await buildTools(agent);
|
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);
|
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
|
||||||
let loopCounter = 0;
|
let loopCounter = 0;
|
||||||
|
|
||||||
|
console.log('here');
|
||||||
|
|
||||||
|
async function pendingMsgs() {
|
||||||
|
const pendingMsgs = [];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// console.error(`loop counter: ${loopCounter++}`)
|
// 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];
|
const lastMessage = state.messages[state.messages.length - 1];
|
||||||
if (lastMessage
|
if (lastMessage
|
||||||
&& lastMessage.role === "assistant"
|
&& 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")
|
|| !lastMessage.content.some(part => part.type === "tool-call")
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// console.error("Nothing to do, exiting (a.)")
|
let pending = 0;
|
||||||
return;
|
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
|
// 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 tool has been denied, deny
|
||||||
if (state.deniedToolCallIds[toolCallId]) {
|
if (state.deniedToolCallIds[toolCallId]) {
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
role: "tool",
|
role: "tool",
|
||||||
|
|
@ -480,7 +586,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
}
|
}
|
||||||
|
|
||||||
// execute approved tool
|
// execute approved tool
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
type: "tool-invocation",
|
type: "tool-invocation",
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
input: JSON.stringify(toolCall.arguments),
|
input: JSON.stringify(toolCall.arguments),
|
||||||
|
|
@ -489,8 +596,14 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
let result: any = null;
|
let result: any = null;
|
||||||
if (agent.tools![toolCall.toolName].type === "agent") {
|
if (agent.tools![toolCall.toolName].type === "agent") {
|
||||||
let subflowState = state.subflowStates[toolCallId];
|
let subflowState = state.subflowStates[toolCallId];
|
||||||
for await (const event of streamAgent(subflowState)) {
|
for await (const event of streamAgent({
|
||||||
yield* state.ingestAndLogAndYield({
|
state: subflowState,
|
||||||
|
idGenerator,
|
||||||
|
runId,
|
||||||
|
messageQueue,
|
||||||
|
modelConfigRepo,
|
||||||
|
})) {
|
||||||
|
yield *processEvent({
|
||||||
...event,
|
...event,
|
||||||
subflow: [toolCallId, ...event.subflow],
|
subflow: [toolCallId, ...event.subflow],
|
||||||
});
|
});
|
||||||
|
|
@ -508,13 +621,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
toolCallId: toolCall.toolCallId,
|
toolCallId: toolCall.toolCallId,
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
};
|
};
|
||||||
yield* state.ingestAndLogAndYield({
|
yield* processEvent({
|
||||||
|
runId,
|
||||||
type: "tool-result",
|
type: "tool-result",
|
||||||
toolName: toolCall.toolName,
|
toolName: toolCall.toolName,
|
||||||
result: result,
|
result: result,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message: resultMsg,
|
message: resultMsg,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -529,10 +645,29 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
}
|
}
|
||||||
|
|
||||||
// if current message state isn't runnable, exit
|
// if current message state isn't runnable, exit
|
||||||
|
/*
|
||||||
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
|
if (state.messages.length === 0 || state.messages[state.messages.length - 1].role === "assistant") {
|
||||||
// console.error("current message state isn't runnable, exiting (c.)")
|
// console.error("current message state isn't runnable, exiting (c.)")
|
||||||
return;
|
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.
|
// run one LLM turn.
|
||||||
// stream agent response and build message
|
// stream agent response and build message
|
||||||
|
|
@ -544,7 +679,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
tools,
|
tools,
|
||||||
)) {
|
)) {
|
||||||
messageBuilder.ingest(event);
|
messageBuilder.ingest(event);
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
type: "llm-stream-event",
|
type: "llm-stream-event",
|
||||||
event: event,
|
event: event,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -553,7 +689,9 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
|
|
||||||
// build and emit final message from agent response
|
// build and emit final message from agent response
|
||||||
const message = messageBuilder.get();
|
const message = messageBuilder.get();
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message,
|
message,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -565,7 +703,8 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
const underlyingTool = agent.tools![part.toolName];
|
const underlyingTool = agent.tools![part.toolName];
|
||||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
type: "ask-human-request",
|
type: "ask-human-request",
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
query: part.arguments.question,
|
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 (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||||
// if command is blocked, then seek permission
|
// if command is blocked, then seek permission
|
||||||
if (isBlocked(part.arguments.command)) {
|
if (isBlocked(part.arguments.command)) {
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
type: "tool-permission-request",
|
type: "tool-permission-request",
|
||||||
toolCall: part,
|
toolCall: part,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
|
|
@ -583,13 +723,16 @@ export async function* streamAgent(state: AgentState): AsyncGenerator<z.infer<ty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
type: "spawn-subflow",
|
type: "spawn-subflow",
|
||||||
agentName: underlyingTool.name,
|
agentName: underlyingTool.name,
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
subflow: [],
|
subflow: [],
|
||||||
});
|
});
|
||||||
yield* state.ingestAndLogAndYield({
|
yield *processEvent({
|
||||||
|
runId,
|
||||||
|
messageId: await idGenerator.next(),
|
||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
role: "user",
|
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 { StreamRenderer } from "./application/lib/stream-renderer.js";
|
||||||
import { stdin as input, stdout as output } from "node:process";
|
import { stdin as input, stdout as output } from "node:process";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { promises as fsp } from "fs";
|
import { promises as fsp } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js";
|
import { WorkDir } from "./config/config.js";
|
||||||
import { RunEvent } from "./application/entities/run-events.js";
|
import { RunEvent } from "./entities/run-events.js";
|
||||||
import { createInterface, Interface } from "node:readline/promises";
|
import { createInterface, Interface } from "node:readline/promises";
|
||||||
import { ToolCallPart } from "./application/entities/message.js";
|
import { ToolCallPart } from "./entities/message.js";
|
||||||
import { Agent } from "./application/entities/agent.js";
|
import { Agent } from "./agents/agents.js";
|
||||||
import { McpServerConfig, McpServerDefinition } from "./application/entities/mcp.js";
|
import { McpServerConfig } from "./mcp/mcp.js";
|
||||||
import { Example } from "./application/entities/example.js";
|
import { McpServerDefinition } from "./mcp/mcp.js";
|
||||||
|
import { Example } from "./entities/example.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Flavor } from "./application/entities/models.js";
|
import { Flavor } from "./models/models.js";
|
||||||
import { examples } from "./examples/index.js";
|
import { examples } from "./examples/index.js";
|
||||||
import { modelMessageSchema } from "ai";
|
import container from "./di/container.js";
|
||||||
|
import { IModelConfigRepo } from "./models/repo.js";
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGreeting() {
|
function renderGreeting() {
|
||||||
const logo = `
|
const logo = `
|
||||||
|
|
@ -61,12 +42,8 @@ export async function app(opts: {
|
||||||
input?: string;
|
input?: string;
|
||||||
noInteractive?: boolean;
|
noInteractive?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// check if model config is required
|
throw new Error("Not implemented");
|
||||||
const c = await getModelConfig();
|
/*
|
||||||
if (!c) {
|
|
||||||
await modelConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderer = new StreamRenderer();
|
const renderer = new StreamRenderer();
|
||||||
const state = new AgentState(opts.agent, opts.runId);
|
const state = new AgentState(opts.agent, opts.runId);
|
||||||
|
|
||||||
|
|
@ -172,6 +149,7 @@ export async function app(opts: {
|
||||||
} finally {
|
} finally {
|
||||||
rl?.close();
|
rl?.close();
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getToolCallPermission(
|
async function getToolCallPermission(
|
||||||
|
|
@ -219,7 +197,8 @@ async function getUserInput(
|
||||||
|
|
||||||
export async function modelConfig() {
|
export async function modelConfig() {
|
||||||
// load existing model config
|
// load existing model config
|
||||||
const config = await getModelConfig();
|
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||||
|
const config = await repo.getConfig();
|
||||||
|
|
||||||
const rl = createInterface({ input, output });
|
const rl = createInterface({ input, output });
|
||||||
try {
|
try {
|
||||||
|
|
@ -333,14 +312,7 @@ export async function modelConfig() {
|
||||||
);
|
);
|
||||||
const model = modelAns.trim() || modelDefault;
|
const model = modelAns.trim() || modelDefault;
|
||||||
|
|
||||||
const newConfig = {
|
await repo.setDefault(providerName!, model);
|
||||||
providers: { ...(config?.providers || {}) },
|
|
||||||
defaults: {
|
|
||||||
provider: providerName!,
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await updateModelConfig(newConfig as any);
|
|
||||||
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
|
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -391,24 +363,13 @@ export async function modelConfig() {
|
||||||
);
|
);
|
||||||
const model = modelAns.trim() || modelDefault;
|
const model = modelAns.trim() || modelDefault;
|
||||||
|
|
||||||
const mergedProviders = {
|
await repo.upsert(providerName, {
|
||||||
...(config?.providers || {}),
|
flavor: selectedFlavor,
|
||||||
[providerName]: {
|
apiKey,
|
||||||
flavor: selectedFlavor,
|
baseURL,
|
||||||
...(apiKey ? { apiKey } : {}),
|
headers,
|
||||||
...(baseURL ? { baseURL } : {}),
|
});
|
||||||
...(headers ? { headers } : {}),
|
await repo.setDefault(providerName, model);
|
||||||
},
|
|
||||||
};
|
|
||||||
const newConfig = {
|
|
||||||
providers: mergedProviders,
|
|
||||||
defaults: {
|
|
||||||
provider: providerName,
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateModelConfig(newConfig as any);
|
|
||||||
renderCurrentModel(providerName, selectedFlavor, model);
|
renderCurrentModel(providerName, selectedFlavor, model);
|
||||||
console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`);
|
console.log(`Configuration written to ${WorkDir}/config/models.json. You can also edit this file manually`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Agent, ToolAttachment } from "../entities/agent.js";
|
import { Agent, ToolAttachment } from "../../agents/agents.js";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { CopilotInstructions } from "./instructions.js";
|
import { CopilotInstructions } from "./instructions.js";
|
||||||
import { BuiltinTools } from "../lib/builtin-tools.js";
|
import { BuiltinTools } from "../lib/builtin-tools.js";
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { skillCatalog } from "./skills/index.js";
|
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.
|
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 { z, ZodType } from "zod";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as path from "path";
|
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 { 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 { 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({
|
const BuiltinToolsSchema = z.record(z.string(), z.object({
|
||||||
description: z.string(),
|
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.',
|
description: 'Add or update an MCP server in the configuration with validation. This ensures the server definition is valid before saving.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
serverName: z.string().describe('Name/alias for the MCP server'),
|
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'),
|
config: McpServerDefinition,
|
||||||
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)'),
|
|
||||||
}),
|
}),
|
||||||
execute: async ({ serverName, serverType, command, args, env, url, headers }: {
|
execute: async ({ serverName, config }: {
|
||||||
serverName: string;
|
serverName: string;
|
||||||
serverType: 'stdio' | 'http';
|
config: z.infer<typeof McpServerDefinition>;
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
url?: string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
// Build server definition based on type
|
const validationResult = McpServerDefinition.safeParse(config);
|
||||||
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);
|
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Server definition failed validation. Check the errors below.',
|
message: 'Server definition failed validation. Check the errors below.',
|
||||||
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
|
validationErrors: validationResult.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`),
|
||||||
providedDefinition: serverDef,
|
providedDefinition: config,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read existing config
|
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||||
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
|
await repo.upsert(serverName, config);
|
||||||
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');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `MCP server '${serverName}' ${isUpdate ? 'updated' : 'added'} successfully`,
|
|
||||||
serverName,
|
serverName,
|
||||||
serverType,
|
|
||||||
isUpdate,
|
|
||||||
configuration: validationResult.data,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
error: `Failed to update MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
message: `Failed to add MCP server: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -421,47 +344,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||||
listMcpServers: {
|
listMcpServers: {
|
||||||
description: 'List all available MCP servers from the configuration',
|
description: 'List all available MCP servers from the configuration',
|
||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async (): Promise<{ success: boolean, servers: any[], count: number, message: string }> => {
|
execute: async () => {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
|
const result = await listServers();
|
||||||
|
|
||||||
// Check if config exists
|
|
||||||
try {
|
|
||||||
await fs.access(configPath);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
servers: [],
|
|
||||||
count: 0,
|
|
||||||
message: 'No MCP servers configured yet',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = await fs.readFile(configPath, 'utf-8');
|
|
||||||
const config = JSON.parse(content);
|
|
||||||
|
|
||||||
const servers = Object.keys(config.mcpServers || {}).map(name => {
|
|
||||||
const server = config.mcpServers[name];
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
type: 'command' in server ? 'stdio' : 'http',
|
|
||||||
command: server.command,
|
|
||||||
url: server.url,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
result,
|
||||||
servers,
|
count: Object.keys(result.mcpServers).length,
|
||||||
count: servers.length,
|
|
||||||
message: `Found ${servers.length} MCP server(s)`,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
error: `Failed to list MCP servers: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
servers: [],
|
|
||||||
count: 0,
|
|
||||||
message: `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',
|
description: 'List all available tools from a specific MCP server',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
serverName: z.string().describe('Name of the MCP server to query'),
|
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 {
|
try {
|
||||||
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
|
const result = await listTools(serverName, cursor);
|
||||||
const content = await fs.readFile(configPath, 'utf-8');
|
|
||||||
const config = JSON.parse(content);
|
|
||||||
|
|
||||||
const mcpConfig = config.mcpServers[serverName];
|
|
||||||
if (!mcpConfig) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: `MCP server '${serverName}' not found in configuration`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create transport based on config type
|
|
||||||
let transport;
|
|
||||||
if ('command' in mcpConfig) {
|
|
||||||
transport = new StdioClientTransport({
|
|
||||||
command: mcpConfig.command,
|
|
||||||
args: mcpConfig.args || [],
|
|
||||||
env: mcpConfig.env || {},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
|
|
||||||
} catch {
|
|
||||||
transport = new SSEClientTransport(new URL(mcpConfig.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and connect client
|
|
||||||
const client = new Client({
|
|
||||||
name: 'rowboat-copilot',
|
|
||||||
version: '1.0.0',
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// List available tools
|
|
||||||
const toolsList = await client.listTools();
|
|
||||||
|
|
||||||
// Close connection
|
|
||||||
client.close();
|
|
||||||
transport.close();
|
|
||||||
|
|
||||||
const tools = toolsList.tools.map((t: any) => ({
|
|
||||||
name: t.name,
|
|
||||||
description: t.description || 'No description',
|
|
||||||
inputSchema: t.inputSchema,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
|
||||||
serverName,
|
serverName,
|
||||||
tools,
|
result,
|
||||||
count: tools.length,
|
count: result.tools.length,
|
||||||
message: `Found ${tools.length} tool(s) in MCP server '${serverName}'`,
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
error: `Failed to list MCP tools: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
message: `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.'),
|
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> }) => {
|
execute: async ({ serverName, toolName, arguments: args = {} }: { serverName: string, toolName: string, arguments?: Record<string, any> }) => {
|
||||||
let transport: any;
|
|
||||||
let client: any;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(BASE_DIR, 'config', 'mcp.json');
|
const result = await executeTool(serverName, toolName, args);
|
||||||
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();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
serverName,
|
serverName,
|
||||||
toolName,
|
toolName,
|
||||||
result: result.content,
|
result,
|
||||||
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
|
message: `Successfully executed tool '${toolName}' from server '${serverName}'`,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ensure cleanup
|
|
||||||
try {
|
|
||||||
if (client) await client.close();
|
|
||||||
if (transport) transport.close();
|
|
||||||
} catch (cleanupError) {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
error: `Failed to execute MCP tool: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
serverName,
|
|
||||||
toolName,
|
|
||||||
hint: 'Use listMcpTools to verify the tool exists and check its schema. Ensure all required parameters are provided in the arguments field.',
|
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 { exec, execSync } from 'child_process';
|
||||||
import { promisify } from 'util';
|
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 execPromise = promisify(exec);
|
||||||
const COMMAND_SPLIT_REGEX = /(?:\|\||&&|;|\||\n)/;
|
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 { 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 { 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> {
|
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: any): Promise<any> {
|
||||||
// load mcp configuration from the tool
|
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
|
||||||
const mcpConfig = McpServers[agentTool.mcpServerName];
|
|
||||||
if (!mcpConfig) {
|
|
||||||
throw new Error(`MCP server ${agentTool.mcpServerName} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// create transport
|
|
||||||
let transport: Transport;
|
|
||||||
if ("command" in mcpConfig) {
|
|
||||||
transport = new StdioClientTransport({
|
|
||||||
command: mcpConfig.command,
|
|
||||||
args: mcpConfig.args,
|
|
||||||
env: mcpConfig.env,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// first try streamable http transport
|
|
||||||
try {
|
|
||||||
transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url));
|
|
||||||
} catch (error) {
|
|
||||||
// if that fails, try sse transport
|
|
||||||
transport = new SSEClientTransport(new URL(mcpConfig.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!transport) {
|
|
||||||
throw new Error(`No transport found for ${agentTool.mcpServerName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// create client
|
|
||||||
const client = new Client({
|
|
||||||
name: 'rowboatx',
|
|
||||||
version: '1.0.0',
|
|
||||||
});
|
|
||||||
await client.connect(transport);
|
|
||||||
|
|
||||||
// call tool
|
|
||||||
const result = await client.callTool({ name: agentTool.name, arguments: input });
|
|
||||||
client.close();
|
|
||||||
transport.close();
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
class RunIdGenerator {
|
export interface IMonotonicallyIncreasingIdGenerator {
|
||||||
|
next(): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IdGen implements IMonotonicallyIncreasingIdGenerator {
|
||||||
private lastMs = 0;
|
private lastMs = 0;
|
||||||
private seq = 0;
|
private seq = 0;
|
||||||
private readonly pid: string;
|
private readonly pid: string;
|
||||||
private readonly hostTag: string;
|
private readonly hostTag: string;
|
||||||
|
|
||||||
constructor(hostTag: string = "") {
|
constructor() {
|
||||||
this.pid = String(process.pid).padStart(7, "0");
|
this.pid = String(process.pid).padStart(7, "0");
|
||||||
this.hostTag = hostTag ? `-${hostTag}` : "";
|
this.hostTag = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an ISO8601-based, lexicographically sortable id string.
|
* Returns an ISO8601-based, lexicographically sortable id string.
|
||||||
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
|
* Example: 2025-11-11T04-36-29Z-0001234-h1-000
|
||||||
*/
|
*/
|
||||||
next(): string {
|
async next(): Promise<string> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
|
const ms = now >= this.lastMs ? now : this.lastMs; // monotonic clamp
|
||||||
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
|
this.seq = ms === this.lastMs ? this.seq + 1 : 0;
|
||||||
|
|
@ -28,5 +32,3 @@ class RunIdGenerator {
|
||||||
return `${iso}-${this.pid}${this.hostTag}-${seqStr}`;
|
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 { z } from "zod";
|
||||||
import { RunEvent } from "../entities/run-events.js";
|
import { RunEvent } from "../../entities/run-events.js";
|
||||||
import { LlmStepStreamEvent } from "../entities/llm-step-events.js";
|
import { LlmStepStreamEvent } from "../../entities/llm-step-events.js";
|
||||||
|
|
||||||
export interface StreamRendererOptions {
|
export interface StreamRendererOptions {
|
||||||
showHeaders?: boolean;
|
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 z from "zod"
|
||||||
import { Agent } from "./agent.js"
|
import { Agent } from "../agents/agents.js"
|
||||||
import { McpServerDefinition } from "./mcp.js"
|
import { McpServerDefinition } from "../mcp/mcp.js";
|
||||||
|
|
||||||
export const Example = z.object({
|
export const Example = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
import { LlmStepStreamEvent } from "./llm-step-events.js";
|
||||||
import { Message, ToolCallPart } from "./message.js";
|
import { Message, ToolCallPart } from "./message.js";
|
||||||
import { Agent } from "./agent.js";
|
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
const BaseRunEvent = z.object({
|
const BaseRunEvent = z.object({
|
||||||
|
runId: z.string(),
|
||||||
ts: z.iso.datetime().optional(),
|
ts: z.iso.datetime().optional(),
|
||||||
subflow: z.array(z.string()),
|
subflow: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StartEvent = BaseRunEvent.extend({
|
export const StartEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("start"),
|
type: z.literal("start"),
|
||||||
runId: z.string(),
|
|
||||||
agentName: z.string(),
|
agentName: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -27,6 +26,7 @@ export const LlmStreamEvent = BaseRunEvent.extend({
|
||||||
|
|
||||||
export const MessageEvent = BaseRunEvent.extend({
|
export const MessageEvent = BaseRunEvent.extend({
|
||||||
type: z.literal("message"),
|
type: z.literal("message"),
|
||||||
|
messageId: z.string(),
|
||||||
message: Message,
|
message: Message,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import twitterPodcast from './twitter-podcast.json' with { type: 'json' };
|
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';
|
import z from 'zod';
|
||||||
|
|
||||||
export const examples: Record<string, z.infer<typeof Example>> = {
|
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 { createOllama } from "ollama-ai-provider-v2";
|
||||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
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> = {};
|
const providerMap: Record<string, ProviderV2> = {};
|
||||||
|
|
||||||
export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
||||||
// get model conf
|
// get model conf
|
||||||
const modelConfig = await getModelConfig();
|
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||||
|
const modelConfig = await repo.getConfig();
|
||||||
if (!modelConfig) {
|
if (!modelConfig) {
|
||||||
throw new Error("Model config not found");
|
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