Skip to content
Merged
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
17 changes: 17 additions & 0 deletions docs/issues/agent-tool-context-budget/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ consumes more context and fails on small formatting deviations.
- Add a compact tool-schema mode for legacy function-call fallback.
- Add UI diagnostics for "context budget pressure" and suggested remediation.

## Second Increment

- Add a provider-call preflight helper that estimates messages, tool definitions, the safety margin,
and the temporary effective output cap before every loop request.
- Reserve a 256-token safety margin for normal model context windows so off-by-one provider
validators do not reject otherwise fitted requests.
- When preflight pressure would reduce a normal request below 4000 output tokens, run an internal
recovery pass before the provider call: compact persisted old turns when enabled, then rely on the
request fitter to trim older in-memory messages while preserving the active tail.
- Write recovered messages back into the active request array so later tool-continuation loops use
the same compacted/trimmed history.
- Keep generation settings unchanged; only the provider call's `maxTokens` argument is reduced.
- Use the same safety-adjusted budget in tool-output fitting so continuation turns do not inject
tool results that leave the next request over the provider limit.
- Report zero effective output tokens when a fitted request still cannot fit at all, and fail before
calling the provider.

## Manual Validation Notes

For a MiniMax-M2.7 agent session, inspect trace/log output rather than running automated test
Expand Down
19 changes: 19 additions & 0 deletions docs/issues/agent-tool-context-budget/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,22 @@ Observed risk points:
- Legacy parser accepts complete function-call tags, a trailing unclosed function-call tag, and JSON
wrapped in Markdown fences.
- No ACP-specific behavior is required for the first increment.

## Second Increment: Context Pressure Backoff

Additional acceptance criteria:

- Every agent-loop provider request must satisfy
`estimated messages + tool schemas + effective maxTokens <= contextLength - 256` for normal model
windows.
- If the estimated input and configured request output would exceed that budget, DeepChat must
temporarily reduce only the per-call `maxTokens` value passed to the provider.
- If context pressure would reduce a normally sized request below 4000 output tokens, DeepChat must
internally recover context first, using auto-compaction when enabled and transient request-window
trimming otherwise.
- User-configured `maxTokens` values below 4000 are respected and do not force recovery by
themselves.
- If no output token can fit after request fitting, preflight reports `effectiveMaxTokens = 0`
instead of a positive budget.
- Context-pressure recovery updates the in-memory request history used by later tool-continuation
loops.
6 changes: 6 additions & 0 deletions docs/issues/agent-tool-context-budget/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
- [x] Preflight-fit provider-loop requests.
- [x] Add effective per-request output cap and shared budget module.
- [x] Harden legacy function-call parsing.
- [x] Add 256-token provider-call safety margin and preflight backoff.
- [x] Trigger internal compaction/trim recovery before pressure-shrunk calls below 4000 output.
- [x] Keep recovered request messages in sync for later provider-loop iterations.
- [x] Apply safety-adjusted budget checks to tool-output continuation fitting.
- [x] Drop orphaned tool results and invalid provider options before AI SDK requests.
- [x] Report zero effective output tokens for unfittable preflight results.
- [ ] Add request budget telemetry.
- [ ] Add reasoning retention budget.
- [ ] Add compact legacy tool schema mode.
27 changes: 27 additions & 0 deletions docs/issues/background-exec-shell-fallback/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Background Exec Shell Fallback Plan

## Approach

- Centralize fallback behavior in `getUserShell()` so foreground exec, background exec, and shell
environment bootstrap share the same shell resolution.
- Check absolute shell candidates for path and executable availability before returning them.
- Search `PATH` plus DeepChat default paths when `SHELL` is a bare command name.
- Use conservative POSIX fallback chains and keep Windows behavior intact.
- Return only resolved fallback candidates from the platform fallback chain; if none resolve, use
`/bin/sh` as the final default.
- Validate shell process working directories before calling `spawn`, because Node reports missing
`cwd` as `spawn <shell> ENOENT`.

## Affected Paths

- `src/main/lib/agentRuntime/shellEnvHelper.ts`
- `src/main/lib/agentRuntime/backgroundExecSessionManager.ts`
- Existing shell environment and background exec tests.

## Compatibility

- Existing valid user shells are still preferred.
- Missing or non-executable shells now fall back to an available POSIX shell instead of failing
with `ENOENT`/`EACCES`.
- Plain `sh` bootstrap no longer receives login-shell flags it may not support.
- Missing working directories now produce a direct working-directory error.
25 changes: 25 additions & 0 deletions docs/issues/background-exec-shell-fallback/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Background Exec Shell Fallback

