refactor(coding-agent): reduced session log footprint

- Projected full tool-call arguments down to a compact summary containing only `command` and `path`.
- Truncated summarized argument fields to 200 characters to prevent inflating session log sizes.
- Replaced routine clean session disposal warnings with debug logs to reduce noise.
- Streamlined debug context in assistant message removal and agent continuation skip paths.
- Extracted duplicate user-facing compaction warning strings into a helper function.
This commit is contained in:
can1357
2026-07-02 02:40:08 +02:00
parent 51684b4b1d
commit 0e2feab742
2 changed files with 85 additions and 72 deletions
@@ -276,6 +276,7 @@ import {
AUTO_THINKING,
type ConfiguredThinkingLevel,
clampAutoThinkingEffort,
concreteThinkingLevel,
parseConfiguredThinkingLevel,
resolveProvisionalAutoLevel,
resolveThinkingLevelForModel,
@@ -327,6 +328,7 @@ import {
collectPendingToolCalls,
SESSION_EXIT_CUSTOM_TYPE,
type SessionExitData,
summarizeToolArguments,
TOOL_EXECUTION_START_CUSTOM_TYPE,
type ToolExecutionStartData,
} from "./exit-diagnostics";
@@ -492,6 +494,18 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
automaticContinuationBlocked: true,
};
/**
* User-facing notice for a compaction dead end: maintenance freed too little
* to retry safely. `remedies` names the recovery actions available on the
* emitting path (the shake-rescue path can additionally offer `/shake images`).
*/
function compactionDeadEndWarning(remedies: string): string {
return (
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. " +
`The most recent turn alone is too large to reduce further; ${remedies} or switch to a larger-context model.`
);
}
/**
* Per-turn prune cache window. A tool result whose all-message suffix exceeds
* this is in the warm, already-sent prompt-cache prefix: re-writing it costs the
@@ -932,7 +946,7 @@ function parseRetryFallbackSelector(
raw: trimmed,
provider: parsed.provider,
id: parsed.id,
thinkingLevel: parsed.thinkingLevel === AUTO_THINKING ? undefined : parsed.thinkingLevel,
thinkingLevel: concreteThinkingLevel(parsed.thinkingLevel),
};
}
@@ -1343,22 +1357,6 @@ type ScheduledAgentContinueOptions = {
onError?: () => void;
};
type AssistantContextRemovalResult = {
reason: string;
removed: boolean;
beforeLength: number;
afterLength: number;
lastRole: AgentMessage["role"] | undefined;
candidateTimestamp: number;
lastTimestamp: number | undefined;
candidateProvider: string;
lastProvider: string | undefined;
candidateModel: string;
lastModel: string | undefined;
candidateStopReason: AssistantMessage["stopReason"];
lastStopReason: AssistantMessage["stopReason"] | undefined;
};
const REPLAN_TITLE_CONTEXT_TURN_LIMIT = 6;
type SessionTitleSource = "auto" | "user";
@@ -2217,7 +2215,7 @@ export class AgentSession {
if (config.model) {
const resolved = resolveModelOverride([config.model], this.#modelRegistry, this.settings);
model = resolved.model;
thinkingLevel = resolved.thinkingLevel === AUTO_THINKING ? undefined : resolved.thinkingLevel;
thinkingLevel = concreteThinkingLevel(resolved.thinkingLevel);
if (!model) {
this.emitNotice("warning", `Advisor "${config.name}": no model matched "${config.model}"`, "advisor");
continue;
@@ -2231,7 +2229,7 @@ export class AgentSession {
continue;
}
model = sel.model;
thinkingLevel = sel.thinkingLevel === AUTO_THINKING ? undefined : sel.thinkingLevel;
thinkingLevel = concreteThinkingLevel(sel.thinkingLevel);
}
const advisorModel = model;
const advisorName = config.name;
@@ -2916,9 +2914,12 @@ export class AgentSession {
const data: ToolExecutionStartData = {
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
startedAt: new Date().toISOString(),
};
// The assistant message already persists the full arguments; store only
// the command/path projection the resume warning renders.
const args = summarizeToolArguments(event.args);
if (args) data.args = args;
if (event.intent) data.intent = event.intent;
this.sessionManager.appendCustomEntry(TOOL_EXECUTION_START_CUSTOM_TYPE, data);
}
@@ -2950,7 +2951,10 @@ export class AgentSession {
try {
this.sessionManager.appendCustomEntry(SESSION_EXIT_CUSTOM_TYPE, data);
this.sessionManager.flushSync();
logger.warn("Session exit recorded", {
// Only pending tool calls or an abnormal teardown are noteworthy; a
// clean dispose logs at debug so routine exits don't read as problems.
const exitLog = pendingToolCalls.length > 0 || kind !== "normal" ? logger.warn : logger.debug;
exitLog("Session exit recorded", {
sessionId: this.sessionManager.getSessionId(),
sessionFile: this.sessionManager.getSessionFile(),
reason,
@@ -3854,31 +3858,8 @@ export class AgentSession {
this.#trackPostPromptTask(scheduled);
}
#agentContinueState(options: ScheduledAgentContinueOptions | undefined, signal: AbortSignal | undefined) {
const messages = this.agent.state.messages;
return {
signalAborted: signal?.aborted === true,
disposed: this.#isDisposed,
compacting: this.isCompacting,
handoff: this.isGeneratingHandoff,
generation: this.#promptGeneration,
expectedGeneration: options?.generation,
messageCount: messages.length,
lastRole: messages.at(-1)?.role,
steeringQueueLength: this.agent.peekSteeringQueue().length,
followUpQueueLength: this.agent.peekFollowUpQueue().length,
};
}
#skipAgentContinue(
reason: AgentContinueSkipReason,
options: ScheduledAgentContinueOptions | undefined,
signal: AbortSignal | undefined,
): void {
logger.debug("agent.continue skipped after scheduling", {
reason,
...this.#agentContinueState(options, signal),
});
#skipAgentContinue(reason: AgentContinueSkipReason, options: ScheduledAgentContinueOptions | undefined): void {
logger.debug("agent.continue skipped after scheduling", { reason });
options?.onSkip?.(reason);
}
@@ -3891,28 +3872,26 @@ export class AgentSession {
// reset. The first-class fix is in #checkCompaction/the agent_end handler,
// but this guard catches anything that bypasses that path.
if (signal.aborted || this.#isDisposed || this.isCompacting || this.isGeneratingHandoff) {
this.#skipAgentContinue("session-unavailable", options, signal);
this.#skipAgentContinue("session-unavailable", options);
return;
}
if (options?.shouldContinue && !options.shouldContinue()) {
this.#skipAgentContinue("should-continue-false", options, signal);
this.#skipAgentContinue("should-continue-false", options);
return;
}
this.#beginInFlight();
try {
await this.#maybeRestoreRetryFallbackPrimary();
if (signal.aborted || this.#isDisposed) {
this.#skipAgentContinue("post-restore-unavailable", options, signal);
this.#skipAgentContinue("post-restore-unavailable", options);
return;
}
logger.debug("agent.continue starting after scheduling", this.#agentContinueState(options, signal));
await this.agent.continue();
} catch (error) {
logger.warn("agent.continue failed after scheduling", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
logger.debug("agent.continue failed state after scheduling", this.#agentContinueState(options, signal));
options?.onError?.();
} finally {
this.#endInFlight();
@@ -3921,7 +3900,7 @@ export class AgentSession {
{
delayMs: options?.delayMs,
generation: options?.generation,
onSkip: reason => this.#skipAgentContinue(reason, options, undefined),
onSkip: reason => this.#skipAgentContinue(reason, options),
},
);
}
@@ -10231,32 +10210,24 @@ export class AgentSession {
#removeAssistantMessageFromActiveContext(
assistantMessage: AssistantMessage,
reason = "assistant-context-cleanup",
): AssistantContextRemovalResult {
): void {
const messages = this.agent.state.messages;
const beforeLength = messages.length;
const lastMessage = messages[messages.length - 1];
const lastAssistant: AssistantMessage | undefined = lastMessage?.role === "assistant" ? lastMessage : undefined;
const removed = lastAssistant !== undefined && this.#isSameAssistantMessage(lastAssistant, assistantMessage);
if (removed) {
if (lastAssistant !== undefined && this.#isSameAssistantMessage(lastAssistant, assistantMessage)) {
this.agent.replaceMessages(messages.slice(0, -1));
return;
}
const result: AssistantContextRemovalResult = {
// A miss means the failed turn is still in active context (or was never
// there); log just enough to explain why the identity check failed.
logger.debug("agent active context assistant removal missed", {
reason,
removed,
beforeLength,
afterLength: this.agent.state.messages.length,
lastRole: lastMessage?.role,
candidateTimestamp: assistantMessage.timestamp,
lastTimestamp: lastAssistant?.timestamp,
candidateProvider: assistantMessage.provider,
lastProvider: lastAssistant?.provider,
candidateModel: assistantMessage.model,
lastModel: lastAssistant?.model,
candidateStopReason: assistantMessage.stopReason,
lastStopReason: lastAssistant?.stopReason,
};
logger.debug("agent active context assistant removal", result);
return result;
});
}
/**
@@ -11813,7 +11784,7 @@ export class AgentSession {
if (noProgressDeadEnd) {
this.emitNotice(
"warning",
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; shrink it (e.g. clear large tool output) or switch to a larger-context model.",
compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
"compaction",
);
}
@@ -12229,7 +12200,7 @@ export class AgentSession {
if (noProgressDeadEnd) {
this.emitNotice(
"warning",
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.",
compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"),
"compaction",
);
}
@@ -4,11 +4,22 @@ import type { SessionEntry } from "./session-entries";
export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
export const SESSION_EXIT_CUSTOM_TYPE = "session_exit";
/**
* Compact projection of tool-call arguments persisted with the start marker.
* The assistant message already carries the full arguments; this exists only
* so `appendArgumentSummary` can name the command/path in resume warnings
* without duplicating whole argument payloads into the session JSONL.
*/
export interface ToolArgumentSummary {
command?: string;
path?: string;
}
/** Persisted marker written before a tool implementation starts running. */
export interface ToolExecutionStartData {
toolCallId: string;
toolName: string;
args?: unknown;
args?: ToolArgumentSummary;
intent?: string;
startedAt: string;
}
@@ -52,6 +63,31 @@ function isToolCallContent(value: unknown): value is ToolCallContent {
return value.type === "toolCall" && (typeof value.name === "string" || typeof value.id === "string");
}
/** Character cap for each summarized argument field. */
const ARGUMENT_SUMMARY_MAX_CHARS = 200;
function truncateSummaryField(value: string): string {
return value.length > ARGUMENT_SUMMARY_MAX_CHARS ? `${value.slice(0, ARGUMENT_SUMMARY_MAX_CHARS)}…` : value;
}
/**
* Project full tool-call arguments down to the fields the pending-tool-call
* resume warning actually renders (`command`/`path`), truncated. Returns
* `undefined` when the arguments carry neither, so callers can omit `args`
* entirely instead of persisting an empty object.
*/
export function summarizeToolArguments(args: unknown): ToolArgumentSummary | undefined {
if (!isObject(args)) return undefined;
const summary: ToolArgumentSummary = {};
if (typeof args.command === "string" && args.command.length > 0) {
summary.command = truncateSummaryField(args.command);
}
if (typeof args.path === "string" && args.path.length > 0) {
summary.path = truncateSummaryField(args.path);
}
return summary.command !== undefined || summary.path !== undefined ? summary : undefined;
}
function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | undefined {
if (entry.type !== "custom" || entry.customType !== TOOL_EXECUTION_START_CUSTOM_TYPE) return undefined;
const data = entry.data;
@@ -63,7 +99,11 @@ function readToolExecutionStart(entry: SessionEntry): ToolExecutionStartData | u
toolName: data.toolName,
startedAt,
};
if ("args" in data) result.args = data.args;
// Legacy sessions persisted full argument objects; project them down.
if ("args" in data) {
const args = summarizeToolArguments(data.args);
if (args) result.args = args;
}
if (typeof data.intent === "string") result.intent = data.intent;
return result;
}
@@ -94,7 +134,9 @@ function applyToolExecutionStart(pending: Map<string, PendingToolCallRecord>, ma
const existing = pending.get(marker.toolCallId);
if (existing) {
existing.startedAt = marker.startedAt;
existing.args = marker.args;
// The assistant message carries the full arguments; the marker only has
// the command/path projection. Keep the richer copy when present.
existing.args ??= marker.args;
if (marker.intent) existing.intent = marker.intent;
return;
}