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:
committed by
pullfrog[bot]
parent
f67cc25f74
commit
fb80343ffd
+2
-2
@@ -261,8 +261,8 @@ export const log = {
|
||||
const timestamp = isDebugEnabled() ? ` [${new Date().toISOString()}]` : "";
|
||||
const output =
|
||||
inputFormatted !== "{}"
|
||||
? `→ ${toolName}(${inputFormatted})${timestamp}`
|
||||
: `→ ${toolName}()${timestamp}`;
|
||||
? `» ${toolName}(${inputFormatted})${timestamp}`
|
||||
: `» ${toolName}()${timestamp}`;
|
||||
|
||||
log.info(output.trimEnd());
|
||||
},
|
||||
|
||||
+23
-2
@@ -120,8 +120,6 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
}
|
||||
|
||||
// 2. setup authentication
|
||||
log.info("» setting up git authentication...");
|
||||
|
||||
// remove existing git auth headers that actions/checkout might have set
|
||||
try {
|
||||
execSync("git config --local --unset-all http.https://github.com/.extraheader", {
|
||||
@@ -133,6 +131,29 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
log.debug("» no existing authentication headers to remove");
|
||||
}
|
||||
|
||||
// remove includeIf entries that actions/checkout@v6 uses for credential persistence.
|
||||
// v6 stores credentials in an external file loaded via includeIf.gitdir, which our
|
||||
// --unset-all above doesn't catch. without this, $git() would produce duplicate
|
||||
// Authorization headers (one from includeIf, one from GIT_CONFIG_PARAMETERS).
|
||||
try {
|
||||
const configOutput = execSync("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;
|
||||
execSync(`git config --local --unset "${key}"`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
log.info("» removed includeIf credential entries");
|
||||
} catch {
|
||||
log.debug("» no includeIf credential entries to remove");
|
||||
}
|
||||
|
||||
// SECURITY: set origin URL without token - auth is injected via GIT_CONFIG_PARAMETERS
|
||||
// in $git() calls. this prevents token leakage to git hooks and subprocesses.
|
||||
const originUrl = `https://github.com/${params.owner}/${params.name}.git`;
|
||||
|
||||
+107
-1
@@ -1,5 +1,5 @@
|
||||
import * as cli from "./cli.ts";
|
||||
import { Timer } from "./timer.ts";
|
||||
import { ThinkingTimer, Timer } from "./timer.ts";
|
||||
|
||||
describe("Timer", () => {
|
||||
beforeEach(() => {
|
||||
@@ -104,3 +104,109 @@ describe("Timer", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThinkingTimer", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(cli.log, "info");
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("markToolResult", () => {
|
||||
it("should store the current timestamp", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 5000);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markToolCall", () => {
|
||||
it("should not log if markToolResult was never called", () => {
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not log if elapsed time is below threshold (3000ms)", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 2999);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should log if elapsed time equals threshold (3000ms)", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 3000);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalledWith("» thought for 3 seconds");
|
||||
});
|
||||
|
||||
it("should log if elapsed time exceeds threshold", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 5500);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalledWith("» thought for 5.5 seconds");
|
||||
});
|
||||
|
||||
it("should format large durations correctly", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 15000);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalledWith("» thought for 15 seconds");
|
||||
});
|
||||
|
||||
it("should handle multiple markToolCall invocations", () => {
|
||||
const startTime = 1000000;
|
||||
vi.setSystemTime(startTime);
|
||||
|
||||
const timer = new ThinkingTimer();
|
||||
timer.markToolResult();
|
||||
|
||||
vi.setSystemTime(startTime + 4000);
|
||||
timer.markToolCall();
|
||||
|
||||
vi.setSystemTime(startTime + 5000);
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalledTimes(2);
|
||||
expect(cli.log.info).toHaveBeenNthCalledWith(1, "» thought for 4 seconds");
|
||||
expect(cli.log.info).toHaveBeenNthCalledWith(2, "» thought for 5 seconds");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,3 +18,32 @@ export class Timer {
|
||||
this.lastCheckpointTimestamp = now;
|
||||
}
|
||||
}
|
||||
|
||||
const THINKING_THRESHOLD = 3000; // ms
|
||||
|
||||
export class ThinkingTimer {
|
||||
private readonly durationFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "unit",
|
||||
unit: "second",
|
||||
unitDisplay: "long",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
private lastToolResultTimestamp: number | null = null;
|
||||
|
||||
markToolResult(): void {
|
||||
this.lastToolResultTimestamp = Date.now();
|
||||
log.debug(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`);
|
||||
}
|
||||
|
||||
markToolCall(): void {
|
||||
const now = Date.now();
|
||||
log.debug(`» 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 / 1000;
|
||||
log.info(`» thought for ${this.durationFormatter.format(seconds)}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user