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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions packages/fold-agent/src/Tools/BashTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ export const decodeBashOutputDelta = (payload: unknown): BashOutputDelta | null

const BashParameters = Schema.Struct({
command: Schema.String.annotate({ description: 'Bash command to execute' }),
timeout: Schema.optionalKey(Schema.Number).annotate({
description: 'Timeout in seconds (default 120)',
timeout_ms: Schema.optionalKey(Schema.Number).annotate({
description: 'Timeout in milliseconds (default 120000)',
}),
workdir: Schema.optionalKey(Schema.String).annotate({
description: 'Working directory for the command. Use this instead of cd.',
Expand All @@ -71,8 +71,8 @@ const BashFailure = Schema.Struct({
message: Schema.String,
})

const defaultTimeoutSeconds = 120
const maxTimeoutSeconds = 2_147_483.647
const defaultTimeoutMilliseconds = 120_000
const maxTimeoutMilliseconds = 2_147_483_647
const killGrace = Duration.millis(200)
// Keep a bounded in-memory tail once output spills: 4x the byte limit comfortably covers the
// tail-truncation window while the spill file holds the full output.
Expand Down Expand Up @@ -232,7 +232,8 @@ export const bashTool = (options?: BashToolOptions): FoldTool =>
description:
'Execute a bash command and return its output (stdout and stderr interleaved, tail-truncated ' +
`to 2000 lines or ${formatSize(defaultMaxBytes)} with the full output saved to a file). The command runs in its ` +
'own process group and is killed at the timeout.\n\n' +
'own process group and is killed at the timeout. The optional timeout_ms is in milliseconds ' +
`(default ${defaultTimeoutMilliseconds}, maximum ${maxTimeoutMilliseconds}).\n\n` +
'Fast search binaries are provided on PATH (fold auto-installs them into ~/.fold/bin): prefer ' +
'`rg` over grep for content search, `fd` over find for locating files by name (fast and ' +
'gitignore-aware), and `ast-grep` for syntax-aware structural search over code. ' +
Expand All @@ -251,13 +252,17 @@ export const bashTool = (options?: BashToolOptions): FoldTool =>
const configuredCwd = yield* resolveToCwd(options?.cwd ?? process.cwd(), process.cwd())
const cwd =
params.workdir === undefined ? configuredCwd : yield* resolveToCwd(params.workdir, configuredCwd)
const timeoutSeconds = params.timeout ?? defaultTimeoutSeconds
const timeoutMilliseconds = params.timeout_ms ?? defaultTimeoutMilliseconds

if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
return yield* Effect.fail({ message: 'Invalid timeout: must be a finite number of seconds' })
if (!Number.isFinite(timeoutMilliseconds) || timeoutMilliseconds <= 0) {
return yield* Effect.fail({
message: 'Invalid timeout_ms: must be a finite number of milliseconds',
})
}
if (timeoutSeconds > maxTimeoutSeconds) {
return yield* Effect.fail({ message: `Invalid timeout: maximum is ${maxTimeoutSeconds} seconds` })
if (timeoutMilliseconds > maxTimeoutMilliseconds) {
return yield* Effect.fail({
message: `Invalid timeout_ms: maximum is ${maxTimeoutMilliseconds} milliseconds`,
})
}

if (!(yield* fs.exists(cwd).pipe(Effect.catch(() => Effect.succeed(false))))) {
Expand Down Expand Up @@ -363,7 +368,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool =>
Effect.catch(() => Effect.succeed(null)),
)

const firstExit = yield* awaitExit.pipe(Effect.timeoutOption(Duration.seconds(timeoutSeconds)))
const firstExit = yield* awaitExit.pipe(Effect.timeoutOption(Duration.millis(timeoutMilliseconds)))
let timedOut = false
let exitCode: number | null
if (Option.isSome(firstExit)) {
Expand Down Expand Up @@ -410,7 +415,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool =>
return yield* Effect.fail({
message: appendStatus(
outputText,
`<system-reminder>Command timed out after ${timeoutSeconds} seconds</system-reminder>`,
`<system-reminder>Command timed out after ${timeoutMilliseconds} milliseconds</system-reminder>`,
),
})
}
Expand Down
30 changes: 17 additions & 13 deletions packages/fold-agent/test/Tools/BashTool.vi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ it.live('missing workdir fails fast with the pi message', () =>
it.live('invalid timeouts are rejected before spawning', () =>
Effect.gen(function* () {
const dir = yield* tempDir
const failure = yield* runHandler(handlerOf(bashTool({ cwd: dir }))({ command: 'echo hi', timeout: -1 })).pipe(
Effect.flip,
)
const failure = yield* runHandler(
handlerOf(bashTool({ cwd: dir }))({ command: 'echo hi', timeout_ms: -1 }),
).pipe(Effect.flip)

expect(messageOf(failure)).toBe('Invalid timeout: must be a finite number of seconds')
expect(messageOf(failure)).toBe('Invalid timeout_ms: must be a finite number of milliseconds')
}),
)

Expand All @@ -137,17 +137,19 @@ it.live('timeout kills the whole process group, including grandchildren', () =>
const dir = yield* tempDir
const grandchildMarker = join(dir, 'grandchild-survived.txt')

// The command spawns a backgrounded grandchild that would write a marker after 2s. The 1s
// The command spawns a backgrounded grandchild that would write a marker after 2s. The 1000ms
// timeout must kill the entire group, so the marker never appears.
const failure = yield* runHandler(
handlerOf(bashTool({ cwd: dir }))({
command: `(sleep 2 && echo alive > ${grandchildMarker}) & echo started && sleep 10`,
timeout: 1,
timeout_ms: 1_000,
}),
).pipe(Effect.flip)

expect(messageOf(failure)).toContain('started')
expect(messageOf(failure)).toContain('<system-reminder>Command timed out after 1 seconds</system-reminder>')
expect(messageOf(failure)).toContain(
'<system-reminder>Command timed out after 1000 milliseconds</system-reminder>',
)

// Give any surviving grandchild time to prove itself, then assert it was killed.
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 2500)))
Expand Down Expand Up @@ -261,11 +263,13 @@ it.live('escalates to SIGKILL for commands that trap SIGTERM', () =>

// The command ignores SIGTERM; only the 200ms SIGKILL escalation can end it.
const failure = yield* runHandler(
handlerOf(bashTool({ cwd: dir }))({ command: "trap '' TERM; echo trapped; sleep 30", timeout: 1 }),
handlerOf(bashTool({ cwd: dir }))({ command: "trap '' TERM; echo trapped; sleep 30", timeout_ms: 1_000 }),
).pipe(Effect.flip)

expect(messageOf(failure)).toContain('trapped')
expect(messageOf(failure)).toContain('<system-reminder>Command timed out after 1 seconds</system-reminder>')
expect(messageOf(failure)).toContain(
'<system-reminder>Command timed out after 1000 milliseconds</system-reminder>',
)
// 1s timeout + 200ms grace + slack: far below the 30s sleep.
expect(Date.now() - started).toBeLessThan(10_000)
}),
Expand All @@ -283,11 +287,11 @@ it.live('a signal-killed command is a success, not an error (pi semantics)', ()
it.live('an empty-output timeout reports only the status (no "(no output)" prefix)', () =>
Effect.gen(function* () {
const dir = yield* tempDir
const failure = yield* runHandler(handlerOf(bashTool({ cwd: dir }))({ command: 'sleep 5', timeout: 1 })).pipe(
Effect.flip,
)
const failure = yield* runHandler(
handlerOf(bashTool({ cwd: dir }))({ command: 'sleep 5', timeout_ms: 1_000 }),
).pipe(Effect.flip)

expect(messageOf(failure)).toBe('<system-reminder>Command timed out after 1 seconds</system-reminder>')
expect(messageOf(failure)).toBe('<system-reminder>Command timed out after 1000 milliseconds</system-reminder>')
}),
)

Expand Down
Loading