From 09b8fd553e763433635fa40fe69d79d6d24e2e8a Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:15:14 +0000 Subject: [PATCH 1/2] + timeout middleware (per-tool deadline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TimeoutMiddleware runs the tool in a child coroutine and awaits it with Async\timeout(); on the deadline it cancels the coroutine (TrueAsync propagates the cancellation into a running bash subprocess and kills it) and returns an error result. Sits innermost, so a slow tool is capped but the user's approval prompt is not. Session adds an optional toolTimeoutMs (0 = off, so existing tests are unaffected); bin/claw wires it from CLAW_TURN_TIMEOUT_MS. 90 tests, PHPStan level 8, php-cs-fixer — all green. --- bin/claw | 11 +++++- src/Exec/TimeoutMiddleware.php | 47 +++++++++++++++++++++++++ src/Session.php | 21 ++++++----- tests/Exec/TimeoutMiddlewareTest.php | 52 ++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 src/Exec/TimeoutMiddleware.php create mode 100644 tests/Exec/TimeoutMiddlewareTest.php diff --git a/bin/claw b/bin/claw index ca6f765..9740875 100755 --- a/bin/claw +++ b/bin/claw @@ -97,7 +97,16 @@ $runSession = static function (ConversationInterface $conversation) use ($agent, $store = new SessionStore($sessionsDir . '/' . $conversation->id() . '.db'); - new Session($conversation, $agent, $tools, $system, $config->model, $config->maxHistory, store: $store)->run(); + new Session( + $conversation, + $agent, + $tools, + $system, + $config->model, + $config->maxHistory, + store: $store, + toolTimeoutMs: $config->turnTimeoutMs, // cap each tool run (kills a hung bash) + )->run(); }; if ($chat instanceof TelegramChat) { diff --git a/src/Exec/TimeoutMiddleware.php b/src/Exec/TimeoutMiddleware.php new file mode 100644 index 0000000..a7ae2c8 --- /dev/null +++ b/src/Exec/TimeoutMiddleware.php @@ -0,0 +1,47 @@ + $next($call)); + + try { + return await($coroutine, timeout($this->timeoutMs)); + } catch (OperationCanceledException) { + $coroutine->cancel(); // propagates into a running bash subprocess and kills it + + return new ToolResultBlock( + $call->id, + 'timed out after ' . (int) ceil($this->timeoutMs / 1000) . 's', + true, + ); + } + } +} diff --git a/src/Session.php b/src/Session.php index ed3a0f2..7157fe6 100644 --- a/src/Session.php +++ b/src/Session.php @@ -23,6 +23,7 @@ use Claw\Exec\ChainExecutor; use Claw\Exec\ExecutorInterface; use Claw\Exec\PermissionMiddleware; +use Claw\Exec\TimeoutMiddleware; use Claw\Permission\Policy; use Claw\Store\SessionStore; use Claw\Tool\Registry; @@ -60,18 +61,22 @@ public function __construct( private readonly int $maxHistory = 0, private readonly Policy $policy = new Policy(), private readonly ?SessionStore $store = null, + private readonly int $toolTimeoutMs = 0, ) { $this->specs = $this->buildSpecs(); // Tool execution funnels through one chain: audit logs every call (even - // denials), permission gates it, and the terminal stage runs the tool. - $this->executor = new ChainExecutor( - middlewares: [ - new AuditMiddleware($this->store), - new PermissionMiddleware($this->policy, $this->tools, $this->conversation, $this->store), - ], - terminal: $this->runTool(...), - ); + // denials), permission gates it, an optional timeout bounds the run, and + // the terminal stage runs the tool. + $middlewares = [ + new AuditMiddleware($this->store), + new PermissionMiddleware($this->policy, $this->tools, $this->conversation, $this->store), + ]; + if ($this->toolTimeoutMs > 0) { + $middlewares[] = new TimeoutMiddleware($this->toolTimeoutMs); // innermost: bound each tool run + } + + $this->executor = new ChainExecutor($middlewares, $this->runTool(...)); } /** Drive the conversation: each message is one task. Ends when it closes. */ diff --git a/tests/Exec/TimeoutMiddlewareTest.php b/tests/Exec/TimeoutMiddlewareTest.php new file mode 100644 index 0000000..aaeb1ae --- /dev/null +++ b/tests/Exec/TimeoutMiddlewareTest.php @@ -0,0 +1,52 @@ +handle( + new ToolCall('1', 'slow', []), + static function (ToolCall $c): ToolResultBlock { + delay(300); // outlives the 20ms deadline + + return new ToolResultBlock($c->id, 'late', false); + }, + ); + + Assert::true($result->isError); + Assert::true(str_contains($result->content, 'timed out')); + } + + #[Test] + public function passesThroughWhenTheToolIsFastEnough(): void + { + $mw = new TimeoutMiddleware(500); + + $result = $mw->handle( + new ToolCall('1', 'fast', []), + static function (ToolCall $c): ToolResultBlock { + delay(10); + + return new ToolResultBlock($c->id, 'quick', false); + }, + ); + + Assert::false($result->isError); + Assert::same($result->content, 'quick'); + } +} From 4228f6248b0a1ad76dd01fc4eb204d2e5ff4e052 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Sun, 14 Jun 2026 17:20:17 +0000 Subject: [PATCH 2/2] fix CI: match the timeout cancellation by class, not catch-type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static-analysis job runs PHPStan without the true_async extension, so the ide-helper stub for Async\OperationCanceledException has no Throwable parent and 'catch (OperationCanceledException)' tripped catch.notThrowable. Catch \Throwable and compare $e::class instead (rethrowing anything that isn't the cancellation) — env-independent and also stops a genuine tool error being masked as a timeout. --- src/Exec/TimeoutMiddleware.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Exec/TimeoutMiddleware.php b/src/Exec/TimeoutMiddleware.php index a7ae2c8..c2d0e62 100644 --- a/src/Exec/TimeoutMiddleware.php +++ b/src/Exec/TimeoutMiddleware.php @@ -34,7 +34,13 @@ public function handle(ToolCall $call, callable $next): ToolResultBlock try { return await($coroutine, timeout($this->timeoutMs)); - } catch (OperationCanceledException) { + } catch (\Throwable $e) { + // Only the timeout cancellation is ours; a genuine tool failure + // propagates rather than being masked as a timeout. + if ($e::class !== OperationCanceledException::class) { + throw $e; + } + $coroutine->cancel(); // propagates into a running bash subprocess and kills it return new ToolResultBlock(