feat(agents): add thinking time logging between tool calls (#244)

* feat(agents): add thinking time logging between tool calls

Adds a ThinkingTimer utility that tracks the gap between tool results and
the next tool call. When the gap exceeds 3 seconds, it logs the duration
with a stopwatch emoji (⏱️ 4.2s).

Uses performance.now() for high-resolution timing and Intl.NumberFormat
for rendering duration in seconds with optional fraction digits.

Integrated across all 5 agents: Claude, Codex, Cursor, Gemini, OpenCode.

Closes #127

* fix: adjusting tests for mocking performance.now.

* fix: reducing diff for claude.

* fix: rm unused args for claude.

* rm unused args for codex.

* fix: rm unused args for gemini.

* fix: rm unused args for opencode.

* mv THINKING_THRESHOLD.

* rev: I decided to pospone node:perf_hooks integration since it requires more comprehensive refactoring.

* fix: using Intl unit formatting.

* tests for ThinkingTimer.

* fix: narrow unit.

* fix: making durationFormatter a class instance property since using one agent per run.

* fix: inverting condition in markToolCall.

* thinking timer improvements and fix actions/checkout v6 auth

- thinking timer: use » chevron and "thought for X seconds" format
- thinking timer: add debug timestamps for sanity checking
- demote PID namespace isolation logs to debug
- remove redundant "setting up git authentication" log
- fix duplicate Authorization header with actions/checkout v6: clean up
  includeIf credential entries that v6 persists via external config files

Co-authored-by: Cursor <cursoragent@cursor.com>

* standardize tool call log prefix to » double chevron

Co-authored-by: Cursor <cursoragent@cursor.com>

* update timer tests for new thinking log format

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
Co-authored-by: Robin Tail <robin_tail@me.com>
Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pullfrog[bot]
2026-02-10 06:17:43 +00:00
committed by pullfrog[bot]
parent f67cc25f74
commit fb80343ffd
13 changed files with 294 additions and 54 deletions
+89 -31
View File
@@ -135752,7 +135752,7 @@ var log = {
toolCall: ({ toolName, input }) => {
const inputFormatted = formatJsonValue(input);
const timestamp = isDebugEnabled() ? ` [${(/* @__PURE__ */ new Date()).toISOString()}]` : "";
const output = inputFormatted !== "{}" ? `\u2192 ${toolName}(${inputFormatted})${timestamp}` : `\u2192 ${toolName}()${timestamp}`;
const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})${timestamp}` : `\xBB ${toolName}()${timestamp}`;
log.info(output.trimEnd());
}
};
@@ -140257,7 +140257,7 @@ function detectSandboxMethod() {
});
if (result.status === 0) {
detectedSandboxMethod = "unshare";
log.info("PID namespace isolation enabled (unprivileged unshare)");
log.debug("PID namespace isolation enabled (unprivileged unshare)");
return "unshare";
}
} catch {
@@ -140269,7 +140269,7 @@ function detectSandboxMethod() {
});
if (result.status === 0) {
detectedSandboxMethod = "sudo-unshare";
log.info("PID namespace isolation enabled (sudo unshare)");
log.debug("PID namespace isolation enabled (sudo unshare)");
return "sudo-unshare";
}
} catch {
@@ -144200,6 +144200,45 @@ async function installFromCurl(params) {
return cliPath;
}
// utils/timer.ts
var Timer = class {
initialTimestamp;
lastCheckpointTimestamp = null;
constructor() {
this.initialTimestamp = Date.now();
}
checkpoint(name) {
const now = Date.now();
const duration4 = this.lastCheckpointTimestamp ? now - this.lastCheckpointTimestamp : now - this.initialTimestamp;
log.debug(`\xBB ${name}: ${duration4}ms`);
this.lastCheckpointTimestamp = now;
}
};
var THINKING_THRESHOLD = 3e3;
var ThinkingTimer = class {
durationFormatter = new Intl.NumberFormat("en-US", {
style: "unit",
unit: "second",
unitDisplay: "long",
minimumFractionDigits: 0,
maximumFractionDigits: 1
});
lastToolResultTimestamp = null;
markToolResult() {
this.lastToolResultTimestamp = Date.now();
log.debug(`\xBB thinking timer: markToolResult at ${this.lastToolResultTimestamp}`);
}
markToolCall() {
const now = Date.now();
log.debug(`\xBB thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`);
if (this.lastToolResultTimestamp === null) return;
const elapsed = now - this.lastToolResultTimestamp;
if (elapsed < THINKING_THRESHOLD) return;
const seconds = elapsed / 1e3;
log.info(`\xBB thought for ${this.durationFormatter.format(seconds)}`);
}
};
// agents/shared.ts
var agent = (input) => {
return {
@@ -144297,6 +144336,7 @@ var claude = agent({
let stdoutBuffer = "";
let finalOutput2 = "";
const bashToolIds = /* @__PURE__ */ new Set();
const thinkingTimer = new ThinkingTimer();
const result = await spawn2({
cmd: "node",
args: args3,
@@ -144317,7 +144357,7 @@ var claude = agent({
log.debug(JSON.stringify(message, null, 2));
const handler2 = messageHandlers[message.type];
if (handler2) {
await handler2(message, bashToolIds);
await handler2(message, bashToolIds, thinkingTimer);
}
} catch {
log.debug(`[claude] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
@@ -144350,7 +144390,7 @@ var claude = agent({
}
});
var messageHandlers = {
assistant: (data, bashToolIds) => {
assistant: (data, bashToolIds, thinkingTimer) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "text" && content.text?.trim()) {
@@ -144359,6 +144399,7 @@ var messageHandlers = {
if (content.name === "bash" && content.id) {
bashToolIds.add(content.id);
}
thinkingTimer.markToolCall();
log.toolCall({
toolName: content.name,
input: content.input
@@ -144367,10 +144408,11 @@ var messageHandlers = {
}
}
},
user: (data, bashToolIds) => {
user: (data, bashToolIds, thinkingTimer) => {
if (data.message?.content) {
for (const content of data.message.content) {
if (content.type === "tool_result") {
thinkingTimer.markToolResult();
const toolUseId = content.tool_use_id;
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
const outputContent = typeof content.content === "string" ? content.content : Array.isArray(content.content) ? content.content.map((c) => typeof c === "string" ? c : c.text || JSON.stringify(c)).join("\n") : String(content.content);
@@ -144525,6 +144567,7 @@ var codex = agent({
let stdoutBuffer = "";
let finalOutput2 = "";
const commandExecutionIds = /* @__PURE__ */ new Set();
const thinkingTimer = new ThinkingTimer();
const env3 = {
...process.env,
CODEX_HOME: codexDir,
@@ -144550,7 +144593,7 @@ var codex = agent({
log.debug(JSON.stringify(event, null, 2));
const handler2 = messageHandlers2[event.type];
if (handler2) {
await handler2(event, commandExecutionIds);
await handler2(event, commandExecutionIds, thinkingTimer);
}
} catch {
log.debug(`[codex] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
@@ -144604,16 +144647,18 @@ var messageHandlers2 = {
"turn.failed": (event) => {
log.error(`Turn failed: ${event.error.message}`);
},
"item.started": (event, commandExecutionIds) => {
"item.started": (event, commandExecutionIds, thinkingTimer) => {
const item = event.item;
if (item.type === "command_execution") {
commandExecutionIds.add(item.id);
thinkingTimer.markToolCall();
log.toolCall({
toolName: item.command,
input: item.args || {}
});
} else if (item.type === "agent_message") {
} else if (item.type === "mcp_tool_call") {
thinkingTimer.markToolCall();
log.toolCall({
toolName: item.tool,
input: {
@@ -144630,13 +144675,14 @@ var messageHandlers2 = {
}
}
},
"item.completed": (event, commandExecutionIds) => {
"item.completed": (event, commandExecutionIds, thinkingTimer) => {
const item = event.item;
if (item.type === "agent_message") {
log.box(item.text.trim(), { title: "Codex" });
} else if (item.type === "command_execution") {
const isTracked = commandExecutionIds.has(item.id);
if (isTracked) {
thinkingTimer.markToolResult();
log.startGroup(`bash output`);
if (item.status === "failed" || item.exit_code !== void 0 && item.exit_code !== 0) {
log.warning(item.aggregated_output || "Command failed");
@@ -144647,6 +144693,7 @@ var messageHandlers2 = {
commandExecutionIds.delete(item.id);
}
} else if (item.type === "mcp_tool_call") {
thinkingTimer.markToolResult();
if (item.status === "failed" && item.error) {
log.warning(`MCP tool call failed: ${item.error.message}`);
} else if (item.output) {
@@ -144716,6 +144763,7 @@ var cursor = agent({
log.info(`\xBB using default model, effort=${ctx.payload.effort}`);
}
const loggedModelCallIds = /* @__PURE__ */ new Set();
const thinkingTimer = new ThinkingTimer();
const messageHandlers5 = {
system: (_event) => {
},
@@ -144739,6 +144787,7 @@ var cursor = agent({
if (event.subtype === "started") {
const mcpToolCall = event.tool_call?.mcpToolCall;
const builtinToolCall = event.tool_call?.builtinToolCall;
thinkingTimer.markToolCall();
if (mcpToolCall?.args?.toolName && mcpToolCall?.args?.args) {
log.toolCall({
toolName: mcpToolCall.args.toolName,
@@ -144751,6 +144800,7 @@ var cursor = agent({
});
}
} else if (event.subtype === "completed") {
thinkingTimer.markToolResult();
const result = event.tool_call?.mcpToolCall?.result?.success;
const isError = result?.isError;
if (isError) {
@@ -144940,17 +144990,19 @@ var messageHandlers3 = {
assistantMessageBuffer = "";
}
},
tool_use: (event) => {
tool_use: (event, thinkingTimer) => {
log.debug(JSON.stringify(event, null, 2));
if (event.tool_name) {
thinkingTimer.markToolCall();
log.toolCall({
toolName: event.tool_name,
input: event.parameters || {}
});
}
},
tool_result: (event) => {
tool_result: (event, thinkingTimer) => {
log.debug(JSON.stringify(event, null, 2));
thinkingTimer.markToolResult();
if (event.status === "error") {
const errorMsg = typeof event.output === "string" ? event.output : JSON.stringify(event.output);
log.warning(`Tool call failed: ${errorMsg}`);
@@ -145016,6 +145068,7 @@ var gemini = agent({
];
let finalOutput2 = "";
let stdoutBuffer = "";
const thinkingTimer = new ThinkingTimer();
try {
const result = await spawn2({
cmd: "node",
@@ -145036,7 +145089,7 @@ var gemini = agent({
markActivity();
const handler2 = messageHandlers3[event.type];
if (handler2) {
await handler2(event);
await handler2(event, thinkingTimer);
}
} catch {
log.debug(`[gemini] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
@@ -145164,6 +145217,7 @@ var opencode = agent({
log.debug(`\xBB XDG_CONFIG_HOME: ${env3.XDG_CONFIG_HOME}`);
const startTime = Date.now();
let eventCount = 0;
const thinkingTimer = new ThinkingTimer();
let output = "";
let stdoutBuffer = "";
const result = await spawn2({
@@ -145200,7 +145254,7 @@ var opencode = agent({
markActivity();
const handler2 = messageHandlers4[event.type];
if (handler2) {
await handler2(event);
await handler2(event, thinkingTimer);
} else {
log.info(
`\xBB OpenCode event (unhandled): type=${event.type}, data=${JSON.stringify(event).substring(0, 500)}`
@@ -145354,7 +145408,7 @@ var messageHandlers4 = {
currentStepType = null;
}
},
tool_use: (event) => {
tool_use: (event, thinkingTimer) => {
const toolName = event.part?.tool;
const toolId = event.part?.callID;
const parameters = event.part?.state?.input;
@@ -145367,6 +145421,7 @@ var messageHandlers4 = {
if (stepHistory.length > 0) {
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
}
thinkingTimer.markToolCall();
log.toolCall({
toolName,
input: parameters || {}
@@ -145376,10 +145431,11 @@ var messageHandlers4 = {
}
}
},
tool_result: (event) => {
tool_result: (event, thinkingTimer) => {
const toolId = event.part?.callID || event.tool_id;
const status = event.part?.state?.status || event.status || "unknown";
const output = event.part?.state?.output || event.output;
thinkingTimer.markToolResult();
if (toolId) {
const toolStartTime = toolCallTimings.get(toolId);
if (toolStartTime) {
@@ -146266,7 +146322,6 @@ async function setupGit(params) {
`Failed to set git config: ${error49 instanceof Error ? error49.message : String(error49)}`
);
}
log.info("\xBB setting up git authentication...");
try {
execSync3("git config --local --unset-all http.https://github.com/.extraheader", {
cwd: repoDir,
@@ -146276,6 +146331,24 @@ async function setupGit(params) {
} catch {
log.debug("\xBB no existing authentication headers to remove");
}
try {
const configOutput = execSync3("git config --local --get-regexp ^includeif\\.", {
cwd: repoDir,
encoding: "utf-8",
stdio: "pipe"
});
for (const line of configOutput.trim().split("\n")) {
const key = line.split(" ")[0];
if (!key) continue;
execSync3(`git config --local --unset "${key}"`, {
cwd: repoDir,
stdio: "pipe"
});
}
log.info("\xBB removed includeIf credential entries");
} catch {
log.debug("\xBB no includeIf credential entries to remove");
}
const originUrl = `https://github.com/${params.owner}/${params.name}.git`;
$("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir });
params.toolState.pushUrl = originUrl;
@@ -146300,21 +146373,6 @@ function parseTimeString(input) {
return (hours * 3600 + minutes * 60 + seconds) * 1e3;
}
// utils/timer.ts
var Timer = class {
initialTimestamp;
lastCheckpointTimestamp = null;
constructor() {
this.initialTimestamp = Date.now();
}
checkpoint(name) {
const now = Date.now();
const duration4 = this.lastCheckpointTimestamp ? now - this.lastCheckpointTimestamp : now - this.initialTimestamp;
log.debug(`\xBB ${name}: ${duration4}ms`);
this.lastCheckpointTimestamp = now;
}
};
// utils/workflow.ts
async function resolveRun(params) {
const runId = process.env.GITHUB_RUN_ID || "";