diff --git a/packages/fold-agent/src/Tools/BashTool.ts b/packages/fold-agent/src/Tools/BashTool.ts
index 5e9ed26..06a11e4 100644
--- a/packages/fold-agent/src/Tools/BashTool.ts
+++ b/packages/fold-agent/src/Tools/BashTool.ts
@@ -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.',
@@ -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.
@@ -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. ' +
@@ -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))))) {
@@ -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)) {
@@ -410,7 +415,7 @@ export const bashTool = (options?: BashToolOptions): FoldTool =>
return yield* Effect.fail({
message: appendStatus(
outputText,
- `Command timed out after ${timeoutSeconds} seconds`,
+ `Command timed out after ${timeoutMilliseconds} milliseconds`,
),
})
}
diff --git a/packages/fold-agent/test/Tools/BashTool.vi.test.ts b/packages/fold-agent/test/Tools/BashTool.vi.test.ts
index fbc0f60..89f5c90 100644
--- a/packages/fold-agent/test/Tools/BashTool.vi.test.ts
+++ b/packages/fold-agent/test/Tools/BashTool.vi.test.ts
@@ -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')
}),
)
@@ -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('Command timed out after 1 seconds')
+ expect(messageOf(failure)).toContain(
+ 'Command timed out after 1000 milliseconds',
+ )
// Give any surviving grandchild time to prove itself, then assert it was killed.
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 2500)))
@@ -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('Command timed out after 1 seconds')
+ expect(messageOf(failure)).toContain(
+ 'Command timed out after 1000 milliseconds',
+ )
// 1s timeout + 200ms grace + slack: far below the 30s sleep.
expect(Date.now() - started).toBeLessThan(10_000)
}),
@@ -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('Command timed out after 1 seconds')
+ expect(messageOf(failure)).toBe('Command timed out after 1000 milliseconds')
}),
)