diff --git a/docs/api.md b/docs/api.md index 95c9ddd..954f2c1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -373,6 +373,62 @@ --- +#### `GET /sessions/{session_id}/recall` + +Get the pending recall for a session, if any. Returns the first `pending` recall only. + +**Response `200`** +```json +{ + "id": "r1", + "call_type": "once", + "trigger_at": "2026-05-16T14:00:00+00:00", + "interval_seconds": null, + "internal_comment": "Check build logs", + "additional_context_message": "Read /tmp/build.log...", + "status": "pending" +} +``` + +**Response `200` (no pending recall)** +```json +{ "recall": null } +``` + +**Errors** +- `404` — session not found + +--- + +#### `DELETE /sessions/{session_id}/recall` + +Cancel the pending recall for this session. + +**Response `200`** +```json +{ "ok": true } +``` + +**Errors** +- `404` — session not found + +--- + +#### `POST /sessions/{session_id}/recall/skip` + +Skip the next occurrence of a recurring recall (advances `trigger_at` by `interval_seconds`). + +**Response `200`** +```json +{ "ok": true } +``` + +**Errors** +- `404` — session not found +- `400` — no recurring pending recall + +--- + #### `DELETE /sessions/{session_id}` Delete a session and its files. @@ -748,6 +804,32 @@ --- +#### `recall_update` +```json +{ + "type": "recall_update", + "session_id": "550e8400-...", + "recall_id": "r1", + "call_type": "once", + "trigger_at": "2026-05-16T14:00:00+00:00", + "status": "pending", + "action": "scheduled" +} +``` +Recall state changed. Sent when a recall is scheduled, cancelled, fired, or rescheduled. Client should refresh the recall banner via `GET /sessions/{id}/recall`. + +**Fields** +| Field | Type | Description | +|-------|------|-------------| +| `session_id` | `string` | Affected session | +| `recall_id` | `string\|null` | Recall ID (null for cancel without ID) | +| `call_type` | `string\|null` | `once`, `recurring`, `immediate` | +| `trigger_at` | `string\|null` | Next trigger time (ISO 8601) | +| `status` | `string\|null` | `pending`, `fired`, `cancelled` | +| `action` | `string\|null` | `scheduled`, `cancelled`, `fired`, `rescheduled` | + +--- + #### `session_sync` ```json { "type": "session_sync" } @@ -755,6 +837,7 @@ Client must reload session history from `GET /sessions/{id}`. Sent: 1. On connect when no run is active (agent may have finished while disconnected). 2. After a reconnect-replay flow completes (ensures client sees the fully saved response). +3. After a headless recall run finishes (so the client sees the recall user message + assistant response). --- diff --git a/docs/index.md b/docs/index.md index ab0e9b7..5a927ba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ | [`agent.md`](agent.md) | Agent loop, planning phase, tool execution, subagents, workers | | [`tools.md`](tools.md) | Built-in tools, user tool format, hot-reload, self-extension | | [`sessions.md`](sessions.md) | Session model, dual-buffer design, context compression | +| [`recall.md`](recall.md) | Scheduled callbacks — headless recall system | | [`websocket.md`](websocket.md) | WebSocket protocol — all events, stop mechanism | | [`profiles.md`](profiles.md) | Profiles, system prompts, persona, profile switching | | [`memory.md`](memory.md) | Long-term memory — facts, extraction, search | diff --git a/docs/recall.md b/docs/recall.md new file mode 100644 index 0000000..260bca6 --- /dev/null +++ b/docs/recall.md @@ -0,0 +1,161 @@ +# Scheduled Recalls + +Self-recall (scheduled callback) system — allows the agent to schedule future headless work that runs without user interaction. + +## Overview + +A **recall** is a delayed or recurring task that the agent schedules for itself. When the trigger time arrives, the agent wakes up, reads its self-instruction, and continues working using other tools. The user can watch the execution live via WebSocket streaming. + +**Use cases:** +- Check build logs in 30 minutes +- Poll an API every 5 minutes +- Offload heavy multi-tool work without blocking the chat +- Continue work after a server restart + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ schedule_recall │────▶│ PostgreSQL │────▶│ recall loop │ +│ manage_recall │ │ session_recalls │ │ (poll + fire) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ Agent.run_stream │ + │ (headless) │ + └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ WebSocket │ + │ streaming to UI │ + └─────────────────┘ +``` + +## Data model + +Table: `session_recalls` + +| Column | Type | Description | +|---|---|---| +| `id` | `TEXT PK` | UUID | +| `session_id` | `TEXT FK` | References `sessions(id)` ON DELETE CASCADE | +| `call_type` | `TEXT` | `once`, `recurring`, `immediate` | +| `trigger_at` | `TIMESTAMPTZ` | When to fire (UTC) | +| `interval_seconds` | `INTEGER` | Repeat interval (required for `recurring`) | +| `internal_comment` | `TEXT` | Human-readable note | +| `additional_context_message` | `TEXT` | Self-instruction injected as system message | +| `status` | `TEXT` | `pending`, `fired`, `cancelled` | +| `created_at` | `TIMESTAMPTZ` | Creation time | +| `updated_at` | `TIMESTAMPTZ` | Last mutation | + +**Constraints:** +- Only **one pending recall per session** (unique partial index on `session_id WHERE status = 'pending'`). +- `immediate` recalls fire ASAP — `trigger_at` is set to `now()`. + +## Tools + +### `schedule_recall` + +Parameters: + +| Param | Type | Required | Description | +|---|---|---|---| +| `call_type` | `string` | yes | `once`, `recurring`, `immediate` | +| `when` | `string` | for once/recurring | Trigger time. Relative formats preferred: `30m`, `2h 15m`, `in 3 hours`, `tomorrow at 09:00`. ISO 8601 only if timezone is known. | +| `timezone_offset` | `string` | optional | `±HH:MM` — required for absolute times if user's timezone is known | +| `interval_seconds` | `integer` | for recurring | Repeat interval in seconds | +| `internal_comment` | `string` | optional | Human-readable note | +| `additional_context_message` | `string` | yes | Self-instruction — write it as a todo for yourself | + +**Rules for `additional_context_message`:** +- Must mention specific tools, files, or URLs +- Must read like a todo item, not a chat reminder +- BAD: `"Tell the user that 2 hours have passed"` +- GOOD: `"Read /tmp/build.log with filesystem. If errors, read last 50 lines and report."` + +### `manage_recall` + +Parameters: + +| Param | Type | Required | Description | +|---|---|---|---| +| `action` | `string` | yes | `cancel`, `skip`, `list` | +| `session_id` | `string` | optional | Target session (defaults to current) | + +**Actions:** +- `cancel` — deletes the pending recall. Standard pattern before scheduling a new one. +- `skip` — advances a recurring recall by `interval_seconds`. Uses `GREATEST(trigger_at, now)` to avoid scheduling in the past. +- `list` — shows all recalls (pending/fired/cancelled) for the session. + +## Scheduler loop + +`navi/core/scheduler.py::recall_scheduler_loop()` — background task that: + +1. Polls for pending recalls with `trigger_at <= now()` +2. Acquires a semaphore (max 3 concurrent headless runs) +3. For each due recall, calls `_fire_recall()` +4. Sleeps until the next pending recall (or 60 s if queue is empty) + +### `_fire_recall` behavior + +1. **Defer if busy** — if a websocket run is active for this session (`_runs`), reschedule +60 s +2. **Load session** — if session deleted, mark recall `cancelled` +3. **Set user context** — `current_user_id`, `current_user_role`, `current_user_info` so tools work correctly +4. **Block user messages** — add session to `_busy_sessions` (dict of `session_id → asyncio.Event`) +5. **Register _AgentRun** — so reconnecting clients can replay events +6. **Send `stream_start`** — clients begin showing the streaming message +7. **Run `agent.run_stream(is_recall=True)`** — stream all events to EventBus + WebSocket + replay buffer +8. **On success:** mark `fired` (once) or `reschedule` (recurring), publish `RecallUpdate` +9. **On `MaxIterationsReached`:** treated as success, not failure +10. **On exception:** mark `cancelled` (once) or `reschedule` (recurring) +11. **Finally:** remove from `_busy_sessions` and `_runs`, send `session_sync` + +### Stopping a recall + +`POST /sessions/{id}/stop` signals the recall's `stop_event` (same mechanism as stopping a normal run). + +## WebSocket integration + +During a headless recall run, the client receives the same events as a normal chat: + +``` +stream_start +thinking_delta +tool_started +tool_call +stream_delta +stream_end +session_sync +``` + +Plus out-of-run `recall_update` events whenever recall state changes. + +## UI behavior + +- **Banner** — shown in chat header when `chatStore.recall` is set. Displays trigger time + Cancel/Skip buttons. +- **Recall messages** — user messages with `is_recall=true` styled differently (light text, clock badge). +- **Sidebar** — sessions with pending recall show a clock icon. Filter button in header toggles "pending recalls only". +- **Real-time updates** — banner and sidebar update live via `recall_update` WebSocket events. + +## API endpoints + +See [`api.md`](api.md) for full schemas: + +- `GET /sessions/{id}/recall` — get pending recall +- `DELETE /sessions/{id}/recall` — cancel +- `POST /sessions/{id}/recall/skip` — skip next recurring occurrence + +## Files + +| File | Role | +|---|---| +| `navi/core/scheduler.py` | `RecallScheduler`, DDL, loop, `_fire_recall` | +| `navi/tools/schedule_recall.py` | `ScheduleRecallTool` | +| `navi/tools/manage_recall.py` | `ManageRecallTool` | +| `navi/tools/_internal/time_parser.py` | `parse_when()` — natural language time parser | +| `navi/api/routes/sessions.py` | REST endpoints for recall CRUD | +| `navi/api/websocket.py` | `_busy_sessions`, `_notify_session`, `_on_recall_update` | +| `navi/core/events.py` | `RecallUpdate` event class | +| `navi/llm/base.py` | `Message.is_recall` flag | diff --git a/docs/sessions.md b/docs/sessions.md index 5c5a18c..fb8fb8e 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -28,6 +28,7 @@ | `is_plan: bool` | Message is a planning phase output (shown as plan card in UI, not text) | | `is_compression: bool` | Marker message injected when context compression ran | | `is_summary: bool` | A summary message replacing compressed history in `session.context` | +| `is_recall: bool` | Message was generated by a scheduled recall (styled differently in UI) | | `thinking: str \| None` | LLM reasoning captured during a tool-calling turn | | `metadata: dict` | Tool result metadata (e.g. `is_image`, `base64`) | diff --git a/docs/tools.md b/docs/tools.md index bcdd147..6c4a034 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -40,6 +40,8 @@ | `TestToolTool` | `test_tool` | Run a user tool and verify its output | | `McpStatusTool` | `mcp_status` | Check connectivity and list tools for configured MCP servers | | `ReflectTool` | `reflect` | Self-reflection and analysis | +| `ScheduleRecallTool` | `schedule_recall` | Schedule a headless callback for the current session (once/recurring/immediate) | +| `ManageRecallTool` | `manage_recall` | Cancel, skip, or list scheduled recalls for the current session | ### User tools (`tools/*.py`) diff --git a/docs/websocket.md b/docs/websocket.md index 723628e..4a6a312 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -85,12 +85,14 @@ |---|---| | `{"type": "context_compressed", "messages_before": N, "messages_after": N, "summary": "...", "context_tokens": N, "max_context_tokens": N}` | After context compression runs | | `{"type": "profile_switched", "profile_id": "...", "profile_name": "..."}` | When `switch_profile` tool succeeds | +| `{"type": "recall_update", "session_id": "...", "recall_id": "...", "call_type": "...", "trigger_at": "...", "status": "...", "action": "..."}` | Recall state changed (scheduled, cancelled, fired, rescheduled) | | `{"type": "heartbeat"}` | Periodic keepalive during long silent operations (every 20 s) | | `{"type": "session_sync"}` | Client should reload session history from REST (`GET /sessions/{id}`) | -`session_sync` is sent in two situations: +`session_sync` is sent in three situations: 1. On fresh connect when no run is active — in case the agent finished while the client was disconnected. 2. After a reconnect-and-replay completes — to ensure the client sees the fully saved response. +3. After a headless recall run finishes — so the client sees the full recall turn (recall user message + assistant response). ---