Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/desktop/src/main/builtin-tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,10 @@ export const builtinToolDefinitions: BuiltinToolDefinition[] = [
type: "number",
description: "Command timeout in milliseconds (default: 30000). Set to 0 for no timeout.",
},
maxOutputChars: {
type: "number",
description: "Maximum characters to return per stream (stdout/stderr). Default 20000, minimum 1000, capped at 200000. Values below 1000 are raised to 1000.",
},
},
required: ["command"],
},
Expand Down Expand Up @@ -445,4 +449,3 @@ export const builtinToolDefinitions: BuiltinToolDefinition[] = [
export function getBuiltinToolNames(): string[] {
return builtinToolDefinitions.map((tool) => tool.name)
}

65 changes: 59 additions & 6 deletions apps/desktop/src/main/builtin-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,37 @@ import path from "path"
import type { AgentMemory } from "../shared/types"

const execAsync = promisify(exec)
const DEFAULT_MAX_TOOL_OUTPUT_CHARS = 20_000
const MAX_ALLOWED_TOOL_OUTPUT_CHARS = 200_000
const DEFAULT_MAX_SKILL_INSTRUCTIONS_CHARS = 20_000

function clampMaxOutputChars(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback
}
const rounded = Math.floor(value)
return Math.min(Math.max(rounded, 1_000), MAX_ALLOWED_TOOL_OUTPUT_CHARS)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clampMaxOutputChars() enforces a minimum of 1,000 chars, but the tool schema description only documents the default and max cap; callers trying to aggressively limit output (e.g., for storage control) won’t be able to set values below 1,000 as implied. Consider documenting the minimum behavior to avoid an API-contract mismatch.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

}

function truncateToolText(
input: string,
maxChars: number,
): { text: string; truncated: boolean; originalLength: number } {
const originalLength = input.length
if (originalLength <= maxChars) {
return { text: input, truncated: false, originalLength }
}

const omitted = originalLength - maxChars
const footer = `\n\n[truncated ${omitted} characters]`
// Slice enough that the content + footer together don't exceed maxChars.
const contentChars = Math.max(0, maxChars - footer.length)
return {
text: `${input.slice(0, contentChars)}${footer}`,
truncated: true,
originalLength,
}
}

