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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,6 @@ php-claw/
bin/claw entrypoint: bootstrap reactor; accept() -> spawn Session
src/
Config.php
Scheduler.php periodic tasks via the reactor timer (every/tick/run)
Session.php conversation state + agentic loop (run/handle/execute)
Chat/ ChatInterface.php (accept) ConversationInterface.php
ConsoleChat.php ConsoleConversation.php TelegramChat.php (todo)
Expand All @@ -270,6 +269,7 @@ php-claw/
Tool/ ToolInterface.php Risk.php ToolCall.php Registry.php Workspace.php
ReadFileTool.php WriteFileTool.php ListFilesTool.php BashTool.php (proc_open)
DateTool.php (current time) PhpEvalTool.php (eval one expression; Dangerous)
ScheduleTool.php (one-shot reminder; spawns a delay coroutine)
Permission/ Policy.php
Store/ SessionStore.php Schema.php
Exceptions/ ClawException.php (base) ConfigException.php ChatException.php
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Composer shortcuts: `composer test`, `composer analyse`, `composer cs`, `compose

## Status

Console agent that runs end to end (Config, async HTTP with cause-aware retry, Claude &
DeepSeek backends, `bash` / `read_file` / `write_file` tools, the session loop, a periodic
scheduler). Next: the security/permission middleware layer, per-session persistence, and a
Telegram channel.
Console agent that runs end to end: Config, async HTTP with cause-aware retry, Claude &
DeepSeek backends, the tool set (`bash`, `read_file`, `write_file`, `list_files`, `date`,
`php_eval`, `schedule`), the session loop, a permission gatekeeper (confirm + persisted
"always" rules), and per-conversation SQLite persistence (history survives restarts). Next:
a Telegram channel (chat-id allowlist) and an audit log of tool calls.
9 changes: 8 additions & 1 deletion bin/claw
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ use Claw\Tool\ListFilesTool;
use Claw\Tool\PhpEvalTool;
use Claw\Tool\ReadFileTool;
use Claw\Tool\Registry;
use Claw\Tool\ScheduleTool;
use Claw\Tool\Workspace;
use Claw\Tool\WriteFileTool;

Expand Down Expand Up @@ -87,7 +88,13 @@ try {
exit(1);
}

new Session($chat->accept(), $agent, $tools, $system, $config->model, $config->maxHistory, store: $store)->run();
$conversation = $chat->accept();

// The schedule tool delivers reminders straight to the user, so it's wired with
// this conversation's send() as its sink.
$tools->add(new ScheduleTool($conversation->send(...)));

new Session($conversation, $agent, $tools, $system, $config->model, $config->maxHistory, store: $store)->run();

function makeAgent(Config $config, HttpClientInterface $http): ?AgentInterface
{
Expand Down
56 changes: 0 additions & 56 deletions src/Scheduler.php

This file was deleted.

81 changes: 81 additions & 0 deletions src/Tool/ScheduleTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Claw\Tool;

use function Async\delay;
use function Async\spawn;

use Claw\Exceptions\ToolException;

/**
* Lets the agent schedule a one-shot reminder: after the given delay, a message
* is delivered to the user. Each scheduled item is its own coroutine that awaits
* the delay and then fires — there is no central timer loop. Reminders live in
* memory, so they do not survive a restart (persistence is a later layer).
*/
final readonly class ScheduleTool implements ToolInterface
{
/**
* @param \Closure(string): void $deliver Sends a message to the user.
*/
public function __construct(private \Closure $deliver)
{
}

public function name(): string
{
return 'schedule';
}

public function description(): string
{
return 'Schedule a one-shot reminder: after the given number of seconds, the message is sent to the user.';
}

public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'after_seconds' => ['type' => 'number', 'description' => 'Delay in seconds before sending (e.g. 60 for one minute).'],
'message' => ['type' => 'string', 'description' => 'The reminder text to send to the user.'],
],
'required' => ['after_seconds', 'message'],
];
}

public function risk(): Risk
{
return Risk::Safe;
}

public function handle(array $input): string
{
$rawAfter = $input['after_seconds'] ?? null;
$after = is_numeric($rawAfter) ? (float) $rawAfter : 0.0;

$rawMessage = $input['message'] ?? null;
$message = is_string($rawMessage) ? trim($rawMessage) : '';

if ($after <= 0) {
throw new ToolException('schedule: "after_seconds" must be greater than zero');
}
if ($message === '') {
throw new ToolException('schedule: "message" is required');
}

$deliver = $this->deliver;
$ms = (int) round($after * 1000);

// Fire-and-forget: this coroutine parks on delay() and costs nothing while
// suspended; when it wakes it pushes the reminder to the user.
spawn(static function () use ($ms, $message, $deliver): void {
delay($ms);
$deliver('⏰ ' . $message);
});

return 'Scheduled: the reminder will be sent in ' . $after . ' seconds.';
}
}
62 changes: 0 additions & 62 deletions tests/SchedulerTest.php

This file was deleted.

64 changes: 64 additions & 0 deletions tests/Tool/ScheduleToolTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace Tests\Tool;

use function Async\delay;

use Claw\Exceptions\ToolException;
use Claw\Tool\Risk;
use Claw\Tool\ScheduleTool;
use Testo\Assert;
use Testo\Test;

final class ScheduleToolTest
{
#[Test]
public function deliversTheMessageAfterTheDelay(): void
{
/** @var list<string> $delivered */
$delivered = [];
$tool = new ScheduleTool(function (string $m) use (&$delivered): void {
$delivered[] = $m;
});

$result = $tool->handle(['after_seconds' => 0.02, 'message' => 'stand up']);

Assert::same($tool->risk(), Risk::Safe);
Assert::true(str_contains($result, 'Scheduled'));
Assert::same($delivered, []); // nothing has fired yet

delay(200); // let the scheduled coroutine wake and deliver

Assert::same($delivered, ['⏰ stand up']);
}

#[Test]
public function rejectsNonPositiveDelay(): void
{
$threw = false;
try {
(new ScheduleTool(static function (string $m): void {
}))->handle(['after_seconds' => 0, 'message' => 'x']);
} catch (ToolException $e) {
$threw = true;
}

Assert::true($threw);
}

#[Test]
public function rejectsEmptyMessage(): void
{
$threw = false;
try {
(new ScheduleTool(static function (string $m): void {
}))->handle(['after_seconds' => 1, 'message' => ' ']);
} catch (ToolException $e) {
$threw = true;
}

Assert::true($threw);
}
}
Loading