rowboat/apps/cli/src/app.ts

384 lines
14 KiB
TypeScript
Raw Normal View History

import { AgentState, streamAgent } from "./application/lib/agent.js";
2025-10-28 13:17:06 +05:30
import { StreamRenderer } from "./application/lib/stream-renderer.js";
2025-11-16 20:58:31 +05:30
import { stdin as input, stdout as output } from "node:process";
import fs from "fs";
import path from "path";
2025-11-20 16:41:41 +05:30
import { WorkDir, getModelConfig, updateModelConfig } from "./application/config/config.js";
import { RunEvent } from "./application/entities/run-events.js";
2025-11-16 20:58:31 +05:30
import { createInterface, Interface } from "node:readline/promises";
import { ToolCallPart } from "./application/entities/message.js";
2025-11-16 20:58:31 +05:30
import { z } from "zod";
2025-10-28 13:17:06 +05:30
2025-11-18 20:38:14 +05:30
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();
}
}
2025-11-18 22:53:50 +05:30
function renderGreeting() {
const logo = `
$$\\ $$\\
$$ | $$ |
$$$$$$\\ $$$$$$\\ $$\\ $$\\ $$\\ $$$$$$$\\ $$$$$$\\ $$$$$$\\ $$$$$$\\ $$\\ $$\\
$$ __$$\\ $$ __$$\\ $$ | $$ | $$ |$$ __$$\\ $$ __$$\\ \\____$$\\_$$ _| \\$$\\ $$ |
$$ | \\__|$$ / $$ |$$ | $$ | $$ |$$ | $$ |$$ / $$ | $$$$$$$ | $$ | \\$$$$ /
$$ | $$ | $$ |$$ | $$ | $$ |$$ | $$ |$$ | $$ |$$ __$$ | $$ |$$\\ $$ $$<
$$ | \\$$$$$$ |\\$$$$$\\$$$$ |$$$$$$$ |\\$$$$$$ |\\$$$$$$$ | \\$$$$ |$$ /\\$$\\
\\__| \\______/ \\_____\\____/ \\_______/ \\______/ \\_______| \\____/ \\__/ \\__|
`;
console.log(logo);
console.log("\nHow can i help you today?");
}
2025-11-15 01:51:22 +05:30
export async function app(opts: {
agent: string;
runId?: string;
input?: string;
2025-11-16 20:58:31 +05:30
noInteractive?: boolean;
2025-11-15 01:51:22 +05:30
}) {
2025-11-20 16:41:41 +05:30
// check if model config is required
const c = await getModelConfig();
if (!c) {
await modelConfig();
}
2025-10-28 13:17:06 +05:30
const renderer = new StreamRenderer();
2025-11-18 20:38:14 +05:30
const state = new AgentState(opts.agent, opts.runId);
2025-11-16 20:58:31 +05:30
2025-11-18 22:53:50 +05:30
if (opts.agent === "copilot" && !opts.runId) {
renderGreeting();
}
2025-11-16 20:58:31 +05:30
// load existing and assemble state if required
let runId = opts.runId;
if (runId) {
console.error("loading run", runId);
let stream: fs.ReadStream | null = null;
let rl: Interface | null = null;
try {
const logFile = path.join(WorkDir, "runs", `${runId}.jsonl`);
stream = fs.createReadStream(logFile, { encoding: "utf8" });
rl = createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
if (line.trim() === "") {
continue;
}
const parsed = JSON.parse(line);
const event = RunEvent.parse(parsed);
state.ingest(event);
2025-11-16 20:58:31 +05:30
}
} finally {
stream?.close();
}
}
let rl: Interface | null = null;
if (!opts.noInteractive) {
rl = createInterface({ input, output });
}
2025-11-18 20:38:14 +05:30
let inputConsumed = false;
2025-11-16 20:58:31 +05:30
try {
while (true) {
// ask for pending tool permissions
for (const perm of Object.values(state.getPendingPermissions())) {
2025-11-18 20:38:14 +05:30
if (opts.noInteractive) {
return;
}
const response = await getToolCallPermission(perm.toolCall, rl!);
state.ingestAndLog({
type: "tool-permission-response",
response,
toolCallId: perm.toolCall.toolCallId,
subflow: perm.subflow,
});
2025-11-16 20:58:31 +05:30
}
// ask for pending human input
for (const ask of Object.values(state.getPendingAskHumans())) {
2025-11-18 20:38:14 +05:30
if (opts.noInteractive) {
return;
}
const response = await getAskHumanResponse(ask.query, rl!);
state.ingestAndLog({
type: "ask-human-response",
response,
toolCallId: ask.toolCallId,
subflow: ask.subflow,
});
2025-11-16 20:58:31 +05:30
}
// run one turn
for await (const event of streamAgent(state)) {
2025-11-16 20:58:31 +05:30
renderer.render(event);
if (event?.type === "error") {
process.exitCode = 1;
}
}
// if nothing pending, get user input
if (state.getPendingPermissions().length === 0 && state.getPendingAskHumans().length === 0) {
2025-11-18 20:38:14 +05:30
if (opts.input && !inputConsumed) {
state.ingestAndLog({
type: "message",
message: {
role: "user",
content: opts.input,
},
subflow: [],
});
inputConsumed = true;
continue;
}
if (opts.noInteractive) {
return;
}
const response = await getUserInput(rl!);
state.ingestAndLog({
type: "message",
message: {
role: "user",
content: response,
},
subflow: [],
});
2025-11-16 20:58:31 +05:30
}
2025-11-11 12:32:46 +05:30
}
2025-11-16 20:58:31 +05:30
} finally {
rl?.close();
2025-10-28 13:17:06 +05:30
}
2025-11-18 02:28:49 +05:30
}
async function getToolCallPermission(
call: z.infer<typeof ToolCallPart>,
rl: Interface,
): Promise<"approve" | "deny"> {
const question = `Do you want to allow running the following tool: ${call.toolName}?:
Tool name: ${call.toolName}
Tool arguments: ${JSON.stringify(call.arguments)}
Choices: y/n/a/d:
- y: approve
- n: deny
`;
const input = await rl.question(question);
if (input.toLowerCase() === "y") return "approve";
if (input.toLowerCase() === "n") return "deny";
return "deny";
}
async function getAskHumanResponse(
query: string,
rl: Interface,
): Promise<string> {
const input = await rl.question(`The agent is asking for your help with the following query:
Question: ${query}
Please respond to the question.
`);
return input;
}
async function getUserInput(
rl: Interface,
): Promise<string> {
const input = await rl.question("You: ");
if (["quit", "exit", "q"].includes(input.toLowerCase().trim())) {
console.error("Bye!");
process.exit(0);
2025-11-18 02:28:49 +05:30
}
return input;
2025-11-20 16:41:41 +05:30
}
export async function modelConfig() {
// load existing model config
const config = await getModelConfig();
const rl = createInterface({ input, output });
try {
const flavors = [
"openai",
"anthropic",
"google",
"ollama",
"openai-compatible",
"openrouter",
] as const;
const defaultBaseUrls: Record<(typeof flavors)[number], string> = {
openai: "https://api.openai.com/v1",
anthropic: "https://api.anthropic.com/v1",
google: "https://generativelanguage.googleapis.com",
ollama: "http://localhost:11434",
"openai-compatible": "http://localhost:8080/v1",
openrouter: "https://openrouter.ai/api/v1",
};
const defaultModels: Record<(typeof flavors)[number], string> = {
openai: "gpt-5.1",
anthropic: "claude-3.5-sonnet",
google: "gemini-1.5-pro",
ollama: "llama3.1",
"openai-compatible": "gpt-4o",
openrouter: "openrouter/auto",
};
const currentProvider = config?.defaults?.provider;
const currentModel = config?.defaults?.model;
const currentProviderConfig = currentProvider ? config?.providers?.[currentProvider] : undefined;
if (config) {
console.log("Currently using:");
console.log(`- provider: ${currentProvider || "none"}${currentProviderConfig?.flavor ? ` (${currentProviderConfig.flavor})` : ""}`);
console.log(`- model: ${currentModel || "none"}`);
console.log("");
}
const flavorPromptLines = flavors
.map((f, idx) => ` ${idx + 1}. ${f}`)
.join("\n");
const flavorAnswer = await rl.question(
`Select a provider type:\n${flavorPromptLines}\nEnter number or name` +
(currentProvider ? ` [${currentProvider}]` : "") +
": ",
);
let selectedFlavorRaw = flavorAnswer.trim();
let selectedFlavor: (typeof flavors)[number] | null = null;
if (selectedFlavorRaw === "" && currentProvider && (flavors as readonly string[]).includes(currentProvider)) {
selectedFlavor = currentProvider as (typeof flavors)[number];
} else if (/^\d+$/.test(selectedFlavorRaw)) {
const idx = parseInt(selectedFlavorRaw, 10) - 1;
if (idx >= 0 && idx < flavors.length) {
selectedFlavor = flavors[idx];
}
} else if ((flavors as readonly string[]).includes(selectedFlavorRaw)) {
selectedFlavor = selectedFlavorRaw as (typeof flavors)[number];
}
if (!selectedFlavor) {
console.error("Invalid selection. Exiting.");
return;
}
const existingAliases = Object.keys(config?.providers || {}).filter(
(name) => config?.providers?.[name]?.flavor === selectedFlavor,
);
let providerName: string | null = null;
let chooseMode: "existing" | "add" = "add";
if (existingAliases.length > 0) {
const listLines = existingAliases
.map((alias, idx) => ` ${idx + 1}. use existing: ${alias}`)
.join("\n");
const addIndex = existingAliases.length + 1;
const providerSelect = await rl.question(
`Found existing providers for ${selectedFlavor}:\n${listLines}\n ${addIndex}. add new\nEnter number or name/alias [${addIndex}]: `,
);
const sel = providerSelect.trim();
if (sel === "" || sel.toLowerCase() === "add" || sel.toLowerCase() === "new") {
chooseMode = "add";
} else if (/^\d+$/.test(sel)) {
const idx = parseInt(sel, 10) - 1;
if (idx >= 0 && idx < existingAliases.length) {
providerName = existingAliases[idx];
chooseMode = "existing";
} else if (idx === existingAliases.length) {
chooseMode = "add";
} else {
console.error("Invalid selection. Exiting.");
return;
}
} else if (existingAliases.includes(sel)) {
providerName = sel;
chooseMode = "existing";
} else {
console.error("Invalid selection. Exiting.");
return;
}
}
if (chooseMode === "existing" && !providerName) {
console.error("No provider selected. Exiting.");
return;
}
if (chooseMode === "existing") {
const modelDefault =
currentProvider === providerName && currentModel
? currentModel
: defaultModels[selectedFlavor];
const modelAns = await rl.question(
`Specify model for ${selectedFlavor} [${modelDefault}]: `,
);
const model = modelAns.trim() || modelDefault;
const newConfig = {
providers: { ...(config?.providers || {}) },
defaults: {
provider: providerName!,
model,
},
};
await updateModelConfig(newConfig as any);
console.log(`Model configuration updated. Provider set to '${providerName}'.`);
return;
}
const providerNameAns = await rl.question(
`Enter a name/alias for this provider [${selectedFlavor}]: `,
);
providerName = providerNameAns.trim() || selectedFlavor;
const baseUrlDefault = defaultBaseUrls[selectedFlavor] || "";
const baseUrlAns = await rl.question(
`Enter baseURL for ${selectedFlavor} [${baseUrlDefault}]: `,
);
const baseURL = (baseUrlAns.trim() || baseUrlDefault) || undefined;
const apiKeyAns = await rl.question(
`Enter API key for ${selectedFlavor} (leave blank to skip): `,
);
const apiKey = apiKeyAns.trim() || undefined;
const modelDefault = defaultModels[selectedFlavor];
const modelAns = await rl.question(
`Specify model for ${selectedFlavor} [${modelDefault}]: `,
);
const model = modelAns.trim() || modelDefault;
const mergedProviders = {
...(config?.providers || {}),
[providerName]: {
flavor: selectedFlavor,
...(apiKey ? { apiKey } : {}),
...(baseURL ? { baseURL } : {}),
},
};
const newConfig = {
providers: mergedProviders,
defaults: {
provider: providerName,
model,
},
};
await updateModelConfig(newConfig as any);
console.log(`Model configuration updated. Provider '${providerName}' ${config?.providers?.[providerName] ? "overwritten" : "added"}.`);
} finally {
rl.close();
}
2025-11-15 01:51:22 +05:30
}