From ccb5b3e100a2b996d4cc61f4261b59c1728f481d Mon Sep 17 00:00:00 2001 From: muyu Date: Wed, 23 Sep 2026 20:22:05 +0800 Subject: [PATCH 1/3] feat: improve local Bash execution contracts --- docs/tui-capabilities.md | 19 + .../agent-tools/src/desktop/builtin-defs.ts | 35 +- packages/agent-tools/src/desktop/index.ts | 5 + .../src/desktop/local-bash-contract.ts | 83 ++ .../src/desktop/local-bash-input.ts | 14 + .../src/desktop/local-bash-result.ts | 99 ++ .../src/desktop/local-bash-timing.ts | 62 ++ .../agent-tools/src/desktop/local-pi-tools.ts | 235 +++-- .../src/desktop/local-task-control.ts | 52 +- .../agent-tools/src/desktop/output-limit.ts | 4 +- packages/agent-tools/src/desktop/types.ts | 3 + .../test/desktop/local-bash-output.test.ts | 207 ++++ .../test/desktop/local-bash-timeout.test.ts | 506 +++++++++ .../desktop/local-task-output-polling.test.ts | 445 ++++++++ .../agents/_default/prompt-base-all.md.hbs | 7 +- .../agents/_default/prompt-base-windows.md | 47 +- .../_default/prompt-base-windows.md.hbs | 47 +- .../service/background-bash/executor.test.ts | 233 +++++ .../src/service/background-bash/executor.ts | 60 +- .../assembly/local-turn-tool-catalog.ts | 23 + .../agent-host/execution/executor.test.ts | 21 +- .../agent-host/execution/executor.ts | 6 +- .../test/unit/agent/builtin/catalog.test.ts | 24 + .../src/background-task/bash-output-writer.ts | 25 +- .../src/background-task/bash-runner.ts | 184 ++-- .../background-task/bash-task-settlement.ts | 172 ++++ .../src/background-task/service.ts | 55 +- .../src/background-task/task-stop.ts | 66 ++ .../test/unit/child-bash-lifecycle.test.ts | 32 +- ...background-bash-review-regressions.test.ts | 430 ++++++++ .../unit/local-background-bash-runner.test.ts | 959 ++++++++++++++++++ .../local-background-task-stop-races.test.ts | 119 +++ .../transcript/presentation/tool-summary.ts | 1 + .../tui/src/tui/transcript/tool-evidence.ts | 9 +- .../tui/test/unit/tui-tool-evidence.test.ts | 264 +++++ .../tui/test/unit/tui-tool-summary.test.ts | 54 + release/public-source.json | 15 + test/vitest-suites.json | 11 +- third_party/pi-mono/MINIMAX_CHANGES.md | 8 + .../coding-agent/src/core/tools/bash.ts | 215 +++- .../coding-agent/src/core/tools/index.ts | 2 + .../src/core/tools/output-accumulator.ts | 48 +- .../packages/coding-agent/src/index.ts | 2 + .../coding-agent/src/utils/child-process.ts | 27 +- .../test/bash-process-observation.test.ts | 61 +- .../packages/coding-agent/test/tools.test.ts | 37 + 46 files changed, 4605 insertions(+), 428 deletions(-) create mode 100644 packages/agent-tools/src/desktop/local-bash-contract.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-input.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-result.ts create mode 100644 packages/agent-tools/src/desktop/local-bash-timing.ts create mode 100644 packages/agent-tools/test/desktop/local-bash-output.test.ts create mode 100644 packages/agent-tools/test/desktop/local-bash-timeout.test.ts create mode 100644 packages/agent-tools/test/desktop/local-task-output-polling.test.ts create mode 100644 packages/local-runtime-v2/src/service/background-bash/executor.test.ts create mode 100644 packages/local-runtime/src/background-task/bash-task-settlement.ts create mode 100644 packages/local-runtime/src/background-task/task-stop.ts create mode 100644 packages/local-runtime/test/unit/local-background-bash-review-regressions.test.ts create mode 100644 packages/local-runtime/test/unit/local-background-bash-runner.test.ts create mode 100644 packages/local-runtime/test/unit/local-background-task-stop-races.test.ts create mode 100644 packages/tui/test/unit/tui-tool-evidence.test.ts create mode 100644 packages/tui/test/unit/tui-tool-summary.test.ts diff --git a/docs/tui-capabilities.md b/docs/tui-capabilities.md index 9ced4ea3..85e1b3aa 100644 --- a/docs/tui-capabilities.md +++ b/docs/tui-capabilities.md @@ -21,6 +21,25 @@ The evidence column summarizes the historical TUI 0.3.11 restoration record from | Files, shell, subagents, sessions, headless, ACP | Actual runtime retained | BYOK, file reads, session resume, ACP, sandbox, and status protocol tests | | Built-in skills, MCP, plugin tools | Original TUI assets and activation conditions retained | Asset build, plugin, and MCP tests; no claim that every skill has passed a real task | +## Local Bash execution + +When the current turn includes native `task_output`, foreground Bash waits up to +60 seconds before returning the same command's background task ID. Its total +command timeout defaults to 600 seconds and is capped at 600 seconds; a shorter +requested timeout applies. Backgrounding and output reads preserve the original +deadline. Without native `task_output`, Bash stays in the foreground with a +120-second default and a 300-second cap, and its schema omits `run_in_background`. +Explicit background commands use the requested timeout; when omitted, the +existing 30-minute runtime watchdog applies. + +Only exit code zero is success. Results retain available exit, signal, timeout, +cancellation, and partial-output facts. Large output keeps its original beginning +and end within a 24 KiB first-response text budget, with a full-log reference when +persistence succeeds. `task_output` reads use byte offsets; a successful read can +report a failed command. Stop failures and incomplete logs are reported separately. +An optional `description` supplies the TUI summary while execution and permission +checks continue to use the original command. + ## Skill directory links Workspace `.agents/skills`, `.claude/skills`, and `.minimax/skills` support diff --git a/packages/agent-tools/src/desktop/builtin-defs.ts b/packages/agent-tools/src/desktop/builtin-defs.ts index ebc13739..24fb62a7 100644 --- a/packages/agent-tools/src/desktop/builtin-defs.ts +++ b/packages/agent-tools/src/desktop/builtin-defs.ts @@ -4,6 +4,7 @@ import type { ToolDefinition } from '@mavis/agent-core/tools'; import { createMavisOperationClassifier } from '../shared/mavis-operation-classifier.js'; import { LOCAL_MAVIS_COMMANDS } from './local-mavis-commands.js'; +import { BashDescriptionSchema } from './local-bash-input.js'; import { prepareAskUserArguments } from './prepare-ask-user-arguments.js'; import { @@ -102,33 +103,17 @@ export const LocalEditToolDef = { } as const satisfies ToolDefinition; export type LocalEditToolInput = Static; -const _isWindows = process.platform === 'win32'; -const MAX_BACKGROUND_BASH_TIMEOUT_SECONDS = 2_147_483; - export const LocalBashToolDef = { name: 'bash', executionMode: 'sequential', description: [ - 'Executes a command in a fresh local shell and returns its output.', + 'Executes a shell command and returns its output.', '', - '- Each call starts in the session workspace. `cd` and shell state do not persist between calls; use absolute paths or change directories within the same command.', - '- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and necessary pipelines.', - '- The shell is non-interactive: no TTY or stdin prompts. Run non-interactive commands yourself; check `--help` for suitable flags before asking the user to run one. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.', - '- Use `run_in_background` for long commands. Do not increase timeouts to mask hung commands. A returned task id refers to the original process; do not rerun it. Use `task_query` or `task_output` to inspect progress and `task_stop` to cancel.', + '- Each call starts in the session workspace. Changes to the working directory and shell state (variables and functions) do not persist between calls. If a command requires a different working directory, change directories within the same call.', + '- Use dedicated `read`, `write`, `edit`, `grep`, and `glob` tools for file operations. Do not use shell commands for file reading, searching, or modification unless the user explicitly requests it or you have verified that the dedicated tools cannot perform the required operation. Use `bash` for processes, git, package managers, builds, tests, and pipelines.', + '- The shell is non-interactive: no TTY or stdin prompts. For commands that require interaction, check `--help` for non-interactive options before asking the user to run them. Leave physical authorization (OAuth consent, MFA, hardware keys) to the user.', '- For file or directory deletion, use one top-level `rm -- ...`; the local runtime routes it through recoverable deletion.', '- Do not bypass recoverable deletion with absolute paths to deletion commands or inline scripts. If it fails, report the failure instead of falling back to permanent deletion. Permission checks still apply.', - ...(_isWindows - ? [ - '', - 'Windows shell:', - '- PowerShell is preferred; Bash is a fallback when PowerShell is unavailable. Match the selected shell syntax.', - '- For PowerShell, use `$env:VAR`; do not use `export`, `/dev/null`, heredocs, `sed -i`, or `&&`. Do not wrap commands in `powershell -Command`.', - "- Start multi-statement PowerShell scripts with `$ErrorActionPreference = 'Stop'`. Use single quotes for literals and regex patterns. Do NOT use Bash-style backslash escapes.", - '- For native program failures in PowerShell, check `$LASTEXITCODE` or enable `$PSNativeCommandUseErrorActionPreference` when available.', - '- Avoid `Get-Content | ... | Set-Content` file-editing pipelines. Prefer file tools or a script with explicit UTF-8 encoding for batch changes. If Get-Content/Set-Content are needed, specify `-Encoding UTF8`; PowerShell 5.1 writes a BOM.', - '- If a CLI is missing, check `.cmd` or `.ps1` wrappers. If `bash` opens WSL or produces garbled output, switch to direct `node`/`python` execution or a verified Git Bash path. After two failures with the same approach, change strategy.', - ] - : []), '', '# Git', '- Interactive flags (`-i`, e.g. `git rebase -i`, `git add -i`) are not supported in this environment.', @@ -137,19 +122,19 @@ export const LocalBashToolDef = { ].join('\n'), schema: Type.Object({ command: Type.String({ - description: 'Shell command line to execute locally.', + description: 'The command to execute', }), + description: BashDescriptionSchema, timeout: Type.Optional( Type.Number({ - maximum: MAX_BACKGROUND_BASH_TIMEOUT_SECONDS, + exclusiveMinimum: 0, description: - 'Timeout in seconds. Foreground: default 120s, max 300s. Use run_in_background for longer commands. Background tasks use the requested timeout, subject to a runtime watchdog.', + 'Total command timeout in seconds. Foreground-only: default 120s, max 300s. Foreground with automatic backgrounding: default/max 600s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.', }), ), run_in_background: Type.Optional( Type.Boolean({ - description: - 'Start in the background and return a task id immediately. Foreground commands may also return a task id after 15s without restarting the process.', + description: 'Set to true to run this command in the background.', }), ), }), diff --git a/packages/agent-tools/src/desktop/index.ts b/packages/agent-tools/src/desktop/index.ts index 5b289f55..7d8b8875 100644 --- a/packages/agent-tools/src/desktop/index.ts +++ b/packages/agent-tools/src/desktop/index.ts @@ -49,6 +49,10 @@ export * from './local-ask-user.js'; export * from './local-feature-enable.js'; export * from './local-browser.js'; export * from './local-pi-tools.js'; +export * from './local-bash-result.js'; +export * from './local-bash-timing.js'; +export * from './local-bash-input.js'; +export * from './local-bash-contract.js'; export { executeLocalHostTrashIfRequested } from './host-trash-executor.js'; export type { LocalHostTrashRuntime } from './host-trash-executor.js'; export * from './local-glob.js'; @@ -198,3 +202,4 @@ export { CloudSessionReadError, type CloudSessionReaderOptions, } from './cloud-session-reader.js'; +export { DESKTOP_BASH_PREVIEW_BYTES } from './output-limit.js'; diff --git a/packages/agent-tools/src/desktop/local-bash-contract.ts b/packages/agent-tools/src/desktop/local-bash-contract.ts new file mode 100644 index 00000000..fd1e210a --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-contract.ts @@ -0,0 +1,83 @@ +import { getShellConfig } from '@earendil-works/pi-coding-agent/shell'; +import type { ToolDefinition } from '@mavis/agent-core/tools'; +import { Clone, Type } from '@sinclair/typebox'; + +import { LocalBashToolDef } from './builtin-defs.js'; +import { + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_MANAGED_BASH_TIMEOUT_SECONDS, +} from './local-bash-timing.js'; + +export interface LocalBashTurnCapabilities { + readonly background: boolean; + readonly shell: ReturnType['type'] | 'unavailable'; +} + +export function resolveLocalBashShell(): LocalBashTurnCapabilities['shell'] { + try { + return getShellConfig().type; + } catch { + return 'unavailable'; + } +} + +/** A fresh definition for the final admitted Turn inventory. */ +export function createLocalBashToolDefinition( + capabilities: LocalBashTurnCapabilities, +): ToolDefinition { + const base = Clone(LocalBashToolDef.schema); + const schema = capabilities.background ? base : Type.Omit(base, ['run_in_background']); + schema.properties.timeout.description = capabilities.background + ? `Total command timeout in seconds. Foreground with automatic backgrounding: default/max ${MAX_MANAGED_BASH_TIMEOUT_SECONDS}s, including foreground time. Explicit background: uses the specified timeout, or a 30-minute limit if omitted.` + : `Timeout in seconds. Foreground: default ${DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS}s; values above ${MAX_FOREGROUND_BASH_TIMEOUT_SECONDS}s are capped.`; + return { + ...LocalBashToolDef, + schema, + description: `${LocalBashToolDef.description}\n\n${renderBashUsage(capabilities)}`, + }; +} + +function renderBashUsage(capabilities: LocalBashTurnCapabilities): string { + return [ + ...(capabilities.background + ? [ + `- Foreground calls wait up to ${DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS / 1000}s, then return a task_id if unfinished. The same command continues in the background; do not start it again.`, + '- Set run_in_background=true to return a task_id without waiting for completion.', + '- Background completion notifies you automatically. Continue independent work; use task_output for current output and task_stop, when available, to cancel.', + '- Backgrounding and reading output do not reset the command timeout.', + ] + : ['- This Turn supports foreground execution only.']), + "- Large output preserves the beginning and end. Follow the result's instructions to read omitted output.", + ...shellRules(capabilities.shell), + ].join('\n'); +} + +function shellRules(shell: LocalBashTurnCapabilities['shell']): string[] { + if (shell === 'unavailable') + return [ + '- No local shell was resolved. Do not assume Bash or PowerShell syntax is executable.', + ]; + if (shell === 'bash') + return [ + '- Selected shell: Bash. Use Bash syntax, including on Windows when Bash is the selected fallback.', + ]; + if (shell === 'sh') + return [ + '- Selected shell: POSIX sh. Use POSIX shell syntax; Bash arrays, [[ ... ]] and process substitution are unavailable.', + ]; + return [ + shell === 'pwsh' + ? '- Selected shell: PowerShell 7 (pwsh). Pipeline-chain operators && and || are supported; use PowerShell quoting and variable syntax.' + : '- Selected shell: Windows PowerShell 5.1. The && and || operators are unsupported; use separate statements and explicit success checks.', + '- Use $env:VAR and single quotes for literals/regex. Do not use Bash export, /dev/null, heredocs, sed -i, or backslash quoting. Do not wrap ordinary commands in powershell -Command or cmd /c.', + "- Start multi-statement scripts with $ErrorActionPreference = 'Stop'. Check $LASTEXITCODE after native programs, or enable $PSNativeCommandUseErrorActionPreference when available.", + `- Prefer dedicated file tools. If shell file I/O is necessary, specify UTF-8 and avoid Get-Content | ... | Set-Content editing pipelines.${ + shell === 'powershell' + ? ' PowerShell 5.1 -Encoding UTF8 writes a BOM.' + : ' PowerShell 7 defaults to UTF-8 without BOM.' + }`, + '- If a CLI is missing, check .cmd/.ps1 wrappers. Change strategy after repeated syntax failures.', + ]; +} diff --git a/packages/agent-tools/src/desktop/local-bash-input.ts b/packages/agent-tools/src/desktop/local-bash-input.ts new file mode 100644 index 00000000..aba2d6f8 --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-input.ts @@ -0,0 +1,14 @@ +import { Type } from '@sinclair/typebox'; + +export const BashDescriptionSchema = Type.Optional( + Type.String({ + description: 'A short description of what this command does.', + }), +); + +export function resolveBashDescription(description: unknown, command: string): string { + if (description !== undefined && typeof description !== 'string') { + throw new Error('Bash description must be a string.'); + } + return description || command; +} diff --git a/packages/agent-tools/src/desktop/local-bash-result.ts b/packages/agent-tools/src/desktop/local-bash-result.ts new file mode 100644 index 00000000..ca29c048 --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-result.ts @@ -0,0 +1,99 @@ +import { + BashExecutionError, + type BashExecutionOutcome, + type BashToolDetails, +} from '@earendil-works/pi-coding-agent/tools'; +import type { AgentToolResult } from '@earendil-works/pi-agent-core'; + +import type { LocalBackgroundBashExecutorResult } from './types.js'; + +export interface LocalBashExecutionOutcome extends Omit { + reason: BashExecutionOutcome['reason'] | 'preflight_failed' | 'watchdog_timeout'; +} + +export function localBashResultFromPi( + result: AgentToolResult, +): LocalBackgroundBashExecutorResult { + const details = (result.details ?? {}) as Record; + const execution = readBashExecutionOutcome(details); + return { + text: result.content + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map((part) => part.text) + .join('\n'), + details, + ...(execution && execution.status !== 'succeeded' ? { isError: true } : {}), + }; +} + +export function localBashResultFromError( + error: unknown, + signal?: AbortSignal, +): LocalBackgroundBashExecutorResult { + const details: BashToolDetails = error instanceof BashExecutionError ? error.details : {}; + const cause = error instanceof BashExecutionError ? error.cause : error; + const causeRecord = + cause && typeof cause === 'object' ? (cause as Record) : undefined; + const errorCode = typeof causeRecord?.code === 'string' ? causeRecord.code : undefined; + const preflight = + errorCode?.startsWith('SANDBOX_') && + (causeRecord?.stage === undefined || + ['init', 'parse', 'invocation', 'pre-spawn'].includes(String(causeRecord.stage))); + const text = error instanceof Error ? error.message : String(error); + const execution: LocalBashExecutionOutcome = { + status: signal?.aborted ? 'canceled' : 'failed', + reason: signal?.aborted ? 'canceled' : 'unknown', + exitCode: null, + message: text, + ...(errorCode ? { errorCode } : {}), + ...details.execution, + ...(preflight ? { reason: 'preflight_failed' as const } : {}), + }; + if (execution.status === 'canceled' && !execution.cancellationReason && signal?.aborted) { + execution.cancellationReason = + signal.reason instanceof Error + ? signal.reason.message + : typeof signal.reason === 'string' + ? signal.reason + : undefined; + } + return { text, details: { ...details, execution }, isError: true }; +} + +export function readBashExecutionOutcome(details: unknown): LocalBashExecutionOutcome | undefined { + if (!details || typeof details !== 'object' || !('execution' in details)) return undefined; + const execution = details.execution; + if (!execution || typeof execution !== 'object') return undefined; + const value = execution as Record; + if ( + typeof value.status !== 'string' || + !['succeeded', 'failed', 'canceled'].includes(value.status) || + typeof value.reason !== 'string' || + ![ + 'exited', + 'signaled', + 'command_timeout', + 'canceled', + 'spawn_failed', + 'unknown', + 'preflight_failed', + 'watchdog_timeout', + ].includes(value.reason) || + !( + value.exitCode === null || + (typeof value.exitCode === 'number' && Number.isInteger(value.exitCode)) + ) || + [value.signal, value.errorCode, value.message, value.cancellationReason].some( + (field) => field !== undefined && typeof field !== 'string', + ) + ) + return undefined; + return execution as LocalBashExecutionOutcome; +} + +export function formatBashExecutionOutcome(execution: LocalBashExecutionOutcome): string { + if (execution.message) return execution.message; + if (execution.signal) return `Command terminated by signal ${execution.signal}`; + if (execution.exitCode !== null) return `Command exited with code ${execution.exitCode}`; + return `Command ${execution.status} (${execution.reason})`; +} diff --git a/packages/agent-tools/src/desktop/local-bash-timing.ts b/packages/agent-tools/src/desktop/local-bash-timing.ts new file mode 100644 index 00000000..57710fff --- /dev/null +++ b/packages/agent-tools/src/desktop/local-bash-timing.ts @@ -0,0 +1,62 @@ +import type { ToolResult } from '@mavis/agent-core/tools'; + +export const DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS = 120; +export const MAX_FOREGROUND_BASH_TIMEOUT_SECONDS = 300; +export const MAX_MANAGED_BASH_TIMEOUT_SECONDS = 600; +export const MAX_BASH_TIMEOUT_SECONDS = 2_147_483; +export const DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS = 60_000; + +export interface LocalBashTiming { + commandTimeoutSeconds?: number; + requestedTimeoutSeconds?: number; + commandTimerStartedAt?: number; + commandDeadlineAt?: number; +} + +export function resolveLocalBashTiming( + timeout: number | undefined, + mode: 'direct_foreground' | 'managed_foreground' | 'explicit_background', +): LocalBashTiming { + if ( + timeout !== undefined && + (!Number.isFinite(timeout) || timeout <= 0 || timeout > MAX_BASH_TIMEOUT_SECONDS) + ) { + throw new Error( + `timeout must be a finite positive number of seconds, at most ${MAX_BASH_TIMEOUT_SECONDS}.`, + ); + } + const commandTimeoutSeconds = + mode === 'explicit_background' + ? timeout + : mode === 'managed_foreground' + ? Math.min(timeout ?? MAX_MANAGED_BASH_TIMEOUT_SECONDS, MAX_MANAGED_BASH_TIMEOUT_SECONDS) + : Math.min( + timeout ?? DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + ); + return { + commandTimeoutSeconds, + ...(timeout !== undefined && timeout !== commandTimeoutSeconds + ? { requestedTimeoutSeconds: timeout } + : {}), + }; +} + +export function validateBashCommand(command: string): void { + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('command must be a non-empty shell command.'); + } +} + +export function withLocalBashTiming(result: ToolResult, timing: LocalBashTiming): ToolResult { + const text = + timing.requestedTimeoutSeconds !== undefined + ? `${result.text}\n\n[bash timeout: requested ${timing.requestedTimeoutSeconds}s; effective ${timing.commandTimeoutSeconds}s command limit.]` + : result.text; + return { + ...result, + text, + content: [{ type: 'text', text }], + details: { ...result.details, timing: { ...(result.details?.timing as object), ...timing } }, + }; +} diff --git a/packages/agent-tools/src/desktop/local-pi-tools.ts b/packages/agent-tools/src/desktop/local-pi-tools.ts index 81c0c3ed..a206248c 100644 --- a/packages/agent-tools/src/desktop/local-pi-tools.ts +++ b/packages/agent-tools/src/desktop/local-pi-tools.ts @@ -21,6 +21,19 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'; import { homedir, release } from 'node:os'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; +import { resolveBashDescription } from './local-bash-input.js'; +import { + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + resolveLocalBashTiming, + validateBashCommand, + withLocalBashTiming, + type LocalBashTiming, +} from './local-bash-timing.js'; +import { + localBashResultFromError, + localBashResultFromPi, + readBashExecutionOutcome, +} from './local-bash-result.js'; import { LocalBashToolDef, @@ -65,6 +78,7 @@ import { buildWriteToolResult, createCapturingWriteOperations } from '../shared/ import { applyDesktopTextLimit, DESKTOP_BASH_MAX_BYTES, + DESKTOP_BASH_PREVIEW_BYTES, DESKTOP_READ_TEXT_MAX_BYTES, limitDesktopHeadTailLines, limitDesktopPrefixLines, @@ -372,6 +386,11 @@ export class LocalBashTool implements ToolImpl< // The background executor and the pi-turn-runner fallback apply the same // hook — keep the three spawn sites in sync. this.tool = createBashTool(workspaceRoot, { + output: { + strategy: 'head_tail', + maxBytes: DESKTOP_BASH_PREVIEW_BYTES, + maxLines: Number.MAX_SAFE_INTEGER, + }, spawnHook: (ctx) => { const { env, removed } = sanitizeBashSubprocessEnv(ctx.env, this.envPolicy); this.lastEnvRemoved = removed; @@ -386,6 +405,49 @@ export class LocalBashTool implements ToolImpl< signal?: AbortSignal, ): Promise { if (signal?.aborted) throw new Error('Operation aborted'); + let timing: LocalBashTiming; + let description: string; + try { + description = resolveBashDescription(input.description, input.command); + validateBashCommand(input.command); + timing = resolveLocalBashTiming( + input.timeout, + input.run_in_background + ? 'explicit_background' + : ctx.canConsumeBackgroundBashOutput === true && + ctx.allowBashAutoPromotion !== false && + this.backgroundAdapter?.runManagedForeground + ? 'managed_foreground' + : 'direct_foreground', + ); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + return { + tool_name: LocalBashToolDef.name, + text, + content: [{ type: 'text', text }], + isError: true, + details: { status: 'invalid_input', error_code: 'BASH_INVALID_INPUT' }, + }; + } + try { + const result = await this.executeValidated(ctx, { ...input, description }, timing, signal); + return { ...result, details: { ...result.details, description } }; + } catch (error) { + if (error instanceof Error) { + const details = (error as Error & { details?: Record }).details; + Object.assign(error, { details: { ...details, description } }); + } + throw error; + } + } + + private async executeValidated( + ctx: LocalRuntimeToolContext, + input: LocalBashToolInput, + timing: LocalBashTiming, + signal?: AbortSignal, + ): Promise { if (input.run_in_background === true && ctx.canConsumeBackgroundBashOutput !== true) { return unavailableBackgroundBashOutputResult(); } @@ -426,16 +488,24 @@ export class LocalBashTool implements ToolImpl< return backgroundBashToolResult( started.taskId, 'started', - this.consumeEnvSanitizationHint(started.details), + this.consumeEnvSanitizationHint({ + ...started.details, + description: input.description, + timing: { ...timing, ...(started.details?.timing as object) }, + }), ); } const { run_in_background: _runInBackground, ...rest } = input; - const foregroundInput = { ...rest, timeout: resolveForegroundTimeout(rest) }; - if (ctx.allowBashAutoPromotion !== false && this.backgroundAdapter?.runManagedForeground) { + const foregroundInput = { ...rest, timeout: timing.commandTimeoutSeconds }; + if ( + ctx.canConsumeBackgroundBashOutput === true && + ctx.allowBashAutoPromotion !== false && + this.backgroundAdapter?.runManagedForeground + ) { const managed = await this.backgroundAdapter.runManagedForeground( ctx, - foregroundInput, + rest, DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, signal, ); @@ -444,7 +514,11 @@ export class LocalBashTool implements ToolImpl< return backgroundBashToolResult( managed.taskId, 'auto_promoted', - this.consumeEnvSanitizationHint(managed.details), + this.consumeEnvSanitizationHint({ + ...managed.details, + description: input.description, + timing: { ...timing, ...(managed.details?.timing as object) }, + }), ); } const text = `${managed.errorMessage ?? 'Local managed bash failed to start'}`; @@ -460,27 +534,36 @@ export class LocalBashTool implements ToolImpl< typeof managed.details?.fullOutputPath === 'string' ? managed.details.fullOutputPath : undefined; - const limited = limitDesktopBashOutput(managed.text, managedFullOutputPath); + const managedDetails = this.consumeEnvSanitizationHint(managed.details); - return applyDesktopTextLimit( - { + const result = withLocalBashTiming( + withCompatibleBashToolResponseFromPiDetails({ tool_name: LocalBashToolDef.name, text: managed.text, content: [{ type: 'text', text: managed.text }], details: { ...managedDetails, - status: 'completed', + status: readBashExecutionOutcome(managedDetails)?.status ?? 'completed', task_id: managed.taskId, }, ...(managed.isError ? { isError: true } : {}), - }, - limited, + }), + timing, + ); + return applyDesktopTextLimit( + result, + limitDesktopBashOutput(result.text, managedFullOutputPath, result.details, managed.taskId), ); } let result: ToolResult; try { const tool = this.sandboxOperationsFactory ? createBashTool(this.workspaceRoot, { + output: { + strategy: 'head_tail', + maxBytes: DESKTOP_BASH_PREVIEW_BYTES, + maxLines: Number.MAX_SAFE_INTEGER, + }, operations: this.sandboxOperationsFactory.create({ identity: directForegroundIdentity(ctx), workspaceRoot: this.workspaceRoot, @@ -493,28 +576,29 @@ export class LocalBashTool implements ToolImpl< }) : this.tool; const res = await tool.execute('', foregroundInput, signal); - result = withCompatibleBashToolResponseFromPiDetails( - toToolResult(LocalBashToolDef.name, res), - ); + const completed = localBashResultFromPi(res); + result = withCompatibleBashToolResponseFromPiDetails({ + tool_name: LocalBashToolDef.name, + ...completed, + content: [{ type: 'text', text: completed.text }], + }); } catch (error) { if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) { throw error; } - const text = error instanceof Error ? error.message : String(error); - const fullOutputPath = extractBashFullOutputPath(text); - result = { + const failed = localBashResultFromError(error, signal); + result = withCompatibleBashToolResponseFromPiDetails({ tool_name: LocalBashToolDef.name, - text, - content: [{ type: 'text', text }], - ...(fullOutputPath ? { details: { fullOutputPath } } : {}), - isError: true, - }; + ...failed, + content: [{ type: 'text', text: failed.text }], + }); } + result = withLocalBashTiming(result, timing); const fullOutputPath = typeof result.details?.fullOutputPath === 'string' ? result.details.fullOutputPath : undefined; - const limited = limitDesktopBashOutput(result.text, fullOutputPath); + const limited = limitDesktopBashOutput(result.text, fullOutputPath, result.details); result = applyDesktopTextLimit(result, limited); result.details = this.consumeEnvSanitizationHint({ ...(result.details ?? {}), @@ -550,57 +634,67 @@ function directForegroundIdentity(ctx: LocalRuntimeToolContext): LocalSandboxInv }; } -function limitDesktopBashOutput(text: string, fullOutputPath?: string) { - const limited = limitDesktopHeadTailLines(text, { +function limitDesktopBashOutput( + text: string, + fullOutputPath?: string, + details?: Record, + taskId?: string, +) { + const output = details?.output as { rawBytes?: number; persistenceError?: string } | undefined; + let previewText = text; + if (!taskId) { + if ((details?.truncation as { truncated?: boolean } | undefined)?.truncated) { + previewText += `\n\n[desktop bash output truncated: original_bytes=${output?.rawBytes}; original head+tail shown.${fullOutputPath ? ` Full output: ${fullOutputPath}` : ''}]`; + } + if (output?.persistenceError) { + previewText += `\n\n[Output persistence failed; complete output is unavailable: ${output.persistenceError}]`; + } + } + const limited = limitDesktopHeadTailLines(previewText, { maxBytes: DESKTOP_BASH_MAX_BYTES, notice: ({ originalBytes }) => fullOutputPath ? `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated; Full output: ${fullOutputPath}]` - : `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated; rerun with a narrower command or redirect output to a file and use read.]`, + : `[desktop bash output truncated: original_bytes=${originalBytes}; head+tail shown; boundary lines may be UTF-8 byte-truncated.]`, }); + if ( + limited.truncation || + (details?.truncation as { truncated?: boolean } | undefined)?.truncated + ) { + limited.truncation = { + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + original_bytes: + output?.rawBytes ?? limited.truncation?.original_bytes ?? Buffer.byteLength(text, 'utf8'), + returned_bytes: Buffer.byteLength(limited.text, 'utf8'), + max_bytes: DESKTOP_BASH_MAX_BYTES, + }; + } return withDesktopOutputContinuation(limited, { - continuation_hint: fullOutputPath + continuation_hint: taskId ? { - tool: 'read', - preserve_args: ['path'], - instruction: `Read the full output at ${fullOutputPath}; continue long reads with the returned next_offset.`, + tool: 'task_output', + preserve_args: ['task_id'], + instruction: `Read task ${taskId} with task_output; continue with its next_offset (UTF-8 log file bytes). Check output persistence status before treating the log as complete.`, } - : { - tool: 'bash', - preserve_args: [], - instruction: - 'Do not blindly rerun a potentially side-effecting command; narrow it or redirect output to a file, then use read.', - }, + : fullOutputPath + ? { + tool: 'read', + preserve_args: ['path'], + instruction: `Read the full output at ${fullOutputPath}; continue long reads with the returned next_offset.`, + } + : { + tool: 'bash', + preserve_args: [], + instruction: + 'Do not blindly rerun a potentially side-effecting command; narrow it or redirect output to a file, then use read.', + }, }); } -function extractBashFullOutputPath(text: string): string | undefined { - const matches = [...text.matchAll(/Full output:\s*(.+?)\](?:\n|$)/gi)]; - const path = matches.at(-1)?.[1]?.trim(); - return path || undefined; -} - -/** - * Default foreground timeout (seconds) applied when the model omits `timeout`. - * Restores main's historical 120s bound so interactive/blocking foreground - * commands (e.g. `lark-cli auth login`) cannot hang forever. Background tasks - * (`run_in_background: true`) are intentionally NOT bounded by this. - */ -export const DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS = 120; -export const MAX_FOREGROUND_BASH_TIMEOUT_SECONDS = 300; -export const DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS = 15_000; - -/** - * Keep interactive foreground commands bounded. Missing, non-finite and - * non-positive values use the 120s default; larger values are capped at 5m. - * Longer commands should opt into background execution explicitly. - */ export function resolveForegroundTimeout(input: { timeout?: number }): number { - const requested = input.timeout; - if (requested === undefined || !Number.isFinite(requested) || requested <= 0) { - return DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS; - } - return Math.min(requested, MAX_FOREGROUND_BASH_TIMEOUT_SECONDS); + return resolveLocalBashTiming(input.timeout, 'direct_foreground').commandTimeoutSeconds!; } function backgroundBashToolResult( @@ -611,8 +705,15 @@ function backgroundBashToolResult( const lead = status === 'auto_promoted' ? 'The command is still running and was yielded to a managed background task without restarting it.' - : 'Started local background bash task.'; - const text = `\n${lead} The owning conversation will automatically resume when it finishes; use task_query/task_output to inspect incremental output and task_stop to cancel it.\n`; + : 'Background Bash task accepted; command startup may still be in progress.'; + const timing = details.timing as LocalBashTiming | undefined; + const timingText = + timing?.commandTimeoutSeconds !== undefined + ? ` Command limit: ${timing.commandTimeoutSeconds}s total; yielding does not reset it.` + : ''; + const purpose = + typeof details.description === 'string' ? `Purpose: ${details.description}\n` : ''; + const text = `\n${purpose}${lead}${timingText} The owning conversation will automatically resume when it finishes; use task_output to inspect incremental output, and task_stop to cancel if that tool is available.\n`; return { tool_name: LocalBashToolDef.name, text, @@ -735,3 +836,9 @@ async function pathExists(filePath: string): Promise { function isFsNotFoundError(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'ENOENT'; } + +export { + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, +} from './local-bash-timing.js'; diff --git a/packages/agent-tools/src/desktop/local-task-control.ts b/packages/agent-tools/src/desktop/local-task-control.ts index 48f7f18b..cb05921f 100644 --- a/packages/agent-tools/src/desktop/local-task-control.ts +++ b/packages/agent-tools/src/desktop/local-task-control.ts @@ -14,6 +14,7 @@ import type { LocalTaskControlAdapter, LocalTaskOutputReadResult, } from './types.js'; +import { formatBashExecutionOutcome, readBashExecutionOutcome } from './local-bash-result.js'; const MAX_TASK_OUTPUT_WAIT_MS = 30_000; @@ -82,6 +83,13 @@ export class LocalTaskOutputTool implements ToolImpl< ...(signal ? { signal } : {}), }); const status = read.status ?? task.status; + const snapshot = read.task ?? task; + const execution = readBashExecutionOutcome(snapshot.metadata?.bashDetails); + const output = (snapshot.metadata?.bashDetails as Record | undefined)?.output; + const persistenceHint = + (output as { persistence?: string } | undefined)?.persistence === 'incomplete' + ? 'Output persistence is incomplete; this log may be missing command output.\n' + : ''; const body = read.content || '(no output yet)'; const cursor = [ ...(read.nextOffset === undefined ? [] : [`next_offset="${read.nextOffset}"`]), @@ -94,10 +102,18 @@ export class LocalTaskOutputTool implements ToolImpl< ? '' : 'Your requested wait_ms exceeded 30000 ms and was capped at 30000 ms (30 seconds). This output read reached its wait limit; the background task was not stopped. Use wait_ms=30000 or less for future reads.\n'; const hint = signal?.aborted ? '' : this.pollingHint(ctx, input.task_id, { ...read, status }); - const text = `\n${receipt}${waitLimitHint}${hint}${body}\n`; - return ok(LocalTaskOutputToolDef.name, text, { + const outcome = execution + ? `${formatBashExecutionOutcome(execution)}\n` + : ''; + const text = `\n${receipt}${waitLimitHint}${persistenceHint}${hint}${outcome}${body}\n`; + const result = ok(LocalTaskOutputToolDef.name, text, { task_id: input.task_id, status, + ...(execution ? { execution } : {}), + ...(output ? { output } : {}), + timing: + (snapshot.metadata?.bashDetails as Record | undefined)?.timing ?? + snapshot.metadata?.timing, effective_wait_ms: effectiveWaitMs, wait_ms_clamped: waitMsClamped, ...(read.timedOut === undefined ? {} : { timed_out: read.timedOut }), @@ -105,6 +121,7 @@ export class LocalTaskOutputTool implements ToolImpl< ...(read.truncated === undefined ? {} : { truncated: read.truncated }), ...(read.summary ? { summary: read.summary } : {}), }); + return result; } private pollingHint( @@ -152,8 +169,21 @@ export class LocalTaskStopTool implements ToolImpl< async execute(ctx: LocalRuntimeToolContext, input: LocalTaskStopToolInput): Promise { const task = await this.adapter.stop(ctx, input.task_id, input.reason); if (!task) return notFound(LocalTaskStopToolDef.name, input.task_id); - const text = `\nStop requested for ${input.task_id}.\n`; - return ok(LocalTaskStopToolDef.name, text, { task_id: input.task_id, status: task.status }); + const stopError = + task.metadata?.stopError ?? + (task.lastError?.code === 'TASK_STOP_FAILED' ? task.lastError : undefined); + const message = stopError + ? `Stop failed for ${input.task_id}: ${(stopError as { message: string }).message}. Process cleanup could not be confirmed.` + : `Task ${input.task_id} is ${task.status}.`; + const text = `\n${message}\n`; + return { + ...ok(LocalTaskStopToolDef.name, text, { + task_id: input.task_id, + status: task.status, + ...(stopError ? { stopError } : {}), + }), + ...(stopError ? { isError: true } : {}), + }; } } @@ -167,16 +197,28 @@ function renderTask(task: BackgroundTask): string { ...(mode ? [`execution_mode=${mode}`] : []), ]; const lineageText = lineage.length === 0 ? '' : ` (${lineage.join(' ')})`; - return `- ${task.taskId} [${task.kind}/${task.status}]${description}${lineageText}`; + const execution = readBashExecutionOutcome(task.metadata?.bashDetails); + const outcome = execution ? ` — ${formatBashExecutionOutcome(execution)}` : ''; + const output = (task.metadata?.bashDetails as { output?: { persistence?: string } } | undefined) + ?.output; + const persistence = output?.persistence === 'incomplete' ? ' — output log incomplete' : ''; + return `- ${task.taskId} [${task.kind}/${task.status}]${description}${lineageText}${outcome}${persistence}`; } function summarizeTask(task: BackgroundTask): Record { const sessionId = childSessionId(task); const mode = executionMode(task); + const execution = readBashExecutionOutcome(task.metadata?.bashDetails); return { task_id: task.taskId, kind: task.kind, status: task.status, + ...(execution ? { execution } : {}), + output: (task.metadata?.bashDetails as Record | undefined)?.output, + timing: + (task.metadata?.bashDetails as Record | undefined)?.timing ?? + task.metadata?.timing, + ...(task.metadata?.stopError ? { stopError: task.metadata.stopError } : {}), // Handle recovery after the first receipt scrolled out of context. Only a // subagent task that really owns a child Session gets a session handle. ...(sessionId ? { session_id: sessionId } : {}), diff --git a/packages/agent-tools/src/desktop/output-limit.ts b/packages/agent-tools/src/desktop/output-limit.ts index b7225c83..eaf2e971 100644 --- a/packages/agent-tools/src/desktop/output-limit.ts +++ b/packages/agent-tools/src/desktop/output-limit.ts @@ -3,12 +3,14 @@ import type { ToolResult, ToolResultContent } from '@mavis/agent-core/tools'; export const DESKTOP_GREP_CONTENT_MAX_BYTES = 16 * 1024; export const DESKTOP_READ_TEXT_MAX_BYTES = 24 * 1024; export const DESKTOP_BASH_MAX_BYTES = 24 * 1024; +// Leave room for exit/timing receipts and the readable output reference. +export const DESKTOP_BASH_PREVIEW_BYTES = DESKTOP_BASH_MAX_BYTES - 2048; export type DesktopOutputLimitStrategy = 'prefix_lines' | 'head_tail_lines'; export type DesktopOutputOffsetUnit = 'line' | 'match' | 'file'; export interface DesktopOutputContinuationHint { - tool: 'read' | 'grep' | 'glob' | 'mavis' | 'bash'; + tool: 'read' | 'grep' | 'glob' | 'mavis' | 'bash' | 'task_output'; preserve_args: readonly string[]; instruction: string; } diff --git a/packages/agent-tools/src/desktop/types.ts b/packages/agent-tools/src/desktop/types.ts index 20f6f0b6..69f02edb 100644 --- a/packages/agent-tools/src/desktop/types.ts +++ b/packages/agent-tools/src/desktop/types.ts @@ -322,6 +322,7 @@ export interface LocalSandboxBashOperationsFactory { export interface LocalBackgroundBashExecutorResult { text: string; details?: Record; + isError?: boolean; } export interface LocalBackgroundBashExecutor { @@ -434,6 +435,8 @@ export interface LocalTaskOutputReadOptions extends TaskOutputReadOptions { export interface LocalTaskOutputReadResult extends TaskOutputReadResult { /** Status read from the same post-wait snapshot as the returned output. */ status?: BackgroundTaskStatus; + /** Task facts from the same post-wait snapshot as status. */ + task?: BackgroundTask; timedOut?: boolean; } diff --git a/packages/agent-tools/test/desktop/local-bash-output.test.ts b/packages/agent-tools/test/desktop/local-bash-output.test.ts new file mode 100644 index 00000000..ef3228c8 --- /dev/null +++ b/packages/agent-tools/test/desktop/local-bash-output.test.ts @@ -0,0 +1,207 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { LocalBashTool } from '../../src/desktop/local-pi-tools.js'; +import { DESKTOP_BASH_MAX_BYTES } from '../../src/desktop/output-limit.js'; +import { readPluginHookCompatibleToolResponse } from '../../src/plugin-hooks/vendor-tool-response.js'; +import type { LocalRuntimeToolContext } from '../../src/desktop/types.js'; + +const SESSION_CTX = { + sessionId: 'sess-test', + turnId: 'turn-test', +} satisfies LocalRuntimeToolContext; + +describe('LocalBashTool — foreground output truncation', () => { + let workspace: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'bash-output-')); + }); + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it('preserves exact stdout and stderr for Compatible PostToolUse', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { + command: "printf 'from-stdout'; printf 'from-stderr' >&2", + }); + + expect(readPluginHookCompatibleToolResponse(result)).toEqual({ + stdout: 'from-stdout', + stderr: 'from-stderr', + interrupted: false, + }); + }); + + it('short output is returned verbatim, not truncated', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { command: 'printf "a\\nb\\nc\\n"' }); + expect(result.tool_name).toBe('bash'); + expect(result.isError).toBeFalsy(); + expect(result.text).toContain('a'); + expect(result.text.toLowerCase()).not.toContain('full output:'); + }); + + it.skipIf(process.platform === 'win32')('reports signal termination as a failed tool result with partial output', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { + command: "printf 'partial-output'; kill -TERM $$", + timeout: 3, + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('partial-output'); + expect(result.text).toContain('Command terminated by signal SIGTERM'); + expect(result.details).toMatchObject({ + execution: { status: 'failed', reason: 'signaled', exitCode: null, signal: 'SIGTERM' }, + processOutput: { stdout: 'partial-output', stderr: '' }, + }); + }); + + it('preserves original head and tail with one readable full output file', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<400;i++) console.log('row-'+i+'-'+'x'.repeat(90))`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('row-0-'); + expect(result.text).toContain('row-399-'); + expect(result.text).not.toContain('row-200-'); + expect(result.text.toLowerCase()).toContain('full output:'); + const full = await readFile(result.details?.fullOutputPath as string, 'utf8'); + expect(full).toContain('row-200-'); + expect(result.details?.output).toMatchObject({ rawBytes: Buffer.byteLength(full), persistence: 'complete' }); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + expect(result.details?.desktop_output_truncation).not.toHaveProperty('next_offset'); + expect(result.details?.desktop_output_truncation).not.toHaveProperty('offset_unit'); + }); + + it('caps a thrown timeout result while preserving the error ToolResult status', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockRejectedValue( + new Error( + `${'timeout-output\n'.repeat(2_000)}\n\nCommand timed out after 1 seconds`, + ), + ); + + const result = await tool.execute(SESSION_CTX, { command: 'ignored', timeout: 1 }); + + expect(innerExecute).toHaveBeenCalledOnce(); + expect(result.isError).toBe(true); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('Command timed out after 1 seconds'); + expect(result.text).toContain('desktop bash output truncated'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'bash', + preserve_args: [], + }, + }); + }); + + it('still propagates an active AbortSignal instead of converting cancellation to a tool result', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const controller = new AbortController(); + vi.spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute').mockImplementation( + async () => { + controller.abort(); + throw new Error('Command aborted'); + }, + ); + + await expect( + tool.execute(SESSION_CTX, { command: 'ignored' }, controller.signal), + ).rejects.toThrow('Command aborted'); + }); + + it('caps a large nonzero-exit result while preserving the error ToolResult status', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<400;i++) console.error('error-row-'+i+'-'+'e'.repeat(90)); process.exit(7)`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + expect(result.isError).toBe(true); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + expect(result.text).toContain('error-row-0-'); + expect(result.text).toContain('error-row-399-'); + expect(result.text).not.toContain('error-row-200-'); + expect(result.text).toContain('Command exited with code 7'); + expect(result.details).toMatchObject({ + execution: { status: 'failed', reason: 'exited', exitCode: 7 }, + processOutput: { exitCode: 7, interrupted: false }, + }); + expect(result.text).toContain('desktop bash output truncated'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + }); + + it('output beyond 2000 lines retains the original first and last lines', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const script = `for(let i=0;i<3000;i++) console.log('spill-row-'+i+'-'+'z'.repeat(50))`; + const command = `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`; + const result = await tool.execute(SESSION_CTX, { command }); + + const lower = result.text.toLowerCase(); + expect(lower).toContain('full output:'); + expect(lower).toContain('original head+tail'); + expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual( + DESKTOP_BASH_MAX_BYTES, + ); + + expect(result.text).toContain('spill-row-2999-'); + expect(result.text).toContain('spill-row-0-'); + expect(result.details?.desktop_output_truncation).toMatchObject({ + truncated: true, + has_more: true, + strategy: 'head_tail_lines', + max_bytes: DESKTOP_BASH_MAX_BYTES, + continuation_hint: { + tool: 'read', + preserve_args: ['path'], + }, + }); + + // Recover the spilled path from the footer and confirm it holds the FULL output. + const m = result.text.match(/full output:\s*(.+?)\]/i); + expect(m).not.toBeNull(); + const full = await readFile((m as RegExpMatchArray)[1].trim(), 'utf-8'); + const fullLines = full.split('\n').filter((l) => l.length > 0); + expect(fullLines).toHaveLength(3000); // complete output, not the 50 KiB tail + expect(fullLines[0]).toContain('spill-row-0-'); + expect(fullLines[fullLines.length - 1]).toContain('spill-row-2999-'); + }); +}); diff --git a/packages/agent-tools/test/desktop/local-bash-timeout.test.ts b/packages/agent-tools/test/desktop/local-bash-timeout.test.ts new file mode 100644 index 00000000..e409ad9b --- /dev/null +++ b/packages/agent-tools/test/desktop/local-bash-timeout.test.ts @@ -0,0 +1,506 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { Value } from '@sinclair/typebox/value'; + +import { LocalBashToolDef } from '../../src/desktop/builtin-defs.js'; +import { createLocalBashToolDefinition } from '../../src/desktop/local-bash-contract.js'; +import { + DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + LocalBashTool, + MAX_FOREGROUND_BASH_TIMEOUT_SECONDS, + resolveForegroundTimeout, +} from '../../src/desktop/local-pi-tools.js'; +import type { + LocalBashAdapter, + LocalBashBackgroundStartResult, + LocalRuntimeToolContext, + LocalSandboxBashOperationsFactory, +} from '../../src/desktop/types.js'; + +const SESSION_CTX = { + sessionId: 'sess-test', + turnId: 'turn-test', +} satisfies LocalRuntimeToolContext; + +function timeoutDescription(): string { + const props = (LocalBashToolDef.schema as { properties: Record }) + .properties; + return props.timeout?.description ?? ''; +} + +describe('resolveForegroundTimeout (bounded foreground helper)', () => { + // TS-1 (P0): omitted timeout → 120s default. + it('defaults to 120 seconds when timeout is omitted', () => { + expect(resolveForegroundTimeout({})).toBe(120); + expect(resolveForegroundTimeout({})).toBe(DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS); + expect(DEFAULT_FOREGROUND_BASH_TIMEOUT_SECONDS).toBe(120); + }); + + it('defaults to 120 seconds when timeout is explicitly undefined', () => { + expect(resolveForegroundTimeout({ timeout: undefined })).toBe(120); + }); + + it('honors a positive timeout up to the 300-second foreground cap', () => { + expect(resolveForegroundTimeout({ timeout: 5 })).toBe(5); + expect(resolveForegroundTimeout({ timeout: 100000 })).toBe(300); + expect(MAX_FOREGROUND_BASH_TIMEOUT_SECONDS).toBe(300); + }); + + it.each([0, -5, NaN, Infinity, 2_147_484])('rejects an invalid timeout %s before execution', (timeout) => { + expect(() => resolveForegroundTimeout({ timeout })).toThrow('finite positive'); + }); + + it('preserves fractional seconds', () => { + expect(resolveForegroundTimeout({ timeout: 0.125 })).toBe(0.125); + }); +}); + +it('requires a positive timeout in the shared schema', () => { + expect(Value.Check(LocalBashToolDef.schema, { description: '运行测试命令', command: 'echo ok', timeout: 0.5 })).toBe(true); + expect(Value.Check(LocalBashToolDef.schema, { description: '运行测试命令', command: 'echo ok', timeout: 0 })).toBe(false); +}); + +describe('LocalBashTool input timing contract', () => { + it.each([null, 42, false])('rejects non-string description %j before dispatch', async (description) => { + const startBackground = vi.fn(); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground }, { mode: 'off' }); + const input = { command: 'echo ok', description }; + expect(Value.Check(LocalBashToolDef.schema, input)).toBe(false); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, input as never); + expect(result).toMatchObject({ isError: true, details: { error_code: 'BASH_INVALID_INPUT' } }); + expect(startBackground).not.toHaveBeenCalled(); + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it.each([undefined, ''])('uses the command when description is %j', async (description) => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'task-description' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground }, { mode: 'off' }); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + command: 'echo exact', description, run_in_background: true, + }); + expect(startBackground.mock.calls[0]?.[1]).toMatchObject({ command: 'echo exact', description: 'echo exact' }); + expect(result.details?.description).toBe('echo exact'); + }); + + it('keeps the purpose separate from the executable command', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'task-description' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground }, { mode: 'off' }); + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + command: ' echo exact ', description: '检查工作区状态', run_in_background: true, + }); + expect(startBackground.mock.calls[0]?.[1]).toMatchObject({ command: ' echo exact ', description: '检查工作区状态' }); + expect(result.text).toContain('Purpose: 检查工作区状态'); + expect(result.details?.description).toBe('检查工作区状态'); + }); + + it.each([false, true])('rejects invalid parameters without dispatching (background=%s)', async (background) => { + const startBackground = vi.fn(); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground }, { mode: 'off' }); + for (const input of [{ description: '运行测试命令', command: 'echo ok', timeout: 0 }, { description: '运行测试命令', command: 'echo ok', timeout: Infinity }, { description: '运行测试命令', command: ' ' }]) { + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { ...input, run_in_background: background }); + expect(result).toMatchObject({ isError: true, details: { error_code: 'BASH_INVALID_INPUT' } }); + } + expect(startBackground).not.toHaveBeenCalled(); + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it('reports a capped foreground timeout while preserving the command text', async () => { + const tool = new LocalBashTool('/tmp/workspace', undefined, { mode: 'off' }); + const execute = vi.spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: ' echo ok ', timeout: 600 }); + expect(execute.mock.calls[0]?.[1]).toEqual({ description: '运行测试命令', command: ' echo ok ', timeout: 300 }); + expect(result.details).toMatchObject({ timing: { requestedTimeoutSeconds: 600, commandTimeoutSeconds: 300 } }); + expect(result.text).toContain('requested 600s; effective 300s'); + }); +}); + +describe('LocalBashTool.execute — background path', () => { + // TS-3 (P0): run_in_background:true (no timeout) → 120 default NOT applied; + // dispatches to the background adapter with the input untouched. + it('dispatches to the background adapter without injecting the 120s default', async () => { + const started: LocalBashBackgroundStartResult = { + status: 'started', + taskId: 'task-1', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + }; + const startBackground = vi.fn( + async (): Promise => started, + ); + const adapter: LocalBashAdapter = { startBackground }; + const tool = new LocalBashTool('/tmp/does-not-matter', adapter, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { + description: '运行测试命令', + command: 'echo hi', + run_in_background: true, + }); + + expect(startBackground).toHaveBeenCalledTimes(1); + const forwarded = startBackground.mock.calls[0]?.[1]; + // Background path must receive the input verbatim — no 120 injected. + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', run_in_background: true }); + expect((forwarded as { timeout?: number }).timeout).toBeUndefined(); + expect(result.details).toMatchObject({ + status: 'started', + task_id: 'task-1', + envSanitized: ['MAVIS_ACCESS_TOKEN'], + }); + }); +}); + +describe('LocalBashTool.execute — foreground path forwarding (TS-4)', () => { + // TS-4 (P1): forwarded foreground input carries resolved timeout and NO + // run_in_background key. We spy on the inner pi tool to capture the forward. + it('forwards resolved timeout and strips run_in_background', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi', run_in_background: false }); + + expect(innerExecute).toHaveBeenCalledTimes(1); + const forwarded = (innerExecute.mock.calls[0] as unknown[])[1] as Record; + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', timeout: 120 }); + expect(forwarded).not.toHaveProperty('run_in_background'); + }); + + it('forwards an explicit timeout unchanged and strips run_in_background', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', undefined, { mode: 'off' }); + const innerExecute = vi + .spyOn((tool as unknown as { tool: { execute: unknown } }).tool, 'execute') + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }], details: {} } as never); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi', timeout: 7 }); + + const forwarded = (innerExecute.mock.calls[0] as unknown[])[1] as Record; + expect(forwarded).toEqual({ description: '运行测试命令', command: 'echo hi', timeout: 7 }); + expect(forwarded).not.toHaveProperty('run_in_background'); + }); + + it('carries the trusted Explore read-only restriction through direct foreground identity', async () => { + const create = vi.fn(() => ({ + exec: async () => ({ exitCode: 0 }), + })); + const tool = new LocalBashTool( + '/tmp/does-not-matter', + undefined, + { mode: 'off' }, + undefined, + { create }, + ); + const ctx = { + ...SESSION_CTX, + toolCallId: 'call-direct', + forceReadOnlyFilesystem: true, + }; + + await tool.execute(ctx, { description: '运行测试命令', command: 'true' }); + + expect(create).toHaveBeenCalledWith({ + identity: { + operationClass: 'direct_foreground', + invocationId: 'call-direct', + sessionId: 'sess-test', + turnId: 'turn-test', + toolCallId: 'call-direct', + forceReadOnlyFilesystem: true, + }, + workspaceRoot: '/tmp/does-not-matter', + }); + }); + + it('uses a unique runtime id without leaking command text when old callers omit toolCallId', async () => { + const create = vi.fn(() => ({ + exec: async () => ({ exitCode: 0 }), + })); + const tool = new LocalBashTool( + '/tmp/does-not-matter', + undefined, + { mode: 'off' }, + undefined, + { create }, + ); + + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'secret-command-one' }); + await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'secret-command-two' }); + + const identities = create.mock.calls.map(([input]) => input.identity); + expect(identities).toHaveLength(2); + expect(identities[0]).toMatchObject({ + operationClass: 'direct_foreground', + sessionId: 'sess-test', + turnId: 'turn-test', + }); + expect(identities[0]?.invocationId).toMatch(/^runtime_[0-9a-f-]{36}$/); + expect(identities[1]?.invocationId).not.toBe(identities[0]?.invocationId); + expect(JSON.stringify(identities)).not.toContain('secret-command'); + }); +}); + +describe('LocalBashTool.execute — managed foreground soft yield', () => { + it.each([0, 7])('returns direct output and exit code %i when native output is absent', async (exitCode) => { + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }, undefined, { + create: () => ({ exec: async (_command, _cwd, opts) => { + opts.onData(Buffer.from('direct stdout and stderr')); + return { exitCode }; + } }), + }); + const result = await tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: true }, { description: '运行测试命令', command: 'controlled command' }); + expect(runManagedForeground).not.toHaveBeenCalled(); + expect(result.text).toContain('direct stdout and stderr'); + expect(Boolean(result.isError)).toBe(exitCode !== 0); + expect(result.details?.task_id).toBeUndefined(); + }); + + it('forwards cancellation to foreground operations when promotion is disabled', async () => { + const controller = new AbortController(); + let started!: () => void; + const ready = new Promise((resolve) => { started = resolve; }); + const runManagedForeground = vi.fn(); + const tool = new LocalBashTool('/tmp/workspace', { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }, undefined, { + create: () => ({ exec: async (_command, _cwd, opts) => { + expect(opts.signal).toBe(controller.signal); + return new Promise((_, reject) => { + opts.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true }); + started(); + }); + } }), + }); + const pending = tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: false }, { description: '运行测试命令', command: 'controlled command' }, controller.signal); + const rejection = expect(pending).rejects.toThrow(/abort/i); + await ready; + controller.abort(); + await rejection; + expect(runManagedForeground).not.toHaveBeenCalled(); + }); + + it('refuses explicit background Bash before starting a task when native output is unavailable', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'unexpected' })); + const tool = new LocalBashTool( + '/tmp/workspace', + { startBackground, runManagedForeground: vi.fn() }, + { mode: 'off' }, + ); + + const result = await tool.execute( + { ...SESSION_CTX, canConsumeBackgroundBashOutput: false }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + + expect(result).toMatchObject({ + isError: true, + details: { + status: 'background_output_unavailable', + error_code: 'BASH_BACKGROUND_OUTPUT_UNAVAILABLE', + }, + }); + expect(result.text).toContain('missing task_output'); + expect(result.text).toContain('foreground instead'); + expect(startBackground).not.toHaveBeenCalled(); + }); + + it('allows explicit background execution when native output is admitted', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'explicit-task' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground: vi.fn() }, { mode: 'off' }); + const result = await tool.execute( + { + ...SESSION_CTX, + allowBashAutoPromotion: false, + canConsumeBackgroundBashOutput: true, + }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + expect(startBackground).toHaveBeenCalledOnce(); + expect(result.details).toMatchObject({ status: 'started', task_id: 'explicit-task' }); + }); + + it('fails closed for explicit background execution when the catalog fact is absent', async () => { + const startBackground = vi.fn(async () => ({ status: 'started' as const, taskId: 'explicit-task' })); + const tool = new LocalBashTool('/tmp/workspace', { startBackground, runManagedForeground: vi.fn() }, { mode: 'off' }); + const result = await tool.execute( + { ...SESSION_CTX, allowBashAutoPromotion: false }, + { description: '运行测试命令', command: 'controlled command', run_in_background: true }, + ); + + expect(result).toMatchObject({ + isError: true, + details: { + status: 'background_output_unavailable', + error_code: 'BASH_BACKGROUND_OUTPUT_UNAVAILABLE', + }, + }); + expect(startBackground).not.toHaveBeenCalled(); + }); + + it('returns a completed managed command inline', async () => { + const runManagedForeground = vi.fn(async () => ({ + status: 'completed' as const, + taskId: 'task-fast', + text: 'ok', + })); + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground, + }, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo ok' }); + + expect(runManagedForeground).toHaveBeenCalledWith( + { ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, + { description: '运行测试命令', command: 'echo ok' }, + DEFAULT_FOREGROUND_BASH_SOFT_YIELD_MS, + undefined, + ); + expect(result).toMatchObject({ text: 'ok', details: { status: 'completed', task_id: 'task-fast' } }); + }); + + it('reports managed env sanitization once per tool instance', async () => { + const runManagedForeground = vi.fn(async () => ({ + status: 'completed' as const, + taskId: 'task-fast', + text: 'ok', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + })); + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground, + }, { mode: 'off' }); + + const first = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo first' }); + const second = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'echo second' }); + + expect(first.details?.envSanitized).toEqual(['MAVIS_ACCESS_TOKEN']); + expect(second.details?.envSanitized).toBeUndefined(); + }); + + it('returns the same task handle when a slow command is auto-promoted', async () => { + const tool = new LocalBashTool('/tmp/does-not-matter', { + startBackground: vi.fn(), + runManagedForeground: vi.fn(async () => ({ + status: 'auto_promoted' as const, + taskId: 'task-slow', + details: { envSanitized: ['MAVIS_ACCESS_TOKEN'] }, + })), + }, { mode: 'off' }); + + const result = await tool.execute({ ...SESSION_CTX, canConsumeBackgroundBashOutput: true }, { description: '运行测试命令', command: 'sleep 30' }); + + expect(result.details).toEqual({ + description: '运行测试命令', + status: 'auto_promoted', + task_id: 'task-slow', + timing: { + commandTimeoutSeconds: 600, + }, + envSanitized: ['MAVIS_ACCESS_TOKEN'], + }); + expect(result.text).toContain('without restarting it'); + expect(result.text).toContain('Command limit: 600s total; yielding does not reset it.'); + }); +}); + +describe('LocalBashTool.execute — real foreground ops', () => { + let workspace: string; + + beforeEach(async () => { + workspace = await mkdtemp(join(tmpdir(), 'local-bash-')); + }); + afterEach(async () => { + await rm(workspace, { recursive: true, force: true }); + }); + + it('returns real output after the 60 second yield window when the Turn has no native task_output', async () => { + const runManagedForeground = vi.fn(async () => ({ status: 'auto_promoted' as const, taskId: 'unexpected-task' })); + const tool = new LocalBashTool(workspace, { startBackground: vi.fn(), runManagedForeground }, { mode: 'off' }); + const startedAt = Date.now(); + const result = await tool.execute({ ...SESSION_CTX, allowBashAutoPromotion: false }, { + description: '运行测试命令', + command: `node -e "setTimeout(() => { console.log('foreground-after-threshold'); console.error('stderr-after-threshold'); }, 61000)"`, + timeout: 90, + }); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(60_000); + expect(runManagedForeground).not.toHaveBeenCalled(); + expect(result.text).toContain('foreground-after-threshold'); + expect(result.text).toContain('stderr-after-threshold'); + expect(result.isError).toBeFalsy(); + expect(result.details?.task_id).toBeUndefined(); + }, 90_000); + + // TS-5 (P1): fast foreground command with no timeout completes normally. + it('runs a fast foreground command with no timeout and returns real output', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'echo hi' }); + expect(result.tool_name).toBe('bash'); + expect(result.text).toContain('hi'); + expect(result.isError).toBeFalsy(); + }); + + // TS-6 (P1): timeout-hit surfaces the vendored error text. Driven with a very + // short explicit timeout against a sleep to stay fast and non-flaky. + it('surfaces the vendored "Command timed out after N seconds" text', async () => { + const tool = new LocalBashTool(workspace, undefined, { mode: 'off' }); + const result = await tool.execute(SESSION_CTX, { description: '运行测试命令', command: 'sleep 5', timeout: 1 }); + expect(result.isError).toBe(true); + expect(result.text).toMatch(/Command timed out after 1 seconds/); + }); +}); + +describe('LocalBashToolDef timeout description (TS-7)', () => { + it('documents seconds, foreground limits, and background yielding', () => { + const desc = timeoutDescription(); + expect(desc).not.toContain('host-defined'); + expect(desc).toContain('seconds'); + expect(desc).toContain('120'); + expect(desc).toContain('300'); + expect(desc).toContain('600'); + expect(desc).toContain('including foreground time'); + expect(desc.toLowerCase()).toContain('foreground'); + const backgroundDesc = createLocalBashToolDefinition({ background: true, shell: 'bash' }).description; + expect(backgroundDesc).toContain('return a task_id if unfinished'); + expect(backgroundDesc).toContain('60s'); + }); + +}); + +describe('per-Turn Bash contract', () => { + it('keeps definitions isolated while hiding background parameters without native output', () => { + const original = JSON.stringify(LocalBashToolDef); + const enabled = createLocalBashToolDefinition({ background: true, shell: 'bash' }); + const disabled = createLocalBashToolDefinition({ background: false, shell: 'bash' }); + expect(enabled.schema.properties).toHaveProperty('run_in_background'); + expect(disabled.schema.properties).not.toHaveProperty('run_in_background'); + expect(JSON.stringify(disabled.schema)).not.toContain('run_in_background'); + expect(enabled.description).toContain('Backgrounding and reading output do not reset the command timeout.'); + expect(disabled.description).toContain('foreground execution only'); + expect(disabled.schema.required).toEqual(['command']); + expect(enabled.schema.properties.timeout.description).toContain('default/max 600s'); + expect(enabled.schema.properties.timeout.description).toContain('including foreground time'); + expect(disabled.schema.properties.timeout.description).toContain('default 120s; values above 300s are capped'); + expect(JSON.stringify(LocalBashToolDef)).toBe(original); + expect(enabled.schema.properties.timeout).not.toBe(LocalBashToolDef.schema.properties.timeout); + expect(disabled.description).toContain('report the failure instead of falling back to permanent deletion'); + expect(disabled.description).toContain('dedicated `read`, `write`, `edit`, `grep`, and `glob`'); + }); + + it.each([ + ['bash', 'Selected shell: Bash', 'PowerShell 5.1'], + ['sh', 'Selected shell: POSIX sh', 'Selected shell: Bash'], + ['powershell', 'The && and || operators are unsupported', 'operators && and || are supported'], + ['pwsh', 'operators && and || are supported', 'operators are unsupported'], + ['unavailable', 'No local shell was resolved', 'Selected shell: Bash'], + ] as const)('describes the selected %s shell capabilities', (shell, expected, excluded) => { + const tool = createLocalBashToolDefinition({ background: true, shell }); + expect(tool.description).toContain(expected); + expect(tool.description).not.toContain(excluded); + expect(tool.description).toContain('Backgrounding and reading output do not reset the command timeout.'); + }); +}); diff --git a/packages/agent-tools/test/desktop/local-task-output-polling.test.ts b/packages/agent-tools/test/desktop/local-task-output-polling.test.ts new file mode 100644 index 00000000..83a404e8 --- /dev/null +++ b/packages/agent-tools/test/desktop/local-task-output-polling.test.ts @@ -0,0 +1,445 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { BackgroundTask } from '@mavis/background-task'; +import { PiTurnRunner } from '@mavis/agent-core/pi-turn-runner'; +import type { RuntimeTool } from '@mavis/agent-core/tools'; +import type { StreamFn } from '@earendil-works/pi-agent-core'; +import type { Api, AssistantMessage, Model } from '@earendil-works/pi-ai'; + +import { LocalTaskOutputToolDef } from '../../src/desktop/builtin-defs.js'; +import { LocalTaskOutputTool } from '../../src/desktop/local-task-control.js'; +import type { + LocalRuntimeToolContext, + LocalTaskControlAdapter, + LocalTaskOutputReadResult, +} from '../../src/desktop/types.js'; + +const context: LocalRuntimeToolContext = { sessionId: 'owner', turnId: 'turn-1' }; +const replay = { task_id: 'bg_1', offset: 0, wait_ms: 30_000 }; +const running: LocalTaskOutputReadResult = { + content: 'Compiling\n', + status: 'running', + nextOffset: 312, + truncated: false, + timedOut: false, +}; + +function fixture() { + const get = vi.fn(async (ctx, taskId) => ({ + taskId, + ownerSessionId: ctx.sessionId, + kind: 'bash', + status: 'running', + createdAt: 1, + updatedAt: 1, + })); + const readOutput = vi.fn(async () => ({ ...running })); + const adapter: LocalTaskControlAdapter = { + get, + readOutput, + list: async () => ({ items: [] }), + stop: async () => undefined, + }; + return { tool: new LocalTaskOutputTool(adapter), get, readOutput }; +} + +it('shows incomplete persistence even when the command itself succeeded', async () => { + const { tool, get, readOutput } = fixture(); + get.mockResolvedValue({ + taskId: 'bg_1', ownerSessionId: 'owner', kind: 'bash', status: 'succeeded', createdAt: 1, updatedAt: 2, + metadata: { bashDetails: { output: { persistence: 'incomplete', rawBytes: 1234 } } }, + }); + readOutput.mockResolvedValue({ content: 'saved portion', status: 'succeeded', nextOffset: 100 }); + const result = await tool.execute(context, { task_id: 'bg_1' }); + expect(result.text).toContain('Output persistence is incomplete'); + expect(result.text).toContain('saved portion'); + expect(result.details?.output).toMatchObject({ rawBytes: 1234 }); +}); + +const piTestModel: Model = { + id: 'test-model', + name: 'test-model', + api: 'anthropic-messages', + provider: 'test-provider', + baseUrl: 'https://example.invalid', + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8_192, +}; + +function assistantMessage( + content: AssistantMessage['content'], + stopReason: AssistantMessage['stopReason'], +): AssistantMessage { + return { + role: 'assistant', + content, + api: piTestModel.api, + provider: piTestModel.provider, + model: piTestModel.id, + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: 0, + }; +} + +function resultOnlyStream(message: AssistantMessage) { + return { + async *[Symbol.asyncIterator]() { + yield { type: 'done', reason: message.stopReason, message }; + }, + async result() { + return message; + }, + } as never; +} + +function piToolCallStream( + calls: readonly { readonly id: string; readonly arguments: Record }[], +): StreamFn { + let invocation = 0; + return (async () => { + const message = + invocation++ === 0 + ? assistantMessage( + calls.map((call) => ({ + type: 'toolCall', + id: call.id, + name: 'task_output', + arguments: call.arguments, + })), + 'toolUse', + ) + : assistantMessage([{ type: 'text', text: 'done' }], 'stop'); + return resultOnlyStream(message); + }) as StreamFn; +} + +it.each(['succeeded', 'failed', 'canceled', 'lost'] as const)( + 'successfully reads output from a %s task', + async (status) => { + const { tool, get, readOutput } = fixture(); + get.mockResolvedValue({ + taskId: 'bg_1', + ownerSessionId: 'owner', + kind: 'bash', + status, + createdAt: 1, + updatedAt: 2, + }); + readOutput.mockResolvedValue({ content: 'saved command output', status, nextOffset: 20 }); + const result = await tool.execute(context, { task_id: 'bg_1' }); + expect(result.isError).not.toBe(true); + expect(result.details).toMatchObject({ task_id: 'bg_1', status, next_offset: 20 }); + expect(result.text).toContain('saved command output'); + }, +); + +describe('task_output polling guidance', () => { + it('lets Pi validate raw task_output arguments before executing capped waits', async () => { + const readOutput = vi.fn(async () => ({ + content: '', + status: 'running', + timedOut: true, + })); + const adapter: LocalTaskControlAdapter = { + get: async () => ({ + taskId: 'bg_1', + ownerSessionId: context.sessionId, + kind: 'bash', + status: 'running', + createdAt: 1, + updatedAt: 1, + }), + readOutput, + list: async () => ({ items: [] }), + stop: async () => undefined, + }; + const rawCalls = [ + { id: 'oversized-60', arguments: { task_id: 'bg_1', wait_ms: 60_000 } }, + { id: 'oversized-120', arguments: { task_id: 'bg_1', wait_ms: 120_000 } }, + { id: 'oversized-300', arguments: { task_id: 'bg_1', wait_ms: 300_000 } }, + { id: 'negative', arguments: { task_id: 'bg_1', wait_ms: -1 } }, + { id: 'fraction', arguments: { task_id: 'bg_1', wait_ms: 0.5 } }, + { id: 'string', arguments: { task_id: 'bg_1', wait_ms: '60000' } }, + { id: 'null', arguments: { task_id: 'bg_1', wait_ms: null } }, + { id: 'infinity', arguments: { task_id: 'bg_1', wait_ms: Number.POSITIVE_INFINITY } }, + { id: 'nan', arguments: { task_id: 'bg_1', wait_ms: Number.NaN } }, + ]; + const history: unknown[] = []; + const stepResults: unknown[] = []; + const tool: RuntimeTool = { + def: LocalTaskOutputToolDef, + impl: new LocalTaskOutputTool(adapter), + source: 'builtin', + }; + + await new PiTurnRunner().runTurn({ + sessionId: context.sessionId, + turnId: context.turnId, + workspaceDir: '/workspace', + systemPrompt: '', + userMessage: { text: 'read task output' }, + llm: { model: piTestModel, streamFn: piToolCallStream(rawCalls) }, + eventWriter: { pushRuntime: () => undefined, appendEvents: () => undefined }, + toolConfig: { tools: [tool], disableBuiltinToolFallback: true, context }, + hooks: { + onStepEndHook: [ + ({ toolResults }) => { + stepResults.push(...structuredClone(toolResults)); + }, + ], + onHistoryChangedHook: [ + ({ reason, messages }) => { + if (reason === 'messageDelta') history.push(...structuredClone(messages)); + }, + ], + }, + }); + + expect(readOutput).toHaveBeenCalledTimes(3); + for (const [, , options] of readOutput.mock.calls) { + expect(options).toMatchObject({ waitMs: 30_000 }); + } + const invalidCallIds = new Set(rawCalls.slice(3).map((call) => call.id)); + const invalidResults = ( + stepResults as Array<{ + readonly toolCallId: string; + readonly isError: boolean; + }> + ).filter((result) => invalidCallIds.has(result.toolCallId)); + expect(invalidResults).toHaveLength(invalidCallIds.size); + expect(invalidResults.every((result) => result.isError)).toBe(true); + const assistant = history.find( + (message): message is { readonly role: 'assistant'; readonly content: readonly unknown[] } => + typeof message === 'object' && + message !== null && + (message as { role?: unknown }).role === 'assistant', + ); + expect(assistant?.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'oversized-60', + arguments: { task_id: 'bg_1', wait_ms: 60_000 }, + }), + expect.objectContaining({ + id: 'oversized-120', + arguments: { task_id: 'bg_1', wait_ms: 120_000 }, + }), + expect.objectContaining({ + id: 'oversized-300', + arguments: { task_id: 'bg_1', wait_ms: 300_000 }, + }), + ]), + ); + }); + + it('corrects repeated offset=0 reads with the returned cursor without changing replay semantics', async () => { + const { tool, readOutput } = fixture(); + const first = await tool.execute(context, replay); + const repeated = await tool.execute(context, replay); + + expect(first.text).not.toContain(''); + expect(repeated.text).toContain('Task status and next_offset are unchanged'); + expect(repeated.text).toContain('task_output({"task_id":"bg_1","offset":312,"wait_ms":30000})'); + expect(repeated.text).toContain('offset=0 replays existing output'); + expect(repeated.text).toContain(running.content); + expect(repeated.details).toEqual(first.details); + expect(repeated.content).toEqual([{ type: 'text', text: repeated.text }]); + expect(readOutput.mock.calls.map(([, , options]) => options)).toEqual([ + { offset: 0, waitMs: 30_000 }, + { offset: 0, waitMs: 30_000 }, + ]); + expect(replay.offset).toBe(0); + }); + + it.each([undefined, 0])('corrects unchanged empty reads with wait_ms=%s', async (waitMs) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ content: '', nextOffset: 312, status: 'running' }); + const input = { task_id: 'bg_1', offset: 312, ...(waitMs === undefined ? {} : { wait_ms: waitMs }) }; + await tool.execute(context, input); + const repeated = await tool.execute(context, input); + + expect(repeated.text).toContain('"offset":312,"wait_ms":30000'); + expect(repeated.text).toContain('(no output yet)'); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { + offset: 312, + waitMs: waitMs ?? 0, + }); + }); + + it('preserves automatic cursor reads while providing an explicit recovery call', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ content: '', nextOffset: 312, status: 'running' }); + await tool.execute(context, { task_id: 'bg_1' }); + const repeated = await tool.execute(context, { task_id: 'bg_1' }); + + expect(repeated.text).toContain('"offset":312,"wait_ms":30000'); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { waitMs: 0 }); + expect(repeated.details).toMatchObject({ effective_wait_ms: 0, wait_ms_clamped: false }); + }); + + it.each([30_001, 60_000, 300_000])( + 'caps oversized wait_ms=%s while preserving the requested value for diagnostics', + async (waitMs) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + const input = { task_id: 'bg_1', offset: 312, wait_ms: waitMs }; + + const result = await tool.execute(context, input); + + expect(input.wait_ms).toBe(waitMs); + expect(readOutput).toHaveBeenCalledWith(context, 'bg_1', { offset: 312, waitMs: 30_000 }); + expect(result.text).toContain( + 'Your requested wait_ms exceeded 30000 ms and was capped at 30000 ms (30 seconds). This output read reached its wait limit; the background task was not stopped. Use wait_ms=30000 or less for future reads.', + ); + expect(result.details).toMatchObject({ + effective_wait_ms: 30_000, + wait_ms_clamped: true, + timed_out: true, + }); + }, + ); + + it.each([ + { read: { ...running, timedOut: true }, waitMs: 30_000 }, + { read: { ...running, timedOut: false }, waitMs: 60_000 }, + { read: { ...running, status: 'succeeded' as const, timedOut: false }, waitMs: 60_000 }, + ])( + 'does not describe a cap timeout when the read returns early: %j', + async ({ read, waitMs }) => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue(read); + + const result = await tool.execute(context, { task_id: 'bg_1', wait_ms: waitMs }); + + expect(result.text).not.toContain('Your requested wait_ms exceeded 30000 ms'); + expect(result.details).toMatchObject({ + effective_wait_ms: waitMs > 30_000 ? 30_000 : waitMs, + wait_ms_clamped: waitMs > 30_000, + }); + }, + ); + + it('does not describe a capped wait as timed out after cancellation', async () => { + const { tool, readOutput } = fixture(); + const controller = new AbortController(); + controller.abort(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + + const result = await tool.execute( + context, + { task_id: 'bg_1', wait_ms: 60_000 }, + controller.signal, + ); + + expect(result.text).not.toContain('Your requested wait_ms exceeded 30000 ms'); + expect(result.details).toMatchObject({ effective_wait_ms: 30_000, wait_ms_clamped: true }); + }); + + it('does not warn on a normal long-poll timeout but still detects the following immediate check', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, content: '', timedOut: true }); + await tool.execute(context, { ...replay, offset: 312 }); + const timeout = await tool.execute(context, { ...replay, offset: 312 }); + expect(timeout.text).not.toContain(''); + expect(timeout.details).toMatchObject({ timed_out: true, next_offset: 312 }); + + readOutput.mockResolvedValue({ ...running, content: '', timedOut: undefined }); + const immediate = await tool.execute(context, { task_id: 'bg_1', offset: 312 }); + expect(immediate.text).toContain(''); + }); + + it('resets the comparison when output advances or task status changes', async () => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, nextOffset: 344 }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue({ ...running, nextOffset: 344, status: 'stopping' }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain('"offset":344'); + }); + + it.each(['succeeded', 'failed', 'canceled', 'lost'])( + 'does not recommend waiting on a %s task and forgets the previous active read', + async (status) => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, status }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue(running); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }, + ); + + it.each([ + { ctx: { ...context, sessionId: 'other-owner' }, input: replay }, + { ctx: { ...context, turnId: 'turn-2' }, input: replay }, + { ctx: context, input: { ...replay, task_id: 'bg_2' } }, + ])('only compares consecutive reads of the same session, Turn and task: %j', async ({ ctx, input }) => { + const { tool } = fixture(); + await tool.execute(context, replay); + expect((await tool.execute(ctx, input)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain(''); + }); + + it.each([undefined, -1, 0.5, Number.NaN, Number.POSITIVE_INFINITY])( + 'never invents continuation arguments for an invalid cursor %s', + async (nextOffset) => { + const { tool, readOutput } = fixture(); + await tool.execute(context, replay); + readOutput.mockResolvedValue({ ...running, nextOffset }); + await tool.execute(context, replay); + expect((await tool.execute(context, replay)).text).not.toContain(''); + readOutput.mockResolvedValue(running); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }, + ); + + it('clears the comparison on task-not-found without turning it into polling advice', async () => { + const { tool, get } = fixture(); + await tool.execute(context, replay); + get.mockResolvedValueOnce(undefined); + const missing = await tool.execute(context, replay); + expect(missing.isError).toBe(true); + expect(missing.details).toMatchObject({ effective_wait_ms: 30_000, wait_ms_clamped: false }); + expect(missing.text).not.toContain(''); + expect((await tool.execute(context, replay)).text).not.toContain(''); + }); + + it('does not learn a failed or canceled read and forwards cancellation unchanged', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockRejectedValueOnce(new Error('read failed')); + await expect(tool.execute(context, replay)).rejects.toThrow('read failed'); + const controller = new AbortController(); + controller.abort(); + const canceled = await tool.execute(context, replay, controller.signal); + expect(canceled.text).not.toContain(''); + expect(readOutput).toHaveBeenLastCalledWith(context, 'bg_1', { + offset: 0, waitMs: 30_000, signal: controller.signal, + }); + expect((await tool.execute(context, replay)).text).not.toContain(''); + expect((await tool.execute(context, replay)).text).toContain(''); + }); + + it('preserves truncated replay output and points to the next page without claiming the task is finished', async () => { + const { tool, readOutput } = fixture(); + readOutput.mockResolvedValue({ ...running, truncated: true }); + await tool.execute(context, replay); + const repeated = await tool.execute(context, replay); + expect(repeated.text).toContain(running.content); + expect(repeated.text).toContain('"offset":312'); + expect(repeated.details).toMatchObject({ truncated: true, status: 'running', next_offset: 312 }); + }); +}); diff --git a/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs b/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs index 37ed2a9e..b3adedcd 100644 --- a/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs +++ b/packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md.hbs @@ -192,12 +192,7 @@ When the user's latest message contains the exact phrase `Prompt 灰度验证`, ### Non-interactive shell - Your shell is **non-interactive** — no TTY, no stdin, no prompt. Commands that wait for stdin - or require a terminal UI will hang forever. - -### Bash timeout - -- Bash `timeout` is measured in seconds. Foreground commands default to 120s, are capped at 300s, and yield to the same managed background process after 15s so the turn is not blocked. Start builds, tests, installs, downloads, servers, or diagnostics expected to take longer as background tasks up front; inspect their incremental output with the task tools. - Do not use excessively large timeouts to mask hung commands, and do not rerun a command after it yields—the returned task is the original process. + or require a terminal UI may fail or wait until the command timeout. Use non-interactive flags. ```bash # BAD — interactive commands hang diff --git a/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md b/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md index 79c46841..6e4bb0d8 100644 --- a/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md +++ b/packages/local-runtime-v2/assets/agents/_default/prompt-base-windows.md @@ -1,45 +1,6 @@ ## Windows Shell Constraints -### PowerShell only - -- Use **PowerShell syntax only**. Do NOT use legacy DOS / `cmd.exe` commands (`cmd`, `cmd /c`, - `dir`, `type`, `copy`, `move`, `del`, `erase`, `rd`, `rmdir`, etc.). Use full PowerShell - cmdlets instead (`Get-ChildItem`, `Copy-Item`, `Move-Item`, `New-Item`). -- The bash tool already runs the command through the selected Windows shell. Do not wrap ordinary - bash-tool commands in `powershell -Command`. If you truly must launch a nested `pwsh`/PowerShell - process, prefer `pwsh -NoProfile -NonInteractive -Command '