open-swe/apps/cli/src/logger.ts
Brace Sproul 504be7c563
fix: Rename from open-swe to openswe (#809)
* fix: Rename from open-swe to openswe

* del tmp file, format and lint
2025-08-27 19:33:26 +00:00

501 lines
15 KiB
TypeScript

import {
coerceMessageLikeToMessage,
ToolMessage,
isAIMessage,
isHumanMessage,
isToolMessage,
} from "@langchain/core/messages";
import { getMessageContentString } from "@openswe/shared/messages";
import { createWriteTechnicalNotesToolFields } from "@openswe/shared/open-swe/tools";
export type ToolCall = {
name: string;
args: Record<string, any>;
id?: string;
type?: "tool_call";
};
interface LogChunk {
event: string;
data: any;
ops?: Array<{ value: string }>;
}
/**
* Create a simple diff between old and new strings
*/
function createSimpleDiff(oldString: string, newString: string): string[] {
const logs: string[] = [];
if (!oldString && newString) {
const lines = newString.split("\n").slice(0, 10);
lines.forEach((line) => logs.push(`+ ${line}`));
if (newString.split("\n").length > 10) {
logs.push(`+ ... (${newString.split("\n").length - 10} more lines)`);
}
return logs;
}
if (!newString) {
oldString.split("\n").forEach((line) => logs.push(`- ${line}`));
return logs;
}
const oldLines = oldString.split("\n");
const newLines = newString.split("\n");
const removedLines = oldLines.filter(
(oldLine) => !newLines.some((newLine) => newLine === oldLine),
);
const addedLines = newLines.filter(
(newLine) => !oldLines.some((oldLine) => oldLine === newLine),
);
removedLines.forEach((line) => logs.push(`- ${line}`));
addedLines.forEach((line) => logs.push(`+ ${line}`));
return logs;
}
/**
* Format a tool call arguments into a clean, readable string
*/
function formatToolCallArgs(tool: ToolCall): string {
const toolName = tool.name || "unknown tool";
if (!tool.args) return toolName;
switch (toolName.toLowerCase()) {
case "shell":
case "execute_bash": {
let command = "";
if (Array.isArray(tool.args.command)) {
command = tool.args.command.join(" ");
} else {
command = tool.args.command || "";
}
// Truncate long commands (more than 160 characters)
if (command.length > 160) {
return `${toolName}: ${command.substring(0, 160)}...`;
}
return `${toolName}: ${command}`;
}
case "write_file": {
const filePath = tool.args.file_path || "";
const content = tool.args.content || "";
const lineCount = content.split("\n").length;
return `${toolName}: ${filePath} (${lineCount} lines)`;
}
case "read_file": {
const filePath = tool.args.file_path || "";
return `${toolName}: ${filePath}`;
}
case "edit_file": {
const filePath = tool.args.file_path || "";
return `${toolName}: ${filePath}`;
}
case "http_request": {
const method = tool.args.method || "GET";
const url = tool.args.url || "";
return `${toolName}: ${method} ${url}`;
}
case "web_search": {
const query = tool.args.query || "";
return `${toolName}: "${query}"`;
}
case "grep": {
const pattern = tool.args.pattern || "";
const path = tool.args.path || "";
return `${toolName}: "${pattern}"${path ? ` in ${path}` : ""}`;
}
case "glob": {
const pattern = tool.args.pattern || "";
const path = tool.args.path || "";
return `${toolName}: ${pattern}${path ? ` in ${path}` : ""}`;
}
case "view": {
return `${toolName}: ${tool.args.path || ""}`;
}
case "ls": {
const path = tool.args.path || "";
return `${toolName}: ${path}`;
}
case "str_replace_based_edit_tool": {
const command = tool.args.command || "";
switch (command) {
case "insert": {
const insertLine = tool.args.insert_line;
const newStr = tool.args.new_str || "";
return `${toolName}: insert_line=${insertLine}, new_str="${newStr}"`;
}
case "str_replace": {
return `${toolName}: string replacement`;
}
case "create": {
const fileText = tool.args.file_text || "";
return `${toolName}: file_text="${fileText}"`;
}
case "view": {
const viewRange = tool.args.view_range;
if (viewRange) {
return `${toolName}: view_range=[${viewRange[0]}, ${viewRange[1]}]`;
}
return `${toolName}: view`;
}
default:
return `${toolName}: ${command}`;
}
}
case "write_todos": {
const todos = tool.args.todos || [];
if (Array.isArray(todos)) {
const todoCount = todos.length;
const statusCounts = todos.reduce((acc: any, todo: any) => {
acc[todo.status] = (acc[todo.status] || 0) + 1;
return acc;
}, {});
const statusSummary = Object.entries(statusCounts)
.map(([status, count]) => `${count} ${status}`)
.join(", ");
return `${toolName}: Updated ${todoCount} todos (${statusSummary})`;
}
return `${toolName}: Updated todos`;
}
}
return toolName;
}
/**
* Format a tool result based on its type and content
*/
function formatToolResult(message: ToolMessage): string {
const content = getMessageContentString(message.content);
if (!content) return "";
const isError = message.status === "error";
const toolName = message.name || "tool";
// If it's an error, return error message immediately
if (isError) return `Error: ${content}`;
switch (toolName.toLowerCase()) {
case "shell":
case "execute_bash": {
try {
const result = JSON.parse(content);
if (!result.success && result.stderr) {
return result.stderr;
}
if (result.success && result.stdout) {
return result.stdout;
}
return content;
} catch {
return content;
}
}
case "write_file":
if (isError) return content;
return "File written successfully";
case "read_file": {
const contentLength = content.length;
return `${contentLength} characters`;
}
case "edit_file":
return isError ? content : "File edited successfully";
case "http_request": {
try {
const result = JSON.parse(content);
return `HTTP ${result.status_code || "unknown"}: ${result.success ? "Success" : "Failed"}`;
} catch {
return content.length > 100 ? content.slice(0, 100) + "..." : content;
}
}
case "web_search": {
try {
const result = JSON.parse(content);
if (result.error) {
return `Search error: ${result.error}`;
}
const results = result.results || [];
return `${results.length} search results found`;
} catch {
return content.length > 100 ? content.slice(0, 100) + "..." : content;
}
}
case "grep": {
if (content.includes("Exit code 1. No results found.")) {
return "No results found";
}
const lines = content.split("\n").filter((line) => line.trim());
return `${lines.length} matches found`;
}
case "view": {
const contentLength = content.length;
return `${contentLength} characters`;
}
case "str_replace_based_edit_tool":
return "File edited successfully";
case "get_url_content":
return `${content.length} characters of content`;
case "write_todos":
if (content.includes("Updated todo list")) {
return "Todo list updated successfully";
}
return content.length > 100 ? content.slice(0, 100) + "..." : content;
case "ls":
try {
const items = JSON.parse(content);
if (Array.isArray(items)) {
return `${items.length} items: ${items.slice(0, 8).join(", ")}${items.length > 8 ? "..." : ""}`;
}
} catch {
// fallthrough to default
}
return content.length > 100 ? content.slice(0, 100) + "..." : content;
default:
return content.length > 200 ? content.slice(0, 200) + "..." : content;
}
}
export function formatDisplayLog(chunk: LogChunk | string): string[] {
if (typeof chunk === "string") {
return [chunk];
}
const data = chunk.data;
const logs: string[] = [];
// Handle messages
const nestedDataObj = Object.values(data)[0] as unknown as Record<
string,
any
>;
if (
nestedDataObj &&
typeof nestedDataObj === "object" &&
"messages" in nestedDataObj
) {
const messages = Array.isArray(nestedDataObj.messages)
? nestedDataObj.messages
: [nestedDataObj.messages];
for (const msg of messages) {
try {
const message = coerceMessageLikeToMessage(msg);
// Handle tool messages
if (isToolMessage(message)) {
const toolName = message.name || "tool";
// Skip displaying results for todo list tool calls
if (toolName === "write_todos") {
continue;
}
const result = formatToolResult(message);
if (result) {
// Display tool results as indented subsections
let formattedResult = result.replace(/\s+/g, " ");
logs.push(` ↳ ${formattedResult}`);
}
continue;
}
// Handle AI messages
if (isAIMessage(message)) {
// Handle reasoning if present
if (message.additional_kwargs?.reasoning) {
const reasoning = String(message.additional_kwargs.reasoning)
.replace(/\s+/g, " ")
.trim();
logs.push(`[REASONING] ${reasoning}`);
}
// Handle tool calls
if (message.tool_calls && message.tool_calls.length > 0) {
const technicalNotesToolName =
createWriteTechnicalNotesToolFields().name;
message.tool_calls.forEach((tool) => {
const formattedArgs = formatToolCallArgs(tool);
logs.push(`▸ ${formattedArgs}`);
// Special handling for write_todos to display the actual todos nicely
if (
tool.name === "write_todos" &&
tool.args &&
tool.args.todos &&
Array.isArray(tool.args.todos)
) {
const todos = tool.args.todos;
logs.push(""); // blank line before todos
todos.forEach((todo: any) => {
const statusIcon =
todo.status === "completed"
? "✓"
: todo.status === "in_progress"
? "→"
: "○";
logs.push(` ${statusIcon} ${todo.content}`);
});
}
// Special handling for edit_file to display the diff
if (tool.name === "edit_file" && tool.args) {
const oldString = tool.args.old_string || "";
const newString = tool.args.new_string || "";
const diffLines = createSimpleDiff(oldString, newString);
logs.push(...diffLines);
}
// Special handling for write_file to display the new content
if (tool.name === "write_file" && tool.args) {
const content = tool.args.content || "";
const diffLines = createSimpleDiff("", content);
logs.push(...diffLines);
}
// Special handling for str_replace_based_edit_tool to display the diff
if (tool.name === "str_replace_based_edit_tool" && tool.args) {
const oldStr = tool.args.old_str || "";
const newStr = tool.args.new_str || "";
const diffLines = createSimpleDiff(oldStr, newStr);
logs.push(...diffLines);
}
// Handle technical notes from tool call
if (
tool.name === technicalNotesToolName &&
tool.args &&
typeof tool.args === "object" &&
"notes" in tool.args
) {
const notes = (tool.args as any).notes;
if (Array.isArray(notes)) {
logs.push(
"[TECHNICAL NOTES]",
...notes.map((note: string) => ` • ${note}`),
);
}
}
});
}
// Handle regular AI messages
const text = getMessageContentString(message.content);
if (text) {
// Always single line, remove newlines
const cleanText = text.replace(/\s+/g, " ").trim();
logs.push(`◆ ${cleanText}`);
}
}
// Handle human messages
if (isHumanMessage(message)) {
const text = getMessageContentString(message.content);
if (text) {
// Single line human messages
const cleanText = text.replace(/\s+/g, " ").trim();
logs.push(`◉ ${cleanText}`);
}
}
} catch (error: any) {
console.error("Error formatting log:", error.message);
// Fallback to original message if conversion fails
if (msg.type === "tool") {
const toolName = msg.name || "tool";
// Skip displaying results for todo list tool calls
if (toolName === "write_todos") {
// Skip this tool result
} else {
const content = getMessageContentString(msg.content);
if (content) {
logs.push(` ↳ ${content}`);
}
}
} else if (msg.type === "ai") {
const text = getMessageContentString(msg.content);
if (text) {
const cleanText = text.replace(/\s+/g, " ").trim();
logs.push(`◆ ${cleanText}`);
}
} else if (msg.type === "human") {
const text = getMessageContentString(msg.content);
if (text) {
const cleanText = text.replace(/\s+/g, " ").trim();
logs.push(`◉ ${cleanText}`);
}
}
}
}
}
// Handle feedback messages
if (data.command?.resume?.[0]?.type) {
const type = data.command.resume[0].type;
logs.push(`[HUMAN FEEDBACK RECEIVED] ${type}`);
}
// Handle interrupts and plans
if (data.__interrupt__) {
const interrupt = data.__interrupt__[0]?.value;
if (interrupt?.action_request?.args?.plan) {
const plan = interrupt.action_request.args.plan;
const steps = plan
.split(":::")
.map((s: string) => s.trim())
.filter(Boolean);
// Add clear visual separation and format nicely
logs.push(
" ", // Blank line for separation
"🎯 PROPOSED PLAN",
...steps.map((step: string, idx: number) => ` ${idx + 1}. ${step}`),
" ", // Blank line after
);
}
}
return logs;
}
/**
* Formats a log chunk for debug purposes, showing all raw data.
* This should only be used during development.
*/
export function formatDebugLog(chunk: LogChunk | string): string {
if (typeof chunk === "string") return chunk;
return JSON.stringify(chunk, null, 2);
}