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..c2d0e62 --- /dev/null +++ b/src/Exec/TimeoutMiddleware.php @@ -0,0 +1,53 @@ + $next($call)); + + try { + return await($coroutine, timeout($this->timeoutMs)); + } 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( + $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'); + } +}