// Re-export from the dependency-free definitions module for backward compatibility
// This breaks the circular dependency: profile-service -> builtin-tool-definitions (no cycle)
Expand Down Expand Up @@ -853,6 +884,7 @@ const toolHandlers: Record<string, ToolHandler> = {
const timeout = (typeof rawTimeout === "number" && Number.isFinite(rawTimeout) && rawTimeout >= 0)
? rawTimeout
: 30000
const maxOutputChars = clampMaxOutputChars(args.maxOutputChars, DEFAULT_MAX_TOOL_OUTPUT_CHARS)

// Determine the working directory
let cwd: string | undefined
Expand Down Expand Up @@ -909,6 +941,8 @@ const toolHandlers: Record<string, ToolHandler> = {
}

const { stdout, stderr } = await execAsync(command, execOptions)
const truncatedStdout = truncateToolText(stdout || "", maxOutputChars)
const truncatedStderr = truncateToolText(stderr || "", maxOutputChars)

return {
content: [
Expand All @@ -919,8 +953,12 @@ const toolHandlers: Record<string, ToolHandler> = {
command,
cwd: cwd || process.cwd(),
skillName,
stdout: stdout || "",
stderr: stderr || "",
stdout: truncatedStdout.text,
stderr: truncatedStderr.text,
maxOutputChars,
stdoutOriginalLength: truncatedStdout.originalLength,
stderrOriginalLength: truncatedStderr.originalLength,
outputTruncated: truncatedStdout.truncated || truncatedStderr.truncated,
}, null, 2),
},
],
Expand All @@ -932,6 +970,8 @@ const toolHandlers: Record<string, ToolHandler> = {
const stderr = error.stderr || ""
const errorMessage = error.message || String(error)
const exitCode = error.code
const truncatedStdout = truncateToolText(stdout, maxOutputChars)
const truncatedStderr = truncateToolText(stderr, maxOutputChars)

return {
content: [
Expand All @@ -944,8 +984,12 @@ const toolHandlers: Record<string, ToolHandler> = {
skillName,
error: errorMessage,
exitCode,
stdout,
stderr,
stdout: truncatedStdout.text,
stderr: truncatedStderr.text,
maxOutputChars,
stdoutOriginalLength: truncatedStdout.originalLength,
stderrOriginalLength: truncatedStderr.originalLength,
outputTruncated: truncatedStdout.truncated || truncatedStderr.truncated,
}, null, 2),
},
],
Expand Down Expand Up @@ -1800,10 +1844,14 @@ const toolHandlers: Record<string, ToolHandler> = {
const skillByName = allSkills.find(s => s.name.toLowerCase() === skillId.toLowerCase())

if (skillByName) {
const truncated = truncateToolText(
`# ${skillByName.name}\n\n${skillByName.instructions}`,
DEFAULT_MAX_SKILL_INSTRUCTIONS_CHARS,
)
return {
content: [{
type: "text",
text: `# ${skillByName.name}\n\n${skillByName.instructions}`,
text: truncated.text,
}],
isError: false,
}
Expand All @@ -1821,10 +1869,15 @@ const toolHandlers: Record<string, ToolHandler> = {
}
}

const truncated = truncateToolText(
`# ${skill.name}\n\n${skill.instructions}`,
DEFAULT_MAX_SKILL_INSTRUCTIONS_CHARS,
)

return {
content: [{
type: "text",
text: `# ${skill.name}\n\n${skill.instructions}`,
text: truncated.text,
}],
isError: false,
}
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ const getConfig = () => {
conversationsEnabled: true,
maxConversationsToKeep: 100,
autoSaveConversations: true,
recordingsCleanupEnabled: true,
recordingHistoryMaxItems: 2000,
recordingHistoryRetentionDays: 90,
recordingFileRetentionDays: 90,
recordingOrphanGracePeriodMinutes: 5,
// Settings hotkey defaults
settingsHotkeyEnabled: true,
settingsHotkey: "ctrl-shift-s",
Expand Down
55 changes: 34 additions & 21 deletions apps/desktop/src/main/context-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,36 +622,50 @@ export async function shrinkMessagesForLLM(opts: ShrinkOptions): Promise<ShrinkR

let messages = [...opts.messages]
let tokens = estimateTokensFromMessages(messages)
const initialTokens = tokens

if (isDebugLLM()) {
logLLM("ContextBudget: initial", { providerId, model, maxTokens, targetTokens, estTokens: tokens, count: messages.length })
}

if (tokens <= targetTokens) {
return { messages, appliedStrategies: applied, estTokensBefore: tokens, estTokensAfter: tokens, maxTokens }
return { messages, appliedStrategies: applied, estTokensBefore: initialTokens, estTokensAfter: tokens, maxTokens }
}

// Tier 0: Aggressive truncation of very large tool responses (>5000 chars)
// This happens BEFORE summarization to avoid expensive LLM calls on huge payloads
const AGGRESSIVE_TRUNCATE_THRESHOLD = 5000
const tokensBeforeAggressiveTruncate = tokens
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
if (msg.role === "user" && msg.content && msg.content.length > AGGRESSIVE_TRUNCATE_THRESHOLD) {
// Check if this looks like a tool result (contains JSON arrays/objects)
if (msg.content.includes('"url":') || msg.content.includes('"id":')) {
// Truncate aggressively and add note
messages[i] = {
...msg,
content: msg.content.substring(0, AGGRESSIVE_TRUNCATE_THRESHOLD) +
'\n\n... (truncated ' + (msg.content.length - AGGRESSIVE_TRUNCATE_THRESHOLD) +
' characters for context management. Key information preserved above.)'
}
applied.push("aggressive_truncate")
tokens = estimateTokensFromMessages(messages)
if (tokens <= targetTokens) {
if (isDebugLLM()) logLLM("ContextBudget: after aggressive_truncate", { estTokens: tokens })
return { messages, appliedStrategies: applied, estTokensBefore: tokens, estTokensAfter: tokens, maxTokens }
}
if (!msg.content || msg.content.length <= AGGRESSIVE_TRUNCATE_THRESHOLD) {
continue
}

// Heuristic: aggressively truncate oversized tool payloads first.
// JSON-key heuristics are only applied to non-user messages to avoid
// accidentally truncating user prompts that happen to contain JSON.
const looksLikeToolPayload =
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looksLikeToolPayload can match large user messages that happen to contain JSON keys like "id": / "url":, causing aggressive truncation of the user’s prompt even though this tier is described as “tool responses”. Consider tightening the predicate to reduce accidental truncation of non-tool content.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

msg.role === "tool" ||
(msg.role !== "user" && (
msg.content.includes('"stdout"') ||
msg.content.includes('"stderr"') ||
msg.content.includes('"url":') ||
msg.content.includes('"id":')
))

if (looksLikeToolPayload) {
messages[i] = {
...msg,
content: msg.content.substring(0, AGGRESSIVE_TRUNCATE_THRESHOLD) +
'\n\n... (truncated ' + (msg.content.length - AGGRESSIVE_TRUNCATE_THRESHOLD) +
' characters for context management. Key information preserved above.)'
}
applied.push("aggressive_truncate")
tokens = estimateTokensFromMessages(messages)
if (tokens <= targetTokens) {
if (isDebugLLM()) logLLM("ContextBudget: after aggressive_truncate", { estTokens: tokens })
return { messages, appliedStrategies: applied, estTokensBefore: tokensBeforeAggressiveTruncate, estTokensAfter: tokens, maxTokens }
}
}
}
Expand Down Expand Up @@ -692,7 +706,7 @@ export async function shrinkMessagesForLLM(opts: ShrinkOptions): Promise<ShrinkR

if (tokens <= targetTokens) {
if (isDebugLLM()) logLLM("ContextBudget: after summarize", { estTokens: tokens })
return { messages, appliedStrategies: applied, estTokensBefore: tokens, estTokensAfter: tokens, maxTokens }
return { messages, appliedStrategies: applied, estTokensBefore: initialTokens, estTokensAfter: tokens, maxTokens }
}

// Tier 2: Remove middle messages (keep system, first user, last N)
Expand Down Expand Up @@ -760,7 +774,7 @@ export async function shrinkMessagesForLLM(opts: ShrinkOptions): Promise<ShrinkR

if (tokens <= targetTokens) {
if (isDebugLLM()) logLLM("ContextBudget: after drop_middle", { estTokens: tokens, kept: messages.length })
return { messages, appliedStrategies: applied, estTokensBefore: tokens, estTokensAfter: tokens, maxTokens, toolResultsSummarized }
return { messages, appliedStrategies: applied, estTokensBefore: initialTokens, estTokensAfter: tokens, maxTokens, toolResultsSummarized }
}

// Tier 3: Minimal system prompt
Expand All @@ -780,6 +794,5 @@ export async function shrinkMessagesForLLM(opts: ShrinkOptions): Promise<ShrinkR

if (isDebugLLM()) logLLM("ContextBudget: after minimal_system_prompt", { estTokens: tokens })

return { messages, appliedStrategies: applied, estTokensBefore: tokens, estTokensAfter: tokens, maxTokens, toolResultsSummarized }
return { messages, appliedStrategies: applied, estTokensBefore: initialTokens, estTokensAfter: tokens, maxTokens, toolResultsSummarized }
}

Loading