diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d1b3317..207eaa9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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) @@ -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 diff --git a/README.md b/README.md index 372d09c..54415a1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/bin/claw b/bin/claw index 134c7bc..d1e3c4c 100755 --- a/bin/claw +++ b/bin/claw @@ -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; @@ -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 { diff --git a/src/Scheduler.php b/src/Scheduler.php deleted file mode 100644 index cfdbcaf..0000000 --- a/src/Scheduler.php +++ /dev/null @@ -1,56 +0,0 @@ - */ - private array $jobs = []; - - /** Register a task to run every $everyMs. */ - public function every(int $everyMs, callable $task): void - { - if ($everyMs <= 0) { - throw new \InvalidArgumentException('Scheduler: interval must be positive'); - } - - $this->jobs[] = ['everyMs' => $everyMs, 'task' => $task, 'dueAtMs' => $everyMs]; - } - - /** - * Run every job due at $nowMs and reschedule it. Returns how many ran. - * Missed ticks are skipped (next due is relative to now, no catch-up storm). - */ - public function tick(int $nowMs): int - { - $ran = 0; - foreach ($this->jobs as $i => $job) { - if ($nowMs >= $job['dueAtMs']) { - ($job['task'])(); - $this->jobs[$i]['dueAtMs'] = $nowMs + $job['everyMs']; - $ran++; - } - } - - return $ran; - } - - /** Reactor loop: sleep one step, tick, repeat. Non-blocking under TrueAsync. */ - public function run(int $resolutionMs = 1000): void - { - $elapsed = 0; - for (;;) { - \Async\delay($resolutionMs); - $elapsed += $resolutionMs; - $this->tick($elapsed); - } - } -} diff --git a/src/Tool/ScheduleTool.php b/src/Tool/ScheduleTool.php new file mode 100644 index 0000000..8de5ae8 --- /dev/null +++ b/src/Tool/ScheduleTool.php @@ -0,0 +1,81 @@ + '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.'; + } +} diff --git a/tests/SchedulerTest.php b/tests/SchedulerTest.php deleted file mode 100644 index 757b53a..0000000 --- a/tests/SchedulerTest.php +++ /dev/null @@ -1,62 +0,0 @@ -every(1000, function () use (&$hits): void { - $hits++; - }); - - Assert::same($scheduler->tick(500), 0); // not due yet - Assert::same($scheduler->tick(1000), 1); // due - Assert::same($scheduler->tick(1500), 0); // rescheduled to 2000 - Assert::same($scheduler->tick(2000), 1); // due again - - Assert::same($hits, 2); - } - - #[Test] - public function handlesMultipleIndependentIntervals(): void - { - $a = 0; - $b = 0; - $scheduler = new Scheduler(); - $scheduler->every(1000, function () use (&$a): void { - $a++; - }); - $scheduler->every(2000, function () use (&$b): void { - $b++; - }); - - $scheduler->tick(1000); // a - $scheduler->tick(2000); // a + b - - Assert::same($a, 2); - Assert::same($b, 1); - } - - #[Test] - public function rejectsNonPositiveInterval(): void - { - $threw = false; - try { - (new Scheduler())->every(0, static fn (): null => null); - } catch (\InvalidArgumentException $e) { - $threw = true; - } - - Assert::true($threw); - } -} diff --git a/tests/Tool/ScheduleToolTest.php b/tests/Tool/ScheduleToolTest.php new file mode 100644 index 0000000..df51648 --- /dev/null +++ b/tests/Tool/ScheduleToolTest.php @@ -0,0 +1,64 @@ + $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); + } +}