improve cursor tool call logs
This commit is contained in:
+143
-30
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user