improve cursor tool call logs

This commit is contained in:
Colin McDonnell
2025-11-21 14:41:30 -08:00
parent b0a404c461
commit b6bfcb0cca
7 changed files with 207 additions and 53 deletions
+143 -30
View File
@@ -2,10 +2,136 @@ import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { log } from "../utils/cli.ts";
import { formatToolCallLog, log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts";
import { agent, type ConfigureMcpServersParams, installFromCurl } from "./shared.ts";
// cursor cli event types inferred from stream-json output
interface CursorSystemEvent {
type: "system";
subtype?: string;
[key: string]: unknown;
}
interface CursorUserEvent {
type: "user";
message?: {
role: string;
content: Array<{ type: string; text?: string }>;
};
[key: string]: unknown;
}
interface CursorThinkingEvent {
type: "thinking";
subtype: "delta" | "completed";
text?: string;
[key: string]: unknown;
}
interface CursorAssistantEvent {
type: "assistant";
model_call_id?: string;
message?: {
role: string;
content: Array<{ type: string; text?: string }>;
};
[key: string]: unknown;
}
interface CursorToolCallEvent {
type: "tool_call";
subtype: "started" | "completed";
call_id?: string;
tool_call?: {
mcpToolCall?: {
args?: {
name?: string;
args?: unknown;
toolName?: string;
providerIdentifier?: string;
};
result?: {
success?: {
content?: Array<{ text?: { text?: string } }>;
isError?: boolean;
};
};
};
};
[key: string]: unknown;
}
interface CursorResultEvent {
type: "result";
subtype: "success" | "error";
result?: string;
duration_ms?: number;
[key: string]: unknown;
}
type CursorEvent =
| CursorSystemEvent
| CursorUserEvent
| CursorThinkingEvent
| CursorAssistantEvent
| CursorToolCallEvent
| CursorResultEvent;
const messageHandlers = {
system: (_event: CursorSystemEvent) => {
// system init events - no logging needed
},
user: (_event: CursorUserEvent) => {
// user messages already logged in prompt box
},
thinking: (_event: CursorThinkingEvent) => {
// thinking events are internal - no logging needed
},
assistant: (event: CursorAssistantEvent) => {
// only log finalized messages (ones with model_call_id)
// cursor emits each message twice: once without model_call_id, then again with it
if (event.model_call_id) {
const text = event.message?.content?.[0]?.text;
if (text?.trim()) {
log.box(text.trim(), { title: "Cursor" });
}
}
},
tool_call: (event: CursorToolCallEvent) => {
if (event.subtype === "started") {
// handle both MCP tools and built-in tools (bash, WebFetch, etc)
const mcpToolCall = event.tool_call?.mcpToolCall;
const builtinToolCall = (event.tool_call as any)?.builtinToolCall;
if (mcpToolCall?.args?.toolName && mcpToolCall?.args?.args) {
const formatted = formatToolCallLog({
toolName: mcpToolCall.args.toolName,
input: mcpToolCall.args.args,
});
log.info(formatted);
} else if (builtinToolCall?.args?.name && builtinToolCall?.args?.args) {
const formatted = formatToolCallLog({
toolName: builtinToolCall.args.name,
input: builtinToolCall.args.args,
});
log.info(formatted);
}
} else if (event.subtype === "completed") {
const isError = event.tool_call?.mcpToolCall?.result?.success?.isError;
if (isError) {
log.warning("Tool call failed");
}
}
},
result: async (event: CursorResultEvent) => {
if (event.subtype === "success" && event.duration_ms) {
const durationSec = (event.duration_ms / 1000).toFixed(1);
log.debug(`Cursor completed in ${durationSec}s`);
}
},
};
export const cursor = agent({
name: "cursor",
install: async () => {
@@ -42,9 +168,12 @@ export const cursor = agent({
{
cwd: process.cwd(),
env: {
...process.env,
CURSOR_API_KEY: apiKey,
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
LOG_LEVEL: process.env.LOG_LEVEL,
NODE_ENV: process.env.NODE_ENV,
HOME: process.env.HOME,
PATH: process.env.PATH,
// Don't override HOME - Cursor CLI needs access to macOS keychain
// MCP config is written to tempDir/.cursor/mcp.json which Cursor will find
},
@@ -54,7 +183,6 @@ export const cursor = agent({
let stdout = "";
let stderr = "";
let jsonBuffer = "";
child.on("spawn", () => {
log.debug("Cursor CLI process spawned");
@@ -63,26 +191,21 @@ export const cursor = agent({
child.stdout?.on("data", async (data) => {
const text = data.toString();
stdout += text;
jsonBuffer += text;
// parse ndjson (newline-delimited json)
const lines = jsonBuffer.split("\n");
// keep last incomplete line in buffer
jsonBuffer = lines.pop() || "";
try {
const event = JSON.parse(text) as CursorEvent;
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
try {
const event = JSON.parse(trimmedLine);
// log everything for now - we'll infer types from real output
log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`);
} catch {
// if json parse fails, might be partial line or non-json output
// log debug info but don't crash
log.debug(`failed to parse json line: ${trimmedLine.substring(0, 100)}`);
// route to appropriate handler
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
if (handler) {
await handler(event as never);
}
// debug: log all events
log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`);
} catch {
// ignore parse errors - might be formatted tool call logs from cursor cli
// our handlers log tool calls instead, so we don't need to display these
}
});
@@ -94,16 +217,6 @@ export const cursor = agent({
});
child.on("close", async (code, signal) => {
// process any remaining buffered json
if (jsonBuffer.trim()) {
try {
const event = JSON.parse(jsonBuffer.trim());
log.debug(`[cursor event] ${JSON.stringify(event, null, 2)}`);
} catch {
// ignore parse errors for final buffer
}
}
if (signal) {
log.warning(`Cursor CLI terminated by signal: ${signal}`);
}
+2
View File
@@ -35,6 +35,8 @@ export const gemini = agent({
env: {
GEMINI_API_KEY: apiKey,
GITHUB_INSTALLATION_TOKEN: githubInstallationToken,
LOG_LEVEL: process.env.LOG_LEVEL!,
NODE_ENV: process.env.NODE_ENV!,
},
onStdout: (chunk) => {
const trimmed = chunk.trim();
+4 -1
View File
@@ -302,8 +302,11 @@ export async function installFromCurl({
const installResult = spawnSync("bash", [installScriptPath], {
cwd: tempDir,
env: {
...process.env,
HOME: tempDir, // Cursor install script uses HOME for installation path
PATH: process.env.PATH || "",
SHELL: process.env.SHELL || "/bin/bash",
USER: process.env.USER || "",
TMPDIR: process.env.TMPDIR || "/tmp",
},
stdio: "pipe",
encoding: "utf-8",
+6 -1
View File
@@ -3,6 +3,7 @@ import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { flatMorph } from "@ark/util";
import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype";
import { agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts";
@@ -316,7 +317,11 @@ function validateApiKey(ctx: MainContext): void {
async function runAgent(ctx: MainContext): Promise<AgentResult> {
log.info(`Running ${ctx.agentName}...`);
log.box(ctx.payload.prompt, { title: "Prompt" });
// strip context from event
const { context: _context, ...eventWithoutContext } = ctx.payload.event;
// format: prompt + two newlines + TOON encoded event
const promptContent = `${ctx.payload.prompt}\n\n${toonEncode(eventWithoutContext)}`;
log.box(promptContent, { title: "Prompt" });
return ctx.agent.run({
payload: ctx.payload,
+19 -16
View File
@@ -56,25 +56,28 @@ export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>)
toolDef.execute = async (args: params, context: any) => {
try {
const result = await originalExecute(args, context);
// Check if result is a ToolResult with isError property
const isError =
result && typeof result === "object" && "isError" in result && result.isError === true;
const resultData =
result && typeof result === "object" && "content" in result
? (result as ToolResult).content[0]?.text
: undefined;
if (isError && resultData) {
log.toolCall({ toolName, request: args, error: resultData });
} else if (resultData) {
log.toolCall({ toolName, request: args, result: resultData });
} else {
log.toolCall({ toolName, request: args });
}
// TOOL CALL LOGGING DISABLED — now handled by each agent
// Check if result is a ToolResult with isError property
// const isError =
// result && typeof result === "object" && "isError" in result && result.isError === true;
// const resultData =
// result && typeof result === "object" && "content" in result
// ? (result as ToolResult).content[0]?.text
// : undefined;
// if (isError && resultData) {
// log.toolCall({ toolName, request: args, error: resultData });
// } else if (resultData) {
// log.toolCall({ toolName, request: args, result: resultData });
// } else {
// log.toolCall({ toolName, request: args });
// }
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log.toolCall({ toolName, request: args, error: errorMessage });
// const errorMessage = error instanceof Error ? error.message : String(error);
// log.toolCall({ toolName, request: args, error: errorMessage });
throw error;
}
};
+28 -4
View File
@@ -9,7 +9,7 @@ import * as core from "@actions/core";
import { table } from "table";
const isGitHubActions = !!process.env.GITHUB_ACTIONS;
const isDebugEnabled = process.env.LOG_LEVEL === "debug";
const isDebugEnabled = () => process.env.LOG_LEVEL === "debug";
/**
* Start a collapsed group (GitHub Actions) or regular group (local)
@@ -258,7 +258,7 @@ export const log = {
* Print debug message (only if LOG_LEVEL=debug)
*/
debug: (message: string): void => {
if (isDebugEnabled) {
if (isDebugEnabled()) {
if (isGitHubActions) {
core.debug(message);
} else {
@@ -345,7 +345,7 @@ function getMcpLogPath(): string {
/**
* Format a value as JSON, using compact format for simple values and pretty-printed for complex ones
*/
function formatJsonValue(value: unknown): string {
export function formatJsonValue(value: unknown): string {
const compact = JSON.stringify(value);
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value, null, 2) : compact;
}
@@ -354,7 +354,7 @@ function formatJsonValue(value: unknown): string {
* Format a multi-line string with proper indentation for tool call output
* First line has the label, subsequent lines are indented 4 spaces
*/
function formatIndentedField(label: string, content: string): string {
export function formatIndentedField(label: string, content: string): string {
if (!content.includes("\n")) {
return ` ${label}: ${content}\n`;
}
@@ -420,6 +420,30 @@ function formatToolCall({
return logEntry;
}
/**
* Format a tool call log entry with tool name and input
*/
export function formatToolCallLog({
toolName,
input,
result: _result,
}: {
toolName: string;
input: unknown;
result?: unknown;
}): string {
let output = `${toolName}\n`;
const inputFormatted = formatJsonValue(input);
if (inputFormatted !== "{}") {
output += formatIndentedField("input", inputFormatted);
}
// result parameter added for future use but not implemented yet
return output.trimEnd();
}
/**
* Finds a CLI executable path by checking if it's installed globally
* @param name The name of the CLI executable to find
+5 -1
View File
@@ -29,8 +29,12 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
let stderrBuffer = "";
return new Promise((resolve, reject) => {
// security: caller must provide complete env object, not merged with process.env
const child = nodeSpawn(cmd, args, {
env: env ? { ...process.env, ...env } : process.env,
env: env || {
PATH: process.env.PATH || "",
HOME: process.env.HOME || "",
},
stdio: ["pipe", "pipe", "pipe"],
cwd: cwd || process.cwd(),
});