## User Story

Users can run foreground and background shell commands even when the configured POSIX login shell
path, such as `/bin/zsh`, is unavailable in the current runtime environment.

## Acceptance Criteria

- POSIX shell execution does not blindly spawn a missing `process.env.SHELL` path.
- POSIX shell fallback skips existing but non-executable shell candidates.
- macOS falls back from zsh to bash and then sh; Linux falls back from bash to sh and then zsh.
- If no configured or platform fallback shell resolves, DeepChat uses `/bin/sh` instead of an
unchecked rejected candidate.
- Background exec sessions use the resolved executable shell path.
- Shell environment bootstrap uses plain `sh -c` flags when the fallback is `sh`.
- Missing or inaccessible working directories are reported before spawn instead of surfacing as a
misleading shell `ENOENT`.
- Windows shell selection is unchanged.

## Non-goals

- Do not add renderer settings or IPC for shell configuration.
- Do not persist a detected fallback shell.
- Do not change user command permission behavior or output formatting.
10 changes: 10 additions & 0 deletions docs/issues/background-exec-shell-fallback/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Background Exec Shell Fallback Tasks

- [x] Document the issue and desired behavior.
- [x] Add available shell resolution and POSIX fallback ordering.
- [x] Verify fallback shell candidates are executable before spawn.
- [x] Avoid returning an unchecked platform fallback after candidate validation fails.
- [x] Avoid login bootstrap flags for plain `sh`.
- [x] Validate spawn working directories before launching shell processes.
- [x] Add unit coverage for missing configured shell fallback.
- [x] Run format, i18n, lint, typecheck, and targeted tests.
21 changes: 18 additions & 3 deletions src/main/lib/agentRuntime/backgroundExecSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createUtf8OutputDecoderPair,
prepareShellCommandForUtf8Output
} from './shellOutputEncoding'
import { describeSpawnFailure, resolveUsableSpawnCwd } from './spawnGuard'
import { terminateProcessTree } from './processTree'
import { resolveSessionDir } from './sessionPaths'

