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
11 changes: 10 additions & 1 deletion bin/claw
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions src/Exec/TimeoutMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Claw\Exec;

use function Async\await;

use Async\OperationCanceledException;

use function Async\spawn;
use function Async\timeout;

use Claw\Agent\ToolResultBlock;
use Claw\Tool\ToolCall;

/**
* Bounds a single tool run. The tool runs in a child coroutine; if it outlives
* the deadline the coroutine is cancelled — and TrueAsync cancellation propagates
* into an awaited `bash` subprocess, killing it — and an error result is returned.
*
* Sits innermost (just before the terminal), so a slow tool is capped but the
* user's approval prompt is not.
*/
final readonly class TimeoutMiddleware implements MiddlewareInterface
{
public function __construct(private int $timeoutMs)
{
}

public function handle(ToolCall $call, callable $next): ToolResultBlock
{
$coroutine = spawn(static fn (): ToolResultBlock => $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,
);
}
}
}
21 changes: 13 additions & 8 deletions src/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down
52 changes: 52 additions & 0 deletions tests/Exec/TimeoutMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Tests\Exec;

use function Async\delay;

use Claw\Agent\ToolResultBlock;
use Claw\Exec\TimeoutMiddleware;
use Claw\Tool\ToolCall;
use Testo\Assert;
use Testo\Test;

final class TimeoutMiddlewareTest
{
#[Test]
public function returnsErrorWhenTheToolExceedsTheDeadline(): void
{
$mw = new TimeoutMiddleware(20);

$result = $mw->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');
}
}
Loading