Skip to content

Commit 199cc7f

Browse files
committed
Add timeout support to bash tool
- Add optional timeout parameter (1-600 seconds, default 120) - Kill processes that exceed timeout limit - Return error message when timeout occurs - Matches Taiga's bash tool timeout behavior - Add comprehensive tests for timeout functionality
1 parent c801126 commit 199cc7f

File tree

3 files changed

+102
-2
lines changed

3 files changed

+102
-2
lines changed

src/tools/bash.jl

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ function tool_schema(::BashTool)
2121
"command" => Dict(
2222
"type" => "string",
2323
"description" => "The bash command to execute"
24+
),
25+
"timeout" => Dict(
26+
"type" => "integer",
27+
"description" => "Maximum execution time in seconds (default: 120)",
28+
"minimum" => 1,
29+
"maximum" => 600
2430
)
2531
),
2632
"required" => ["command"]
@@ -30,11 +36,17 @@ end
3036

3137
function execute(tool::BashTool, params::Dict)
3238
command = get(params, "command", nothing)
39+
timeout_seconds = get(params, "timeout", 120) # Default 120 seconds like Taiga
3340

3441
if command === nothing
3542
return create_content_response("Error: No command provided", is_error=true)
3643
end
3744

45+
# Validate timeout
46+
if timeout_seconds < 1 || timeout_seconds > 600
47+
return create_content_response("Error: Timeout must be between 1 and 600 seconds", is_error=true)
48+
end
49+
3850
try
3951
# Use Cmd with ignorestatus to capture all output regardless of exit code
4052
cmd = Cmd(`sh -c $command`, ignorestatus=true, dir=tool.working_dir)
@@ -48,8 +60,34 @@ function execute(tool::BashTool, params::Dict)
4860
stdout_buf = IOBuffer()
4961
stderr_buf = IOBuffer()
5062

51-
# Run the command and capture outputs
52-
proc = run(pipeline(cmd, stdout=stdout_buf, stderr=stderr_buf))
63+
# Start the process without waiting for it to complete
64+
process = open(pipeline(cmd, stdout=stdout_buf, stderr=stderr_buf))
65+
66+
# Wait for completion with timeout
67+
timed_out = false
68+
start_time = time()
69+
70+
while process_running(process)
71+
if time() - start_time > timeout_seconds
72+
timed_out = true
73+
# Kill the process
74+
kill(process)
75+
break
76+
end
77+
sleep(0.1) # Check every 100ms
78+
end
79+
80+
# Wait for process to fully terminate if we killed it
81+
if timed_out
82+
wait(process)
83+
return create_content_response(
84+
"Error: Command timed out after $timeout_seconds seconds",
85+
is_error=true
86+
)
87+
end
88+
89+
# Get the process result
90+
proc = process
5391

5492
# Get the outputs
5593
stdout_str = String(take!(stdout_buf))

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ using JSON
44

55
@testset "ClaudeMCPTools.jl" begin
66
include("test_bash.jl")
7+
include("test_bash_timeout.jl")
78
include("test_str_replace_editor.jl")
89
include("test_view_range.jl")
910
include("test_server.jl")

test/test_bash_timeout.jl

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
@testset "Bash tool timeout" begin
2+
tool = BashTool()
3+
4+
@testset "Quick command completes normally" begin
5+
# Command that completes quickly
6+
result = execute(tool, Dict(
7+
"command" => "echo 'Hello'",
8+
"timeout" => 5
9+
))
10+
@test !result["isError"]
11+
@test occursin("Hello", result["content"][1]["text"])
12+
end
13+
14+
@testset "Long-running command times out" begin
15+
# Command that would run for 10 seconds but timeout is 2 seconds
16+
result = execute(tool, Dict(
17+
"command" => "sleep 10 && echo 'Should not see this'",
18+
"timeout" => 2
19+
))
20+
@test result["isError"]
21+
@test occursin("timed out after 2 seconds", result["content"][1]["text"])
22+
end
23+
24+
@testset "Default timeout (120 seconds)" begin
25+
# Test that default timeout is applied
26+
result = execute(tool, Dict(
27+
"command" => "echo 'Testing default timeout'"
28+
))
29+
@test !result["isError"]
30+
@test occursin("Testing default timeout", result["content"][1]["text"])
31+
end
32+
33+
@testset "Invalid timeout values" begin
34+
# Timeout too small
35+
result = execute(tool, Dict(
36+
"command" => "echo 'test'",
37+
"timeout" => 0
38+
))
39+
@test result["isError"]
40+
@test occursin("Timeout must be between 1 and 600 seconds", result["content"][1]["text"])
41+
42+
# Timeout too large
43+
result = execute(tool, Dict(
44+
"command" => "echo 'test'",
45+
"timeout" => 700
46+
))
47+
@test result["isError"]
48+
@test occursin("Timeout must be between 1 and 600 seconds", result["content"][1]["text"])
49+
end
50+
51+
@testset "Command with output before timeout" begin
52+
# Command that produces output then sleeps (should capture output before timeout)
53+
result = execute(tool, Dict(
54+
"command" => "echo 'Started' && sleep 10",
55+
"timeout" => 2
56+
))
57+
@test result["isError"]
58+
@test occursin("timed out", result["content"][1]["text"])
59+
# Note: Output might not be captured due to buffering
60+
end
61+
end

0 commit comments

Comments
 (0)