Expand Down Expand Up @@ -60,6 +61,8 @@ interface BackgroundSession {
sessionId: string
conversationId: string
command: string
cwd: string
shell: string
child: ChildProcess
status: 'running' | 'done' | 'error' | 'killed'
exitCode?: number
Expand Down Expand Up @@ -127,6 +130,7 @@ export class BackgroundExecSessionManager {
const sessionId = `bg_${nanoid(12)}`
const { shell, args } = getUserShell()
const shellCommand = prepareShellCommandForUtf8Output(shell, command)
const spawnCwd = resolveUsableSpawnCwd(cwd)

const sessionDir = resolveSessionDir(conversationId)
if (sessionDir) {
Expand All @@ -138,7 +142,7 @@ export class BackgroundExecSessionManager {
: null

const child = spawn(shell, [...args, shellCommand], {
cwd,
cwd: spawnCwd,
env: { ...process.env, ...options?.env },
detached: process.platform !== 'win32',
stdio: ['pipe', 'pipe', 'pipe']
Expand All @@ -154,6 +158,8 @@ export class BackgroundExecSessionManager {
sessionId,
conversationId,
command,
cwd: spawnCwd,
shell,
child,
status: 'running',
createdAt: now,
Expand Down Expand Up @@ -503,8 +509,17 @@ export class BackgroundExecSessionManager {
if (session.status === 'running') {
session.status = 'error'
}
session.errorMessage = error.message
logger.error(`[BackgroundExec] Session ${session.sessionId} error:`, error)
const errorMessage = describeSpawnFailure(error, {
shell: session.shell,
cwd: session.cwd
})
session.errorMessage = errorMessage
this.appendOutput(session, `${errorMessage}\n`, getConfig())
logger.error(`[BackgroundExec] Session ${session.sessionId} error:`, {
error,
cwd: session.cwd,
shell: session.shell
})
queueMicrotask(() => {
if (!session.closeSettled && session.exitCode === undefined) {
void this.finalizeSession(session, null, null)
Expand Down
58 changes: 54 additions & 4 deletions src/main/lib/agentRuntime/shellEnvHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn } from 'child_process'
import { app } from 'electron'
import fs from 'fs'
import * as path from 'path'
import { RuntimeHelper } from '../runtimeHelper'

Expand Down Expand Up @@ -28,6 +29,12 @@ const NPM_ENV_KEYS = [
] as const
const RELEVANT_ENV_KEYS = [...PATH_ENV_KEYS, ...NODE_ENV_KEYS, ...NPM_ENV_KEYS] as const

const POSIX_SHELL_FALLBACKS: Partial<Record<NodeJS.Platform, string[]>> = {
darwin: ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash', '/bin/sh', '/usr/bin/sh'],
linux: ['/bin/bash', '/usr/bin/bash', '/bin/sh', '/usr/bin/sh', '/bin/zsh', '/usr/bin/zsh']
}
const DEFAULT_POSIX_SHELL_FALLBACKS = ['/bin/sh', '/usr/bin/sh']

let cachedShellEnv: Record<string, string> | null = null

const TIMEOUT_MS = 8000
Expand Down Expand Up @@ -253,7 +260,48 @@ function getShellBootstrapArgs(shellPath: string, command: string): string[] {
return ['-l', '-i', '-c', command]
}

return ['-l', '-c', command]
return ['-c', command]
}

function isAvailableShell(shellPath: string): boolean {
try {
const stat = fs.statSync(shellPath)
if (!stat.isFile()) {
return false
}
fs.accessSync(shellPath, fs.constants.X_OK)
return true
} catch {
return false
}
}

function resolveShellPath(shellCandidate: string | undefined): string | null {
const shell = shellCandidate?.trim()
if (!shell) {
return null
}

if (path.isAbsolute(shell)) {
return isAvailableShell(shell) ? shell : null
}

const searchEntries = mergePathEntries([getPathEntriesFromEnv(process.env)], {
includeDefaultPaths: true
}).entries

for (const entry of searchEntries) {
const resolved = path.join(entry, shell)
if (isAvailableShell(resolved)) {
return resolved
}
}

return null
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function getFallbackShellCandidates(platform: NodeJS.Platform): string[] {
return POSIX_SHELL_FALLBACKS[platform] ?? DEFAULT_POSIX_SHELL_FALLBACKS
}

export function getUserShell(): { shell: string; args: string[] } {
Expand All @@ -267,9 +315,11 @@ export function getUserShell(): { shell: string; args: string[] } {
return { shell: 'cmd.exe', args: ['/c'] }
}

const fallbackShell =
platform === 'darwin' ? '/bin/zsh' : platform === 'linux' ? '/bin/bash' : '/bin/sh'
const shell = process.env.SHELL || fallbackShell
const fallbackShells = getFallbackShellCandidates(platform)
const shell =
resolveShellPath(process.env.SHELL) ??
fallbackShells.map((candidate) => resolveShellPath(candidate)).find(Boolean) ??
'/bin/sh'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return { shell, args: ['-c'] }
}
Expand Down
56 changes: 56 additions & 0 deletions src/main/lib/agentRuntime/spawnGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import fs from 'fs'
import path from 'path'

const cwdErrorHint =
'Update the session workspace to an existing folder before running shell tools. Node may report this as "spawn <shell> ENOENT" even when the shell exists.'

export function resolveUsableSpawnCwd(cwd: string): string {
const normalizedCwd = path.resolve(cwd.trim() || process.cwd())

if (!fs.existsSync(normalizedCwd)) {
throw new Error(
`Working directory does not exist or is not accessible: ${normalizedCwd}. ${cwdErrorHint}`
)
}

const statSync = fs.statSync as ((targetPath: fs.PathLike) => fs.Stats) | undefined
if (typeof statSync !== 'function') {
return normalizedCwd
}

try {
if (!statSync(normalizedCwd).isDirectory()) {
throw new Error(`Working directory is not a directory: ${normalizedCwd}. ${cwdErrorHint}`)
}
} catch (error) {
if (error instanceof Error && error.message.includes(cwdErrorHint)) {
throw error
}
throw new Error(
`Working directory is not accessible: ${normalizedCwd}. ${cwdErrorHint}`,
error instanceof Error ? { cause: error } : undefined
)
}

return normalizedCwd
}

export function describeSpawnFailure(
error: Error,
context: {
shell: string
cwd: string
}
): string {
const code =
typeof (error as NodeJS.ErrnoException).code === 'string'
? ` ${(error as NodeJS.ErrnoException).code}`
: ''
const message = `Failed to spawn shell${code}: ${context.shell} (cwd: ${context.cwd}). ${error.message}`

if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return `${message}. If the shell path exists, the working directory may be missing or inaccessible.`
}

return message
}
Loading