mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aead911004 |
@@ -107,7 +107,6 @@ File operations have path traversal protection, but:
|
|||||||
**API Calls:**
|
**API Calls:**
|
||||||
- All external API calls use HTTPS by default
|
- All external API calls use HTTPS by default
|
||||||
- Timeouts are configured to prevent hanging requests
|
- Timeouts are configured to prevent hanging requests
|
||||||
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
|
|
||||||
- Consider using a firewall to restrict outbound connections if needed
|
- Consider using a firewall to restrict outbound connections if needed
|
||||||
|
|
||||||
**WhatsApp:**
|
**WhatsApp:**
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ If a local `nanobot agent` session can already answer normally, you can also ask
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings |
|
||||||
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control |
|
||||||
| Use slash commands and automations | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, local triggers, heartbeat tasks, and chat-side controls |
|
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls |
|
||||||
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior |
|
||||||
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions |
|
||||||
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup |
|
||||||
|
|||||||
@@ -103,8 +103,7 @@ class WebhookChannel(BaseChannel):
|
|||||||
msg.content — markdown text (convert to platform format as needed)
|
msg.content — markdown text (convert to platform format as needed)
|
||||||
msg.media — list of local file paths to attach
|
msg.media — list of local file paths to attach
|
||||||
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
|
||||||
msg.metadata — channel routing context such as message/thread ids
|
msg.metadata — may contain "_progress": True for streaming chunks
|
||||||
msg.event — typed runtime event for progress/status messages
|
|
||||||
"""
|
"""
|
||||||
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
|
||||||
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
# In a real plugin: POST to a callback URL, send via SDK, etc.
|
||||||
@@ -239,15 +238,15 @@ nanobot channels login <channel_name> --force # re-authenticate
|
|||||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||||
| `is_running` | Returns `self._running`. |
|
| `is_running` | Returns `self._running`. |
|
||||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||||
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||||
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||||
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||||
|
|
||||||
### Optional (streaming)
|
### Optional (streaming)
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
|
||||||
|
|
||||||
### Message Types
|
### Message Types
|
||||||
|
|
||||||
@@ -258,12 +257,10 @@ class OutboundMessage:
|
|||||||
chat_id: str # recipient (same value you passed to _handle_message)
|
chat_id: str # recipient (same value you passed to _handle_message)
|
||||||
content: str # markdown text — convert to platform format as needed
|
content: str # markdown text — convert to platform format as needed
|
||||||
media: list[str] # local file paths to attach (images, audio, docs)
|
media: list[str] # local file paths to attach (images, audio, docs)
|
||||||
metadata: dict # channel routing context, e.g. "message_id" for threading
|
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
|
||||||
event: object | None # typed runtime/UI event; usually inspect with isinstance()
|
# "message_id" for reply threading
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
|
|
||||||
|
|
||||||
## Streaming Support
|
## Streaming Support
|
||||||
|
|
||||||
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
|
||||||
@@ -282,18 +279,10 @@ If either is missing, the agent falls back to the normal one-shot `send()` path.
|
|||||||
Override `send_delta` to handle two types of calls:
|
Override `send_delta` to handle two types of calls:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
self,
|
meta = metadata or {}
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
if meta.get("_stream_end"):
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
if stream_end:
|
|
||||||
# Streaming finished — do final formatting, cleanup, etc.
|
# Streaming finished — do final formatting, cleanup, etc.
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -301,7 +290,12 @@ async def send_delta(
|
|||||||
# delta contains a small chunk of text (a few tokens)
|
# delta contains a small chunk of text (a few tokens)
|
||||||
```
|
```
|
||||||
|
|
||||||
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
|
**Metadata flags:**
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `_stream_delta: True` | A content chunk (delta contains the new text) |
|
||||||
|
| `_stream_end: True` | Streaming finished (delta is empty) |
|
||||||
|
|
||||||
### Example: Webhook with Streaming
|
### Example: Webhook with Streaming
|
||||||
|
|
||||||
@@ -316,27 +310,18 @@ class WebhookChannel(BaseChannel):
|
|||||||
super().__init__(config, bus)
|
super().__init__(config, bus)
|
||||||
self._buffers: dict[str, str] = {}
|
self._buffers: dict[str, str] = {}
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
self,
|
meta = metadata or {}
|
||||||
chat_id: str,
|
if meta.get("_stream_end"):
|
||||||
delta: str,
|
text = self._buffers.pop(chat_id, "")
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
if stream_end:
|
|
||||||
text = self._buffers.pop(buffer_key, "")
|
|
||||||
# Final delivery — format and send the complete message
|
# Final delivery — format and send the complete message
|
||||||
await self._deliver(chat_id, text, final=True)
|
await self._deliver(chat_id, text, final=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
self._buffers.setdefault(buffer_key, "")
|
self._buffers.setdefault(chat_id, "")
|
||||||
self._buffers[buffer_key] += delta
|
self._buffers[chat_id] += delta
|
||||||
# Incremental update — push partial text to the client
|
# Incremental update — push partial text to the client
|
||||||
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
|
await self._deliver(chat_id, self._buffers[chat_id], final=False)
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
# Non-streaming path — unchanged
|
# Non-streaming path — unchanged
|
||||||
@@ -365,7 +350,7 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
|
|||||||
|
|
||||||
| Method / Property | Description |
|
| Method / Property | Description |
|
||||||
|-------------------|-------------|
|
|-------------------|-------------|
|
||||||
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
|
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
|
||||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||||
|
|
||||||
## Progress, Tool Hints, and Reasoning
|
## Progress, Tool Hints, and Reasoning
|
||||||
@@ -374,20 +359,18 @@ Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These
|
|||||||
|
|
||||||
### Progress and Tool Hints
|
### Progress and Tool Hints
|
||||||
|
|
||||||
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
|
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
event = msg.event
|
meta = msg.metadata or {}
|
||||||
|
|
||||||
if isinstance(event, ProgressEvent) and event.tool_hint:
|
if meta.get("_tool_hint"):
|
||||||
# A short tool breadcrumb, e.g. read_file("config.json")
|
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||||
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(event, ProgressEvent):
|
if meta.get("_progress"):
|
||||||
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||||
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||||
return
|
return
|
||||||
@@ -429,33 +412,32 @@ class WebhookChannel(BaseChannel):
|
|||||||
chat_id: str,
|
chat_id: str,
|
||||||
delta: str,
|
delta: str,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
buffer_key = stream_id or chat_id
|
meta = metadata or {}
|
||||||
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
|
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||||
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
|
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
|
||||||
|
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
|
||||||
|
|
||||||
async def send_reasoning_end(
|
async def send_reasoning_end(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
buffer_key = stream_id or chat_id
|
meta = metadata or {}
|
||||||
text = self._reasoning_buffers.pop(buffer_key, "")
|
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||||
|
text = self._reasoning_buffers.pop(stream_id, "")
|
||||||
if text:
|
if text:
|
||||||
await self._update_reasoning_block(chat_id, text, final=True)
|
await self._update_reasoning_block(chat_id, text, final=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Reasoning arguments:**
|
**Reasoning metadata flags:**
|
||||||
|
|
||||||
| Argument | Meaning |
|
| Flag | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
|
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
|
||||||
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
|
||||||
| `send_reasoning_end()` | The current reasoning block is complete. |
|
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
|
||||||
|
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||||
|
|
||||||
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,24 @@ Optional session database path:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Optional activity cues:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"typingPresence": true,
|
||||||
|
"reactEmoji": "👀"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `typingPresence` to `false` to stop sending composing indicators. Set
|
||||||
|
`reactEmoji` to `""` to disable the temporary reaction while nanobot works.
|
||||||
|
Outbound WhatsApp messages preserve explicit mention metadata when a tool or
|
||||||
|
channel sends native WhatsApp mentions.
|
||||||
|
|
||||||
**Migrating from the old bridge**
|
**Migrating from the old bridge**
|
||||||
|
|
||||||
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
|
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ These commands work inside chat channels and interactive agent sessions:
|
|||||||
| `/dream-restore` | List recent Dream memory versions |
|
| `/dream-restore` | List recent Dream memory versions |
|
||||||
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
| `/dream-restore <sha>` | Restore memory to the state before a specific change |
|
||||||
| `/skill` | List enabled skills and their descriptions |
|
| `/skill` | List enabled skills and their descriptions |
|
||||||
| `/trigger` | Show local trigger usage |
|
|
||||||
| `/trigger <name>` | Create a named local trigger for the current chat/session |
|
|
||||||
| `/pairing` | List pending pairing requests |
|
| `/pairing` | List pending pairing requests |
|
||||||
| `/pairing approve <code>` | Approve a pairing code |
|
| `/pairing approve <code>` | Approve a pairing code |
|
||||||
| `/pairing deny <code>` | Deny a pending pairing request |
|
| `/pairing deny <code>` | Deny a pending pairing request |
|
||||||
@@ -57,65 +55,6 @@ To switch presets for future turns:
|
|||||||
|
|
||||||
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
|
||||||
|
|
||||||
## Local triggers
|
|
||||||
|
|
||||||
Use `/trigger <name>` when a local script or another service should be able to
|
|
||||||
send a message into the current chat/session later. A name is required; plain
|
|
||||||
`/trigger` only shows the usage hint.
|
|
||||||
|
|
||||||
Create the trigger from the chat where future messages should arrive:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/trigger PR review
|
|
||||||
```
|
|
||||||
|
|
||||||
nanobot replies with a trigger ID and a command shaped like:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
|
|
||||||
trigger is bound to the session where it was created, so the message goes back
|
|
||||||
to that same chat. Keep `nanobot gateway` running so trigger messages can be
|
|
||||||
delivered. The trigger message starts an automation turn recorded in that
|
|
||||||
session with the message you passed to the CLI; it is not treated as a normal
|
|
||||||
user message. If that session is already running a turn, the trigger waits
|
|
||||||
until the session is idle instead of being injected into the active turn.
|
|
||||||
|
|
||||||
Trigger deliveries are stored in the workspace until their linked agent turn
|
|
||||||
finishes successfully. If the gateway exits after claiming a delivery but before
|
|
||||||
the turn completes, the next gateway start requeues that delivery. This is an
|
|
||||||
at-least-once local queue: a delivery may run more than once if the process
|
|
||||||
exits at the wrong time, so external scripts should make repeated trigger
|
|
||||||
messages safe. If the delivery reaches the agent and the agent turn fails, the
|
|
||||||
delivery is marked failed in Automations instead of retrying forever.
|
|
||||||
|
|
||||||
For longer or generated content, omit the message argument and pipe stdin:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
printf '%s\n' "Review the latest failed CI job" | nanobot trigger trg_8K4P2Q9X
|
|
||||||
```
|
|
||||||
|
|
||||||
If an external webhook should wake nanobot up, run your own small webhook
|
|
||||||
service and have it call the trigger command after it builds the final message:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot trigger <trigger-id> "<message>"
|
|
||||||
```
|
|
||||||
|
|
||||||
If you run multiple nanobot instances, pass the same config or workspace
|
|
||||||
selector used by the gateway:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
|
|
||||||
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
|
|
||||||
```
|
|
||||||
|
|
||||||
Manage triggers from the WebUI Automations view. You can search, pause/resume,
|
|
||||||
rename, delete, and copy the trigger command there. A session may have multiple
|
|
||||||
triggers, just like it may have multiple scheduled automations.
|
|
||||||
|
|
||||||
## Periodic Tasks
|
## Periodic Tasks
|
||||||
|
|
||||||
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||||
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
|
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
|
||||||
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
|
||||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||||
@@ -123,52 +122,6 @@ http://127.0.0.1:18790/health
|
|||||||
|
|
||||||
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
|
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
|
||||||
|
|
||||||
## Local Triggers
|
|
||||||
|
|
||||||
`nanobot trigger` delivers one local message to a trigger that was created from
|
|
||||||
a chat/session with `/trigger <name>`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep `nanobot gateway` running so the message can be delivered to the linked
|
|
||||||
chat/session. The message is recorded as an automation turn in that session,
|
|
||||||
not as a normal chat message typed by the user.
|
|
||||||
|
|
||||||
The command writes to a workspace-local durable queue. If `nanobot gateway` is
|
|
||||||
not running yet, the message waits in that workspace. If the target session is
|
|
||||||
already running a turn, the trigger waits for that session to become idle. If the
|
|
||||||
gateway exits after claiming a delivery but before the linked turn completes,
|
|
||||||
the next gateway start requeues that delivery. The queue is at-least-once, not
|
|
||||||
exactly-once, so the same message can be delivered again after an interrupted
|
|
||||||
process. If the agent receives the delivery and the turn fails, the delivery is
|
|
||||||
marked failed instead of retried indefinitely. Each delivery also writes an
|
|
||||||
audit record under `<workspace>/triggers/runs`. Run one gateway consumer per
|
|
||||||
workspace; this local queue is not a distributed multi-consumer queue.
|
|
||||||
|
|
||||||
Use stdin when another local process generates the message:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
generate-report | nanobot trigger trg_8K4P2Q9X
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `nanobot trigger <id> "message"` | Deliver one message through a trigger |
|
|
||||||
| `nanobot trigger <id>` | Read the message from stdin |
|
|
||||||
| `nanobot trigger --config <path> <id> "message"` | Use the workspace from a specific config |
|
|
||||||
| `nanobot trigger --workspace <path> <id> "message"` | Use a specific workspace |
|
|
||||||
|
|
||||||
Triggers are managed in the WebUI Automations view instead of through separate
|
|
||||||
`list`, `revoke`, or `delete` CLI subcommands. From there you can pause/resume,
|
|
||||||
rename, delete, search, and copy the command for each trigger.
|
|
||||||
|
|
||||||
For webhooks or other external systems, run your own small service and have it
|
|
||||||
call this CLI after it decides what message nanobot should receive.
|
|
||||||
|
|
||||||
## OpenAI-Compatible API
|
## OpenAI-Compatible API
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
+3
-18
@@ -123,7 +123,7 @@ Tools are discovered automatically from built-in modules and plugin entry points
|
|||||||
- shell execution with configurable sandboxing;
|
- shell execution with configurable sandboxing;
|
||||||
- web search and web fetch with SSRF checks;
|
- web search and web fetch with SSRF checks;
|
||||||
- MCP servers;
|
- MCP servers;
|
||||||
- cron reminders, local triggers, and heartbeat tasks;
|
- cron reminders and heartbeat tasks;
|
||||||
- image generation;
|
- image generation;
|
||||||
- subagents and runtime self-inspection.
|
- subagents and runtime self-inspection.
|
||||||
|
|
||||||
@@ -131,29 +131,14 @@ Security-sensitive controls live in [`configuration.md#security`](./configuratio
|
|||||||
|
|
||||||
## Background Jobs
|
## Background Jobs
|
||||||
|
|
||||||
When `nanobot gateway` starts, it runs workspace-scoped automations and
|
When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<workspace>/cron/jobs.json` and registers system jobs:
|
||||||
registers system jobs:
|
|
||||||
|
|
||||||
- `dream`, when `agents.defaults.dream.enabled` is true;
|
- `dream`, when `agents.defaults.dream.enabled` is true;
|
||||||
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
|
- `heartbeat`, when `gateway.heartbeat.enabled` is true.
|
||||||
|
|
||||||
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
|
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
|
||||||
|
|
||||||
User-created reminders use the same cron service but are not the same as the
|
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. They run as scheduled turns in their origin chat/session and normally deliver the result back to that channel.
|
||||||
protected heartbeat system job. They run as scheduled turns in their origin
|
|
||||||
chat/session and normally deliver the result back to that channel.
|
|
||||||
|
|
||||||
Local triggers are also session-bound, but they do not have their own
|
|
||||||
schedule. Create one from the target chat with `/trigger <name>`, then call
|
|
||||||
`nanobot trigger <id> "<message>"` when a local script or external service wants
|
|
||||||
nanobot to respond in that session. Webhook servers, third-party auth, and
|
|
||||||
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
|
|
||||||
in the workspace until the linked agent turn finishes successfully. If the
|
|
||||||
target session is busy, the trigger waits until that session is idle instead of
|
|
||||||
being injected into the active turn. The message is recorded as an automation
|
|
||||||
turn in that session. Delivery is at-least-once, so external systems should
|
|
||||||
tolerate repeated trigger messages; a delivery that reaches the agent but fails
|
|
||||||
is marked failed rather than retried forever.
|
|
||||||
|
|
||||||
## Where to Go Next
|
## Where to Go Next
|
||||||
|
|
||||||
|
|||||||
+5
-36
@@ -240,7 +240,6 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
|||||||
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
|
||||||
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
|
||||||
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
|
||||||
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers and `openai_codex`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
|
|
||||||
|
|
||||||
| Provider | Purpose | Get API Key |
|
| Provider | Purpose | Get API Key |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
@@ -633,37 +632,20 @@ nanobot agent -m "Reply with one short sentence."
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
<summary><b>OpenAI Codex (OAuth)</b></summary>
|
||||||
|
|
||||||
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. `nanobot provider login` stores the OAuth session outside config. A `providers.openai_codex` block is optional and is only needed for provider-specific settings such as a proxy.
|
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||||
|
|
||||||
**1. Login:**
|
**1. Login:**
|
||||||
```bash
|
```bash
|
||||||
nanobot provider login openai-codex
|
nanobot provider login openai-codex
|
||||||
```
|
```
|
||||||
|
|
||||||
If the machine running nanobot cannot open a graphical browser, copy the printed URL into a real browser. For remote SSH login, open the URL locally, then paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted.
|
**2. Set model** (merge into `~/.nanobot/config.json`):
|
||||||
|
|
||||||
**2. Optional proxy** (merge into `~/.nanobot/config.json` if Codex OAuth or Codex API traffic must use a proxy):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"openai_codex": {
|
|
||||||
"proxy": "http://127.0.0.1:7890"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The proxy applies to Codex OAuth token refresh, interactive token exchange, and Codex Responses API requests. It does not affect other providers; configure `proxy` separately on each supported provider that needs it.
|
|
||||||
|
|
||||||
**3. Set model** (merge into `~/.nanobot/config.json`):
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"modelPresets": {
|
"modelPresets": {
|
||||||
"codex": {
|
"codex": {
|
||||||
"provider": "openai_codex",
|
"provider": "openai_codex",
|
||||||
"model": "gpt-5.1-codex",
|
"model": "openai-codex/gpt-5.1-codex"
|
||||||
"reasoningEffort": "high"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -674,9 +656,7 @@ The proxy applies to Codex OAuth token refresh, interactive token exchange, and
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `reasoningEffort` in the preset to send a Codex reasoning effort such as `"low"`, `"medium"`, `"high"`, or another value supported by the selected model. When `provider` is explicitly `openai_codex`, the model name does not need the `openai-codex/` prefix.
|
**3. Chat:**
|
||||||
|
|
||||||
**4. Chat:**
|
|
||||||
```bash
|
```bash
|
||||||
nanobot agent -m "Hello!"
|
nanobot agent -m "Hello!"
|
||||||
|
|
||||||
@@ -695,17 +675,7 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
<summary><b>GitHub Copilot (OAuth)</b></summary>
|
||||||
|
|
||||||
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
|
||||||
|
|
||||||
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
|
|
||||||
```bash
|
|
||||||
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
|
|
||||||
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
|
|
||||||
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
|
|
||||||
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
|
|
||||||
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
|
|
||||||
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
|
|
||||||
```
|
|
||||||
|
|
||||||
**1. Login:**
|
**1. Login:**
|
||||||
```bash
|
```bash
|
||||||
@@ -2004,7 +1974,6 @@ The heartbeat job is backed by the same cron service as user-created reminders.
|
|||||||
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
|
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
|
||||||
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
|
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
|
||||||
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
|
||||||
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
|
|
||||||
|
|
||||||
|
|
||||||
## Subagent Concurrency
|
## Subagent Concurrency
|
||||||
|
|||||||
@@ -12,32 +12,6 @@ Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or c
|
|||||||
|
|
||||||
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
|
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
|
|
||||||
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
|
|
||||||
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
|
|
||||||
endpoint on the network.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"api": {
|
|
||||||
"host": "0.0.0.0",
|
|
||||||
"port": 8900,
|
|
||||||
"apiKey": "${NANOBOT_API_KEY}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
|
|
||||||
endpoint remains unauthenticated so local probes and load balancers can still
|
|
||||||
check process health.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://127.0.0.1:8900/v1/models \
|
|
||||||
-H "Authorization: Bearer $NANOBOT_API_KEY"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
|
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)
|
||||||
|
|||||||
@@ -61,12 +61,9 @@ These fields answer different questions:
|
|||||||
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
|
||||||
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
|
||||||
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
|
||||||
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers and OpenAI Codex. |
|
|
||||||
|
|
||||||
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
|
||||||
|
|
||||||
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex`, including Codex OAuth token exchange/refresh and Codex Responses API requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
|
|
||||||
|
|
||||||
## Common Provider Patterns
|
## Common Provider Patterns
|
||||||
|
|
||||||
### OpenRouter Gateway
|
### OpenRouter Gateway
|
||||||
@@ -425,32 +422,6 @@ nanobot provider login github-copilot
|
|||||||
|
|
||||||
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks.
|
||||||
|
|
||||||
For OpenAI Codex, add `providers.openai_codex.proxy` only when Codex OAuth/token refresh or Codex API requests must use a proxy:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"providers": {
|
|
||||||
"openai_codex": {
|
|
||||||
"proxy": "http://127.0.0.1:7890"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"modelPresets": {
|
|
||||||
"codex": {
|
|
||||||
"provider": "openai_codex",
|
|
||||||
"model": "gpt-5.1-codex",
|
|
||||||
"reasoningEffort": "high"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"modelPreset": "codex"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If you run the login command on a remote/headless machine and open the authorization URL in a local browser, paste the final `http://localhost:1455/auth/callback?...` redirect URL back into the terminal when prompted. See [`configuration.md#providers`](./configuration.md#providers) for the full OAuth provider notes.
|
|
||||||
|
|
||||||
## Provider Resolution
|
## Provider Resolution
|
||||||
|
|
||||||
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
The recommended path is a named preset selected by `agents.defaults.modelPreset`. The effective model parameters come from:
|
||||||
|
|||||||
+8
-35
@@ -56,7 +56,7 @@ Enter `tokenIssueSecret` when the WebUI asks for a password.
|
|||||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets |
|
||||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||||
| Skills | Inspect available built-in and workspace skills before relying on them |
|
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns |
|
||||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||||
|
|
||||||
## Chat Workspace
|
## Chat Workspace
|
||||||
@@ -116,30 +116,10 @@ to perform that task.
|
|||||||
|
|
||||||
## Automations
|
## Automations
|
||||||
|
|
||||||
Automations are agent turns that run later in a linked chat/session. They should
|
Automations are scheduled agent turns. They should be created from the chat,
|
||||||
be created from the chat, channel, or session where they are supposed to run so
|
channel, or session where they are supposed to run so nanobot keeps the correct
|
||||||
nanobot keeps the correct target context. When an automation runs, it normally
|
target context. When an automation runs, it normally delivers the result back to
|
||||||
delivers the result back to that linked chat.
|
that linked chat.
|
||||||
|
|
||||||
There are two user-facing automation types:
|
|
||||||
|
|
||||||
- Scheduled automations, created by the agent's cron tool, run at a time,
|
|
||||||
interval, or cron expression.
|
|
||||||
- Local triggers, created with `/trigger <name>`, run when you call a local
|
|
||||||
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
|
|
||||||
|
|
||||||
If a GitHub webhook, CI system, or another service should wake nanobot up, keep
|
|
||||||
that webhook/service outside nanobot and have it call the trigger command with
|
|
||||||
the final message.
|
|
||||||
|
|
||||||
Trigger deliveries use the same workspace as the gateway. They survive gateway
|
|
||||||
restarts and are requeued if the process exits before the linked turn completes.
|
|
||||||
If the linked session is already running a turn, the local trigger waits until
|
|
||||||
that session is idle instead of being injected into the active turn. This is an
|
|
||||||
at-least-once local queue, so repeated delivery is possible after an interrupted
|
|
||||||
process. A delivered trigger is recorded as an automation turn in the linked
|
|
||||||
session; if the agent receives it but the turn fails, Automations marks the run
|
|
||||||
failed instead of retrying indefinitely.
|
|
||||||
|
|
||||||
For recurring background checks that should stay quiet unless there is something
|
For recurring background checks that should stay quiet unless there is something
|
||||||
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
|
||||||
@@ -148,25 +128,18 @@ instead of creating a chat automation.
|
|||||||
Use the Automations view to:
|
Use the Automations view to:
|
||||||
|
|
||||||
- Filter by all, active, paused, needs-attention, or system jobs.
|
- Filter by all, active, paused, needs-attention, or system jobs.
|
||||||
- Search by task name, message, trigger command, linked chat, schedule, or status.
|
- Search by task name, message, linked chat, schedule, or status.
|
||||||
- Sort by next run, last run, updated time, or name.
|
- Sort by next run, last run, updated time, or name.
|
||||||
- Run scheduled automations now.
|
- Run now, pause or resume, edit, or delete user-created automations.
|
||||||
- Pause or resume, rename, or delete user-created automations.
|
|
||||||
- Copy the CLI command for local triggers.
|
|
||||||
- Inspect protected system automations without changing them.
|
- Inspect protected system automations without changing them.
|
||||||
|
|
||||||
Search accepts plain text and field filters such as `name:backup`,
|
Search accepts plain text and field filters such as `name:backup`,
|
||||||
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
|
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`.
|
||||||
`status:paused`.
|
|
||||||
|
|
||||||
An automation without a linked chat cannot be enabled or run from the WebUI,
|
An automation without a linked chat cannot be enabled or run from the WebUI,
|
||||||
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
because nanobot would not know where to deliver the scheduled turn. Recreate it
|
||||||
from the target chat or channel so the automation has complete context.
|
from the target chat or channel so the automation has complete context.
|
||||||
|
|
||||||
Local triggers do not have a WebUI "Run now" action because each run needs a
|
|
||||||
message. Use the copied `nanobot trigger ...` command and replace `"message"`
|
|
||||||
with the content that should be delivered.
|
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Settings is the control surface for the browser session and gateway-backed
|
Settings is the control surface for the browser session and gateway-backed
|
||||||
|
|||||||
@@ -34,26 +34,6 @@ class AutoCompact:
|
|||||||
ts = datetime.fromisoformat(ts)
|
ts = datetime.fromisoformat(ts)
|
||||||
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
||||||
|
|
||||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
|
||||||
session = self.sessions.get_or_create(key)
|
|
||||||
tail = list(session.messages[session.last_consolidated:])
|
|
||||||
if not tail:
|
|
||||||
return False
|
|
||||||
probe = Session(
|
|
||||||
key=session.key,
|
|
||||||
messages=tail,
|
|
||||||
created_at=session.created_at,
|
|
||||||
updated_at=session.updated_at,
|
|
||||||
metadata={},
|
|
||||||
last_consolidated=0,
|
|
||||||
)
|
|
||||||
result = probe.retain_recent_legal_suffix(
|
|
||||||
self._RECENT_SUFFIX_MESSAGES,
|
|
||||||
extend_to_user=True,
|
|
||||||
)
|
|
||||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
|
||||||
return bool(messages_to_remove)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_summary(text: str, last_active: datetime) -> str:
|
def _format_summary(text: str, last_active: datetime) -> str:
|
||||||
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
|
||||||
@@ -72,8 +52,7 @@ class AutoCompact:
|
|||||||
continue
|
continue
|
||||||
if key in active_session_keys:
|
if key in active_session_keys:
|
||||||
continue
|
continue
|
||||||
updated_at = info.get("updated_at")
|
if self._is_expired(info.get("updated_at"), now):
|
||||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
|
||||||
self._archiving.add(key)
|
self._archiving.add(key)
|
||||||
schedule_background(self._archive(key))
|
schedule_background(self._archive(key))
|
||||||
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
"""Shared coordination for session-bound automation turns."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import dataclasses
|
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationTurnError(RuntimeError):
|
|
||||||
"""Raised when an automation turn reaches the agent and finishes with an error."""
|
|
||||||
|
|
||||||
|
|
||||||
async def publish_next_deferred_turn(
|
|
||||||
*,
|
|
||||||
deferred_queues: dict[str, list[InboundMessage]],
|
|
||||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
|
||||||
session_key: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Publish the next deferred automation turn for a session."""
|
|
||||||
queue = deferred_queues.get(session_key)
|
|
||||||
if not queue:
|
|
||||||
return False
|
|
||||||
msg = queue.pop(0)
|
|
||||||
if not queue:
|
|
||||||
deferred_queues.pop(session_key, None)
|
|
||||||
await publish_inbound(msg)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class AutomationTurnCoordinator:
|
|
||||||
"""Manage automation turns without mixing them into live injections."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
|
||||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
|
||||||
is_running: Callable[[], bool],
|
|
||||||
turn_id: Callable[[InboundMessage], str | None],
|
|
||||||
pending_id: Callable[[InboundMessage], str | None],
|
|
||||||
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
|
|
||||||
missing_id_error: str,
|
|
||||||
duplicate_id_error: Callable[[str], str],
|
|
||||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._publish_inbound = publish_inbound
|
|
||||||
self._dispatch = dispatch
|
|
||||||
self._is_running = is_running
|
|
||||||
self._turn_id = turn_id
|
|
||||||
self._pending_id = pending_id
|
|
||||||
self._should_defer_turn = should_defer_turn
|
|
||||||
self._missing_id_error = missing_id_error
|
|
||||||
self._duplicate_id_error = duplicate_id_error
|
|
||||||
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
|
|
||||||
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
|
||||||
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
|
|
||||||
|
|
||||||
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
||||||
"""Submit an automation turn and wait for its session response."""
|
|
||||||
turn_id = self._turn_id(msg)
|
|
||||||
if not turn_id:
|
|
||||||
raise ValueError(self._missing_id_error)
|
|
||||||
if turn_id in self._waiters:
|
|
||||||
raise RuntimeError(self._duplicate_id_error(turn_id))
|
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
|
||||||
self._waiters[turn_id] = future
|
|
||||||
self._pending_messages_by_turn_id[turn_id] = msg
|
|
||||||
try:
|
|
||||||
if self._is_running():
|
|
||||||
await self._publish_inbound(msg)
|
|
||||||
else:
|
|
||||||
await self._dispatch(msg)
|
|
||||||
try:
|
|
||||||
return await future
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
|
|
||||||
finally:
|
|
||||||
self._waiters.pop(turn_id, None)
|
|
||||||
self._pending_messages_by_turn_id.pop(turn_id, None)
|
|
||||||
|
|
||||||
def defer_if_active(
|
|
||||||
self,
|
|
||||||
msg: InboundMessage,
|
|
||||||
*,
|
|
||||||
session_key: str,
|
|
||||||
active_session_keys: Iterable[str],
|
|
||||||
) -> bool:
|
|
||||||
"""Defer an automation turn when its target session is already active."""
|
|
||||||
if not self._should_defer_turn(msg, session_key, active_session_keys):
|
|
||||||
return False
|
|
||||||
pending_msg = msg
|
|
||||||
if session_key != msg.session_key:
|
|
||||||
pending_msg = dataclasses.replace(
|
|
||||||
msg,
|
|
||||||
session_key_override=session_key,
|
|
||||||
)
|
|
||||||
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def complete(
|
|
||||||
self,
|
|
||||||
msg: InboundMessage,
|
|
||||||
*,
|
|
||||||
response: OutboundMessage | None = None,
|
|
||||||
error: BaseException | None = None,
|
|
||||||
) -> None:
|
|
||||||
turn_id = self._turn_id(msg)
|
|
||||||
if not turn_id:
|
|
||||||
return
|
|
||||||
future = self._waiters.get(turn_id)
|
|
||||||
if future is None or future.done():
|
|
||||||
return
|
|
||||||
if error is not None:
|
|
||||||
future.set_exception(error)
|
|
||||||
else:
|
|
||||||
future.set_result(response)
|
|
||||||
|
|
||||||
def pending_ids_for_session(self, session_key: str) -> set[str]:
|
|
||||||
"""Return automation IDs that are waiting for or running in *session_key*."""
|
|
||||||
pending_ids: set[str] = set()
|
|
||||||
for msg in self.deferred_queues.get(session_key, []):
|
|
||||||
pending_id = self._pending_id(msg)
|
|
||||||
if pending_id:
|
|
||||||
pending_ids.add(pending_id)
|
|
||||||
for msg in self._pending_messages_by_turn_id.values():
|
|
||||||
if msg.session_key != session_key:
|
|
||||||
continue
|
|
||||||
pending_id = self._pending_id(msg)
|
|
||||||
if pending_id:
|
|
||||||
pending_ids.add(pending_id)
|
|
||||||
return pending_ids
|
|
||||||
|
|
||||||
async def publish_next_deferred(self, session_key: str) -> bool:
|
|
||||||
return await publish_next_deferred_turn(
|
|
||||||
deferred_queues=self.deferred_queues,
|
|
||||||
publish_inbound=self._publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
@@ -36,23 +36,6 @@ COMPACTABLE_TOOLS = frozenset({
|
|||||||
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
|
||||||
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
|
||||||
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
|
||||||
PLACEHOLDER_TEXTS = frozenset({
|
|
||||||
"[Previous assistant message omitted.]",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_call_name_is_valid(tool_call: Any) -> bool:
|
|
||||||
"""Whether a persisted OpenAI-style tool_call carries a usable name.
|
|
||||||
|
|
||||||
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
|
|
||||||
message history: a degenerate call with ``name=None`` / ``""`` cannot be
|
|
||||||
executed and is rejected by upstream APIs if replayed.
|
|
||||||
"""
|
|
||||||
if not isinstance(tool_call, dict):
|
|
||||||
return False
|
|
||||||
fn = tool_call.get("function")
|
|
||||||
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
|
|
||||||
return isinstance(name, str) and bool(name)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -78,9 +61,7 @@ class ContextGovernor:
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
compacted_tool_call_ids: set[str],
|
compacted_tool_call_ids: set[str],
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
updated = self.strip_placeholder_assistant_messages(messages)
|
updated = self.drop_orphan_tool_results(messages)
|
||||||
updated = self.strip_malformed_tool_calls(updated)
|
|
||||||
updated = self.drop_orphan_tool_results(updated)
|
|
||||||
updated = self.backfill_missing_tool_results(updated)
|
updated = self.backfill_missing_tool_results(updated)
|
||||||
updated = self.apply_tool_result_budget(config, updated)
|
updated = self.apply_tool_result_budget(config, updated)
|
||||||
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
|
||||||
@@ -135,99 +116,6 @@ class ContextGovernor:
|
|||||||
return truncate_text(content, config.max_tool_result_chars)
|
return truncate_text(content, config.max_tool_result_chars)
|
||||||
return content
|
return content
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def strip_placeholder_assistant_messages(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Remove assistant messages that are compaction placeholders.
|
|
||||||
|
|
||||||
Messages like ``[Previous assistant message omitted.]`` carry no useful
|
|
||||||
context for the model and can cause it to repeatedly attempt tool calls
|
|
||||||
that previously failed, producing malformed responses in a loop.
|
|
||||||
Consecutive same-role messages that result from removal are handled
|
|
||||||
downstream by the provider's merge-consecutive logic. Only the
|
|
||||||
model-facing copy is repaired; the persisted transcript is untouched
|
|
||||||
(a copy is returned, or the same list object when nothing changes).
|
|
||||||
"""
|
|
||||||
updated: list[dict[str, Any]] | None = None
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
if msg.get("role") != "assistant":
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(msg)
|
|
||||||
continue
|
|
||||||
content = msg.get("content", "")
|
|
||||||
text = content if isinstance(content, str) else ""
|
|
||||||
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
|
|
||||||
has_tool_calls = bool(msg.get("tool_calls"))
|
|
||||||
if is_placeholder and not has_tool_calls:
|
|
||||||
if updated is None:
|
|
||||||
updated = list(messages[:idx])
|
|
||||||
logger.debug(
|
|
||||||
"Stripping placeholder assistant message from history: {!r}",
|
|
||||||
text[:60],
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(msg)
|
|
||||||
if updated is None:
|
|
||||||
return messages
|
|
||||||
return updated
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def strip_malformed_tool_calls(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Drop persisted assistant tool_calls whose name is missing/non-string.
|
|
||||||
|
|
||||||
A degenerate tool call (``name=None`` or ``""``) that slipped into the
|
|
||||||
saved history before this guard existed gets replayed on every turn and
|
|
||||||
makes upstream APIs reject the whole request
|
|
||||||
(``messages.content.N.tool_use.name: Input should be a valid string``),
|
|
||||||
permanently wedging the session. Removing the bad call here lets the
|
|
||||||
existing orphan-result cleanup drop its now-dangling tool result, so a
|
|
||||||
polluted session self-heals on its next turn. The persisted transcript
|
|
||||||
is left untouched; only the model-facing copy is repaired (a copy is
|
|
||||||
returned, or the same list object when nothing changes).
|
|
||||||
"""
|
|
||||||
updated: list[dict[str, Any]] | None = None
|
|
||||||
for idx, msg in enumerate(messages):
|
|
||||||
if msg.get("role") != "assistant":
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(msg)
|
|
||||||
continue
|
|
||||||
calls = msg.get("tool_calls")
|
|
||||||
if not calls:
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(msg)
|
|
||||||
continue
|
|
||||||
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
|
|
||||||
if len(kept) == len(calls):
|
|
||||||
if updated is not None:
|
|
||||||
updated.append(msg)
|
|
||||||
continue
|
|
||||||
if updated is None:
|
|
||||||
updated = [dict(m) for m in messages[:idx]]
|
|
||||||
logger.warning(
|
|
||||||
"Stripping {} malformed tool_call(s) with missing/non-string "
|
|
||||||
"name from assistant history before request",
|
|
||||||
len(calls) - len(kept),
|
|
||||||
)
|
|
||||||
repaired = dict(msg)
|
|
||||||
if kept:
|
|
||||||
repaired["tool_calls"] = kept
|
|
||||||
else:
|
|
||||||
repaired.pop("tool_calls", None)
|
|
||||||
# An assistant turn with neither content nor any valid tool call is
|
|
||||||
# itself invalid upstream; drop it entirely in that case.
|
|
||||||
has_content = bool(repaired.get("content"))
|
|
||||||
if not kept and not has_content:
|
|
||||||
continue
|
|
||||||
updated.append(repaired)
|
|
||||||
|
|
||||||
if updated is None:
|
|
||||||
return messages
|
|
||||||
return updated
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def drop_orphan_tool_results(
|
def drop_orphan_tool_results(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
|
|||||||
+107
-22
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import dataclasses
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
from collections.abc import Awaitable, Callable, Iterable
|
||||||
|
|
||||||
from nanobot.agent.automation_turns import AutomationTurnCoordinator
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.cron.session_turns import (
|
from nanobot.cron.session_turns import (
|
||||||
cron_run_id,
|
cron_run_id,
|
||||||
cron_trigger,
|
cron_trigger,
|
||||||
@@ -13,7 +14,7 @@ from nanobot.cron.session_turns import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CronTurnCoordinator(AutomationTurnCoordinator):
|
class CronTurnCoordinator:
|
||||||
"""Manage scheduled cron turns without mixing them into live injections."""
|
"""Manage scheduled cron turns without mixing them into live injections."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -22,31 +23,115 @@ class CronTurnCoordinator(AutomationTurnCoordinator):
|
|||||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
||||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
||||||
is_running: Callable[[], bool],
|
is_running: Callable[[], bool],
|
||||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
self._publish_inbound = publish_inbound
|
||||||
publish_inbound=publish_inbound,
|
self._dispatch = dispatch
|
||||||
dispatch=dispatch,
|
self._is_running = is_running
|
||||||
is_running=is_running,
|
self.deferred_queues: dict[str, list[InboundMessage]] = {}
|
||||||
turn_id=lambda msg: cron_run_id(msg.metadata),
|
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
|
||||||
pending_id=_cron_job_id,
|
self._pending_messages_by_run_id: dict[str, InboundMessage] = {}
|
||||||
should_defer_turn=_should_defer_cron_turn,
|
|
||||||
missing_id_error="cron turn metadata must include a run_id",
|
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
|
"""Submit a scheduled cron turn and wait for its session response."""
|
||||||
deferred_queues=deferred_queues,
|
run_id = cron_run_id(msg.metadata)
|
||||||
|
if not run_id:
|
||||||
|
raise ValueError("cron turn metadata must include a run_id")
|
||||||
|
if run_id in self._waiters:
|
||||||
|
raise RuntimeError(f"cron run {run_id!r} is already pending")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
|
||||||
|
self._waiters[run_id] = future
|
||||||
|
self._pending_messages_by_run_id[run_id] = msg
|
||||||
|
try:
|
||||||
|
if self._is_running():
|
||||||
|
await self._publish_inbound(msg)
|
||||||
|
else:
|
||||||
|
await self._dispatch(msg)
|
||||||
|
return await future
|
||||||
|
finally:
|
||||||
|
self._waiters.pop(run_id, None)
|
||||||
|
self._pending_messages_by_run_id.pop(run_id, None)
|
||||||
|
|
||||||
|
def should_defer(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
|
active_session_keys: Iterable[str],
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
defer_cron_until_session_idle(msg.metadata)
|
||||||
|
and session_key in active_session_keys
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def defer_if_active(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
|
active_session_keys: Iterable[str],
|
||||||
|
) -> bool:
|
||||||
|
"""Defer a cron turn when its target session is already active."""
|
||||||
|
if not self.should_defer(
|
||||||
|
msg,
|
||||||
|
session_key=session_key,
|
||||||
|
active_session_keys=active_session_keys,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
pending_msg = msg
|
||||||
|
if session_key != msg.session_key:
|
||||||
|
pending_msg = dataclasses.replace(
|
||||||
|
msg,
|
||||||
|
session_key_override=session_key,
|
||||||
|
)
|
||||||
|
self.defer(session_key, pending_msg)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def complete(
|
||||||
|
self,
|
||||||
|
msg: InboundMessage,
|
||||||
|
*,
|
||||||
|
response: OutboundMessage | None = None,
|
||||||
|
error: BaseException | None = None,
|
||||||
|
) -> None:
|
||||||
|
run_id = cron_run_id(msg.metadata)
|
||||||
|
if not run_id:
|
||||||
|
return
|
||||||
|
future = self._waiters.get(run_id)
|
||||||
|
if future is None or future.done():
|
||||||
|
return
|
||||||
|
if error is not None:
|
||||||
|
future.set_exception(error)
|
||||||
|
else:
|
||||||
|
future.set_result(response)
|
||||||
|
|
||||||
|
def defer(self, session_key: str, msg: InboundMessage) -> None:
|
||||||
|
self.deferred_queues.setdefault(session_key, []).append(msg)
|
||||||
|
|
||||||
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
|
def pending_job_ids_for_session(self, session_key: str) -> set[str]:
|
||||||
"""Return cron jobs that are waiting for or running in *session_key*."""
|
"""Return cron jobs that are waiting for or running in *session_key*."""
|
||||||
return self.pending_ids_for_session(session_key)
|
job_ids: set[str] = set()
|
||||||
|
for msg in self.deferred_queues.get(session_key, []):
|
||||||
|
job_id = _cron_job_id(msg)
|
||||||
|
if job_id:
|
||||||
|
job_ids.add(job_id)
|
||||||
|
for msg in self._pending_messages_by_run_id.values():
|
||||||
|
if msg.session_key != session_key:
|
||||||
|
continue
|
||||||
|
job_id = _cron_job_id(msg)
|
||||||
|
if job_id:
|
||||||
|
job_ids.add(job_id)
|
||||||
|
return job_ids
|
||||||
|
|
||||||
|
async def publish_next_deferred(self, session_key: str) -> None:
|
||||||
def _should_defer_cron_turn(
|
queue = self.deferred_queues.get(session_key)
|
||||||
msg: InboundMessage,
|
if not queue:
|
||||||
session_key: str,
|
return
|
||||||
active_session_keys: Iterable[str],
|
msg = queue.pop(0)
|
||||||
) -> bool:
|
if not queue:
|
||||||
return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
|
self.deferred_queues.pop(session_key, None)
|
||||||
|
await self._publish_inbound(msg)
|
||||||
|
|
||||||
|
|
||||||
def _cron_job_id(msg: InboundMessage) -> str | None:
|
def _cron_job_id(msg: InboundMessage) -> str | None:
|
||||||
|
|||||||
+42
-96
@@ -18,7 +18,6 @@ from loguru import logger
|
|||||||
from nanobot.agent import context as agent_context
|
from nanobot.agent import context as agent_context
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.agent.autocompact import AutoCompact
|
from nanobot.agent.autocompact import AutoCompact
|
||||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.cron_turns import CronTurnCoordinator
|
from nanobot.agent.cron_turns import CronTurnCoordinator
|
||||||
from nanobot.agent.hook import AgentHook, CompositeHook
|
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||||
@@ -32,13 +31,6 @@ from nanobot.agent.tools.message import MessageTool
|
|||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
RetryWaitEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
outbound_message_for_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.progress import build_bus_progress_callback
|
from nanobot.bus.progress import build_bus_progress_callback
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import (
|
from nanobot.bus.runtime_events import (
|
||||||
@@ -48,6 +40,9 @@ from nanobot.bus.runtime_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
|
from nanobot.cron.session_turns import (
|
||||||
|
cron_history_overrides,
|
||||||
|
)
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
@@ -56,19 +51,13 @@ from nanobot.security.workspace_access import (
|
|||||||
reset_workspace_scope,
|
reset_workspace_scope,
|
||||||
)
|
)
|
||||||
from nanobot.session import turn_continuation
|
from nanobot.session import turn_continuation
|
||||||
from nanobot.session.automation_turns import automation_history_overrides
|
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_runtime_lines,
|
goal_state_runtime_lines,
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
sustained_goal_active,
|
sustained_goal_active,
|
||||||
)
|
)
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||||
from nanobot.session.manager import (
|
from nanobot.session.manager import Session, SessionManager
|
||||||
Session,
|
|
||||||
SessionManager,
|
|
||||||
replay_max_messages_for_context,
|
|
||||||
)
|
|
||||||
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
|
|
||||||
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
from nanobot.utils.document import extract_documents, reference_non_image_attachments
|
||||||
from nanobot.utils.helpers import image_placeholder_text
|
from nanobot.utils.helpers import image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
@@ -212,6 +201,7 @@ class AgentLoop:
|
|||||||
timezone: str | None = None,
|
timezone: str | None = None,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
consolidation_ratio: float = 0.5,
|
consolidation_ratio: float = 0.5,
|
||||||
|
max_messages: int = 120,
|
||||||
hooks: list[AgentHook] | None = None,
|
hooks: list[AgentHook] | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
disabled_skills: list[str] | None = None,
|
disabled_skills: list[str] | None = None,
|
||||||
@@ -225,8 +215,6 @@ class AgentLoop:
|
|||||||
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
|
||||||
runtime_events: RuntimeEventBus | None = None,
|
runtime_events: RuntimeEventBus | None = None,
|
||||||
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
|
||||||
restart_mode: str = "auto",
|
|
||||||
local_trigger_store: Any | None = None,
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -236,7 +224,6 @@ class AgentLoop:
|
|||||||
self.runtime_events = runtime_events or RuntimeEventBus()
|
self.runtime_events = runtime_events or RuntimeEventBus()
|
||||||
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
self.runtime_event_publisher = RuntimeEventPublisher(self.runtime_events)
|
||||||
self.channels_config = channels_config
|
self.channels_config = channels_config
|
||||||
self.restart_mode = restart_mode
|
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
self._provider_snapshot_loader = provider_snapshot_loader
|
self._provider_snapshot_loader = provider_snapshot_loader
|
||||||
self._preset_snapshot_loader = preset_snapshot_loader
|
self._preset_snapshot_loader = preset_snapshot_loader
|
||||||
@@ -274,7 +261,6 @@ class AgentLoop:
|
|||||||
):
|
):
|
||||||
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
|
self._image_generation_provider_configs["openrouter"] = image_generation_provider_config
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
self.workspace_scopes = WorkspaceScopeResolver(
|
self.workspace_scopes = WorkspaceScopeResolver(
|
||||||
default_workspace=workspace,
|
default_workspace=workspace,
|
||||||
@@ -306,7 +292,7 @@ class AgentLoop:
|
|||||||
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
llm_wall_timeout_for_session=lambda sk: runner_wall_llm_timeout_s(self.sessions, sk),
|
||||||
)
|
)
|
||||||
self._unified_session = unified_session
|
self._unified_session = unified_session
|
||||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
self._max_messages = max_messages if max_messages > 0 else 120
|
||||||
self._running = False
|
self._running = False
|
||||||
self._mcp_servers = mcp_servers or {}
|
self._mcp_servers = mcp_servers or {}
|
||||||
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
self._mcp_stacks: dict[str, AsyncExitStack] = {}
|
||||||
@@ -319,22 +305,10 @@ class AgentLoop:
|
|||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
# are routed here instead of creating a new task.
|
# are routed here instead of creating a new task.
|
||||||
self._pending_queues: dict[str, asyncio.Queue] = {}
|
self._pending_queues: dict[str, asyncio.Queue] = {}
|
||||||
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
|
|
||||||
self._cron_turns = CronTurnCoordinator(
|
self._cron_turns = CronTurnCoordinator(
|
||||||
publish_inbound=self.bus.publish_inbound,
|
publish_inbound=self.bus.publish_inbound,
|
||||||
dispatch=self._dispatch,
|
dispatch=self._dispatch,
|
||||||
is_running=lambda: self._running,
|
is_running=lambda: self._running,
|
||||||
deferred_queues=self._deferred_automation_turns,
|
|
||||||
)
|
|
||||||
self._local_trigger_turns = LocalTriggerTurnCoordinator(
|
|
||||||
publish_inbound=self.bus.publish_inbound,
|
|
||||||
dispatch=self._dispatch,
|
|
||||||
is_running=lambda: self._running,
|
|
||||||
deferred_queues=self._deferred_automation_turns,
|
|
||||||
)
|
|
||||||
self._automation_turn_coordinators = (
|
|
||||||
("cron", self._cron_turns),
|
|
||||||
("local trigger", self._local_trigger_turns),
|
|
||||||
)
|
)
|
||||||
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
|
# NANOBOT_MAX_CONCURRENT_REQUESTS: <=0 means unlimited; default 3.
|
||||||
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
|
_max = int(os.environ.get("NANOBOT_MAX_CONCURRENT_REQUESTS", "3"))
|
||||||
@@ -416,10 +390,10 @@ class AgentLoop:
|
|||||||
disabled_skills=defaults.disabled_skills,
|
disabled_skills=defaults.disabled_skills,
|
||||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||||
consolidation_ratio=defaults.consolidation_ratio,
|
consolidation_ratio=defaults.consolidation_ratio,
|
||||||
|
max_messages=defaults.max_messages,
|
||||||
tools_config=config.tools,
|
tools_config=config.tools,
|
||||||
model_presets=preset_helpers.configured_model_presets(config),
|
model_presets=preset_helpers.configured_model_presets(config),
|
||||||
model_preset=defaults.model_preset,
|
model_preset=defaults.model_preset,
|
||||||
restart_mode=config.gateway.restart_mode,
|
|
||||||
provider_snapshot_loader=provider_snapshot_loader,
|
provider_snapshot_loader=provider_snapshot_loader,
|
||||||
preset_snapshot_loader=preset_snapshot_loader,
|
preset_snapshot_loader=preset_snapshot_loader,
|
||||||
**extra,
|
**extra,
|
||||||
@@ -447,7 +421,6 @@ class AgentLoop:
|
|||||||
self.runner.provider = provider
|
self.runner.provider = provider
|
||||||
self.subagents.set_provider(provider, model)
|
self.subagents.set_provider(provider, model)
|
||||||
self.consolidator.set_provider(provider, model, context_window_tokens)
|
self.consolidator.set_provider(provider, model, context_window_tokens)
|
||||||
self._sync_replay_max_messages()
|
|
||||||
self._provider_signature = snapshot.signature
|
self._provider_signature = snapshot.signature
|
||||||
if publish_update and self._runtime_model_publisher is not None:
|
if publish_update and self._runtime_model_publisher is not None:
|
||||||
self._runtime_model_publisher(
|
self._runtime_model_publisher(
|
||||||
@@ -461,9 +434,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
|
||||||
|
|
||||||
def _sync_replay_max_messages(self) -> None:
|
|
||||||
self._max_messages = replay_max_messages_for_context(self.context_window_tokens)
|
|
||||||
|
|
||||||
def _refresh_provider_snapshot(self) -> None:
|
def _refresh_provider_snapshot(self) -> None:
|
||||||
if self._provider_snapshot_loader is None:
|
if self._provider_snapshot_loader is None:
|
||||||
return
|
return
|
||||||
@@ -588,12 +558,14 @@ class AgentLoop:
|
|||||||
"""Build a retry-wait callback that publishes to the message bus."""
|
"""Build a retry-wait callback that publishes to the message bus."""
|
||||||
|
|
||||||
async def _on_retry_wait(content: str) -> None:
|
async def _on_retry_wait(content: str) -> None:
|
||||||
|
meta = dict(msg.metadata or {})
|
||||||
|
meta["_retry_wait"] = True
|
||||||
await self.bus.publish_outbound(
|
await self.bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
event=RetryWaitEvent(content=content),
|
content=content,
|
||||||
metadata=msg.metadata,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -605,22 +577,9 @@ class AgentLoop:
|
|||||||
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
return await self._cron_turns.submit(msg)
|
return await self._cron_turns.submit(msg)
|
||||||
|
|
||||||
async def submit_local_trigger_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
|
||||||
return await self._local_trigger_turns.submit(msg)
|
|
||||||
|
|
||||||
def pending_cron_job_ids_for_session(self, session_key: str) -> set[str]:
|
def pending_cron_job_ids_for_session(self, session_key: str) -> set[str]:
|
||||||
return self._cron_turns.pending_job_ids_for_session(session_key)
|
return self._cron_turns.pending_job_ids_for_session(session_key)
|
||||||
|
|
||||||
def pending_local_trigger_ids_for_session(self, session_key: str) -> set[str]:
|
|
||||||
return self._local_trigger_turns.pending_trigger_ids_for_session(session_key)
|
|
||||||
|
|
||||||
async def _publish_next_deferred_automation_turn(self, session_key: str) -> None:
|
|
||||||
await publish_next_deferred_turn(
|
|
||||||
deferred_queues=self._deferred_automation_turns,
|
|
||||||
publish_inbound=self.bus.publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _persist_user_message_early(
|
def _persist_user_message_early(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@@ -639,10 +598,10 @@ class AgentLoop:
|
|||||||
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
text = msg.content if isinstance(msg.content, str) else ""
|
||||||
text_override, automation_extra = automation_history_overrides(msg.metadata)
|
text_override, cron_extra = cron_history_overrides(msg.metadata)
|
||||||
if text_override is not None:
|
if text_override is not None:
|
||||||
text = text_override
|
text = text_override
|
||||||
extra.update(automation_extra)
|
extra.update(cron_extra)
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
@@ -950,21 +909,15 @@ class AgentLoop:
|
|||||||
self.commands.dispatch_priority,
|
self.commands.dispatch_priority,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
deferred = False
|
if self._cron_turns.defer_if_active(
|
||||||
for label, coordinator in self._automation_turn_coordinators:
|
|
||||||
if coordinator.defer_if_active(
|
|
||||||
msg,
|
msg,
|
||||||
session_key=effective_key,
|
session_key=effective_key,
|
||||||
active_session_keys=self._pending_queues.keys(),
|
active_session_keys=self._pending_queues.keys(),
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
"Deferred {} turn for active session {}",
|
"Deferred cron turn for active session {}",
|
||||||
label,
|
|
||||||
effective_key,
|
effective_key,
|
||||||
)
|
)
|
||||||
deferred = True
|
|
||||||
break
|
|
||||||
if deferred:
|
|
||||||
continue
|
continue
|
||||||
# If this session already has an active pending queue (i.e. a task
|
# If this session already has an active pending queue (i.e. a task
|
||||||
# is processing this session), route the message there for mid-turn
|
# is processing this session), route the message there for mid-turn
|
||||||
@@ -1037,31 +990,26 @@ class AgentLoop:
|
|||||||
return f"{stream_base_id}:{stream_segment}"
|
return f"{stream_base_id}:{stream_segment}"
|
||||||
|
|
||||||
async def on_stream(delta: str) -> None:
|
async def on_stream(delta: str) -> None:
|
||||||
await self.bus.publish_outbound(
|
meta = dict(msg.metadata or {})
|
||||||
outbound_message_for_event(
|
meta["_stream_delta"] = True
|
||||||
channel=msg.channel,
|
meta["_stream_id"] = _current_stream_id()
|
||||||
chat_id=msg.chat_id,
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
event=StreamDeltaEvent(
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
content=delta,
|
content=delta,
|
||||||
stream_id=_current_stream_id(),
|
metadata=meta,
|
||||||
),
|
))
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_stream_end(*, resuming: bool = False) -> None:
|
async def on_stream_end(*, resuming: bool = False) -> None:
|
||||||
nonlocal stream_segment
|
nonlocal stream_segment
|
||||||
await self.bus.publish_outbound(
|
meta = dict(msg.metadata or {})
|
||||||
outbound_message_for_event(
|
meta["_stream_end"] = True
|
||||||
channel=msg.channel,
|
meta["_resuming"] = resuming
|
||||||
chat_id=msg.chat_id,
|
meta["_stream_id"] = _current_stream_id()
|
||||||
event=StreamEndEvent(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
stream_id=_current_stream_id(),
|
channel=msg.channel, chat_id=msg.chat_id,
|
||||||
resuming=resuming,
|
content="",
|
||||||
),
|
metadata=meta,
|
||||||
metadata=msg.metadata,
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
stream_segment += 1
|
stream_segment += 1
|
||||||
|
|
||||||
response = await self._process_message(
|
response = await self._process_message(
|
||||||
@@ -1087,11 +1035,12 @@ class AgentLoop:
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
for _, coordinator in self._automation_turn_coordinators:
|
self._cron_turns.complete(msg, response=response)
|
||||||
coordinator.complete(msg, response=response)
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
for _, coordinator in self._automation_turn_coordinators:
|
self._cron_turns.complete(
|
||||||
coordinator.complete(msg, error=asyncio.CancelledError())
|
msg,
|
||||||
|
error=asyncio.CancelledError(),
|
||||||
|
)
|
||||||
logger.info("Task cancelled for session {}", session_key)
|
logger.info("Task cancelled for session {}", session_key)
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
# the user does not lose tool results and assistant
|
# the user does not lose tool results and assistant
|
||||||
@@ -1130,8 +1079,7 @@ class AgentLoop:
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
for _, coordinator in self._automation_turn_coordinators:
|
self._cron_turns.complete(msg, error=exc)
|
||||||
coordinator.complete(msg, error=exc)
|
|
||||||
finally:
|
finally:
|
||||||
# Drain any messages still in the pending queue and re-publish
|
# Drain any messages still in the pending queue and re-publish
|
||||||
# them to the bus so they are processed as fresh inbound messages
|
# them to the bus so they are processed as fresh inbound messages
|
||||||
@@ -1162,14 +1110,14 @@ class AgentLoop:
|
|||||||
msg, session_key, "idle"
|
msg, session_key, "idle"
|
||||||
)
|
)
|
||||||
self._runtime_events().clear_turn(session_key)
|
self._runtime_events().clear_turn(session_key)
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._cron_turns.publish_next_deferred(session_key)
|
||||||
finally:
|
finally:
|
||||||
if pending is None:
|
if pending is None:
|
||||||
await self._runtime_events().run_status_changed(
|
await self._runtime_events().run_status_changed(
|
||||||
msg, session_key, "idle"
|
msg, session_key, "idle"
|
||||||
)
|
)
|
||||||
self._runtime_events().clear_turn(session_key)
|
self._runtime_events().clear_turn(session_key)
|
||||||
await self._publish_next_deferred_automation_turn(session_key)
|
await self._cron_turns.publish_next_deferred(session_key)
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
"""Drain pending background archives, then close MCP connections."""
|
"""Drain pending background archives, then close MCP connections."""
|
||||||
@@ -1414,10 +1362,9 @@ class AgentLoop:
|
|||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
|
|
||||||
event = None
|
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
if on_stream is not None and stop_reason not in {"error", "tool_error"}:
|
||||||
event = StreamedResponseEvent()
|
meta["_streamed"] = True
|
||||||
if turn_latency_ms is not None:
|
if turn_latency_ms is not None:
|
||||||
meta["latency_ms"] = int(turn_latency_ms)
|
meta["latency_ms"] = int(turn_latency_ms)
|
||||||
|
|
||||||
@@ -1425,7 +1372,6 @@ class AgentLoop:
|
|||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
content=final_content,
|
content=final_content,
|
||||||
event=event,
|
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1483,7 +1429,7 @@ class AgentLoop:
|
|||||||
# message. Mark messages with _command so get_history can filter
|
# message. Mark messages with _command so get_history can filter
|
||||||
# them out of LLM context. /new is excluded because it
|
# them out of LLM context. /new is excluded because it
|
||||||
# intentionally clears the session.
|
# intentionally clears the session.
|
||||||
if cmd_ctx.raw.lower() != "/new":
|
if raw.lower() != "/new":
|
||||||
ctx.user_persisted_early = self._persist_user_message_early(
|
ctx.user_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg, ctx.session, _command=True
|
ctx.msg, ctx.session, _command=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MemoryStore — pure file I/O layer
|
# MemoryStore — pure file I/O layer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1005,6 +1006,7 @@ class Consolidator:
|
|||||||
|
|
||||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||||
if not messages_to_summarize:
|
if not messages_to_summarize:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -1016,11 +1018,12 @@ class Consolidator:
|
|||||||
metadata={},
|
metadata={},
|
||||||
last_consolidated=0,
|
last_consolidated=0,
|
||||||
)
|
)
|
||||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||||
messages_to_keep = probe.messages
|
messages_to_keep = probe.messages
|
||||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
messages_to_remove = dropped[already_consolidated:]
|
||||||
|
|
||||||
if not messages_to_remove and not messages_to_keep:
|
if not messages_to_remove and not messages_to_keep:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -1043,6 +1046,7 @@ class Consolidator:
|
|||||||
|
|
||||||
session.messages = messages_to_keep
|
session.messages = messages_to_keep
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
|
session.updated_at = datetime.now()
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
if messages_to_remove:
|
if messages_to_remove:
|
||||||
|
|||||||
+12
-128
@@ -18,7 +18,7 @@ from nanobot.agent.context_governance import (
|
|||||||
ContextGovernor,
|
ContextGovernor,
|
||||||
)
|
)
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.utils.file_edit_events import (
|
from nanobot.utils.file_edit_events import (
|
||||||
StreamingFileEditTracker,
|
StreamingFileEditTracker,
|
||||||
@@ -52,7 +52,6 @@ from nanobot.utils.runtime import (
|
|||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
repeated_tool_result_hint,
|
|
||||||
repeated_workspace_violation_error,
|
repeated_workspace_violation_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -352,7 +351,6 @@ class AgentRunner:
|
|||||||
stop_reason = "completed"
|
stop_reason = "completed"
|
||||||
tool_events: list[dict[str, str]] = []
|
tool_events: list[dict[str, str]] = []
|
||||||
external_lookup_counts: dict[str, int] = {}
|
external_lookup_counts: dict[str, int] = {}
|
||||||
repeated_result_counts: dict[str, int] = {}
|
|
||||||
# Per-turn throttle for repeated attempts against the same outside target.
|
# Per-turn throttle for repeated attempts against the same outside target.
|
||||||
workspace_violation_counts: dict[str, int] = {}
|
workspace_violation_counts: dict[str, int] = {}
|
||||||
empty_content_retries = 0
|
empty_content_retries = 0
|
||||||
@@ -391,15 +389,7 @@ class AgentRunner:
|
|||||||
spec.session_key or "default",
|
spec.session_key or "default",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
messages_for_model = ContextGovernor.strip_placeholder_assistant_messages(
|
messages_for_model = ContextGovernor.drop_orphan_tool_results(messages)
|
||||||
messages
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.strip_malformed_tool_calls(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.drop_orphan_tool_results(
|
|
||||||
messages_for_model
|
|
||||||
)
|
|
||||||
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
messages_for_model = ContextGovernor.backfill_missing_tool_results(
|
||||||
messages_for_model
|
messages_for_model
|
||||||
)
|
)
|
||||||
@@ -470,29 +460,17 @@ class AgentRunner:
|
|||||||
context.tool_results = list(results)
|
context.tool_results = list(results)
|
||||||
context.tool_events = list(new_events)
|
context.tool_events = list(new_events)
|
||||||
completed_tool_results: list[dict[str, Any]] = []
|
completed_tool_results: list[dict[str, Any]] = []
|
||||||
for tool_call, result, event in zip(response.tool_calls, results, new_events):
|
for tool_call, result in zip(response.tool_calls, results):
|
||||||
content = self.context_governor.normalize_tool_result(
|
|
||||||
governance_config,
|
|
||||||
tool_call.id,
|
|
||||||
tool_call.name,
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
if event.get("status") == "ok":
|
|
||||||
result_hint = repeated_tool_result_hint(
|
|
||||||
tool_call.name,
|
|
||||||
content,
|
|
||||||
repeated_result_counts,
|
|
||||||
)
|
|
||||||
if result_hint:
|
|
||||||
if isinstance(content, str):
|
|
||||||
content = content + result_hint
|
|
||||||
elif isinstance(content, list):
|
|
||||||
content = [*content, {"type": "text", "text": result_hint.strip()}]
|
|
||||||
tool_message = {
|
tool_message = {
|
||||||
"role": "tool",
|
"role": "tool",
|
||||||
"tool_call_id": tool_call.id,
|
"tool_call_id": tool_call.id,
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
"content": content,
|
"content": self.context_governor.normalize_tool_result(
|
||||||
|
governance_config,
|
||||||
|
tool_call.id,
|
||||||
|
tool_call.name,
|
||||||
|
result,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
messages.append(tool_message)
|
messages.append(tool_message)
|
||||||
completed_tool_results.append(tool_message)
|
completed_tool_results.append(tool_message)
|
||||||
@@ -747,8 +725,6 @@ class AgentRunner:
|
|||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
hook: AgentHook,
|
hook: AgentHook,
|
||||||
context: AgentHookContext,
|
context: AgentHookContext,
|
||||||
*,
|
|
||||||
malformed_retry: bool = False,
|
|
||||||
):
|
):
|
||||||
timeout_s: float | None = spec.llm_timeout_s
|
timeout_s: float | None = spec.llm_timeout_s
|
||||||
if timeout_s is None:
|
if timeout_s is None:
|
||||||
@@ -891,94 +867,8 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
if progress_state and progress_state.get("reasoning_open"):
|
if progress_state and progress_state.get("reasoning_open"):
|
||||||
await hook.emit_reasoning_end()
|
await hook.emit_reasoning_end()
|
||||||
dropped, all_dropped, original_finish_reason = (
|
|
||||||
self._drop_malformed_tool_calls(response)
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
all_dropped
|
|
||||||
and original_finish_reason in ("tool_calls", "function_call")
|
|
||||||
and not malformed_retry
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"Retrying LLM request after all {} malformed tool call(s) were dropped",
|
|
||||||
dropped,
|
|
||||||
)
|
|
||||||
retry_messages = self._malformed_tool_call_retry_messages(
|
|
||||||
messages, response.content,
|
|
||||||
)
|
|
||||||
return await self._request_model(
|
|
||||||
spec, retry_messages, hook, context,
|
|
||||||
malformed_retry=True,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
all_dropped
|
|
||||||
and original_finish_reason in ("tool_calls", "function_call")
|
|
||||||
and malformed_retry
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
"Malformed tool calls persisted after retry; falling back to no-tools request",
|
|
||||||
)
|
|
||||||
fallback_messages = self._malformed_tool_call_retry_messages(
|
|
||||||
messages, response.content,
|
|
||||||
)
|
|
||||||
return await self._request_no_tools(spec, fallback_messages)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _drop_malformed_tool_calls(
|
|
||||||
response: LLMResponse,
|
|
||||||
) -> tuple[int, bool, str | None]:
|
|
||||||
"""Strip tool calls whose name is missing/non-string from the response.
|
|
||||||
|
|
||||||
Returns (dropped_count, all_dropped, original_finish_reason).
|
|
||||||
|
|
||||||
A degenerate call (name=None or "") cannot be executed, and if it were
|
|
||||||
persisted into the assistant message it would be replayed on every
|
|
||||||
subsequent turn, causing upstream validation errors
|
|
||||||
(``tool_use.name: Input should be a valid string``) that permanently
|
|
||||||
wedge the session. Dropping it here keeps it out of execution, the
|
|
||||||
assistant message, and the saved history in one place.
|
|
||||||
"""
|
|
||||||
calls = getattr(response, "tool_calls", None)
|
|
||||||
if not calls:
|
|
||||||
return (0, False, getattr(response, "finish_reason", None))
|
|
||||||
valid = [tc for tc in calls if tc.has_valid_name()]
|
|
||||||
if len(valid) == len(calls):
|
|
||||||
return (0, False, getattr(response, "finish_reason", None))
|
|
||||||
dropped = len(calls) - len(valid)
|
|
||||||
original_finish_reason = getattr(response, "finish_reason", None)
|
|
||||||
logger.warning(
|
|
||||||
"Dropped {} malformed tool call(s) with missing/non-string name "
|
|
||||||
"from LLM response (finish_reason={!r})",
|
|
||||||
dropped,
|
|
||||||
original_finish_reason,
|
|
||||||
)
|
|
||||||
response.tool_calls = valid
|
|
||||||
if not valid:
|
|
||||||
response.finish_reason = "stop"
|
|
||||||
return (dropped, not valid, original_finish_reason)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _malformed_tool_call_retry_messages(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
assistant_text: str | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
retry_messages = list(messages)
|
|
||||||
note = (
|
|
||||||
"The previous model response attempted to call tools, but every tool call "
|
|
||||||
"was malformed: the tool_use blocks had missing or non-string tool names. "
|
|
||||||
"Do not answer with a promise to use tools. Either call the required tools again "
|
|
||||||
"using valid tool names from the provided tool list and JSON object inputs, or give "
|
|
||||||
"a final answer only if no tool is required."
|
|
||||||
)
|
|
||||||
if assistant_text:
|
|
||||||
note += (
|
|
||||||
f"\n\nPrevious assistant text before the malformed calls:\n"
|
|
||||||
f"{assistant_text}"
|
|
||||||
)
|
|
||||||
retry_messages.append({"role": "user", "content": note})
|
|
||||||
return retry_messages
|
|
||||||
|
|
||||||
async def _request_finalization_retry(
|
async def _request_finalization_retry(
|
||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
@@ -1149,10 +1039,7 @@ class AgentRunner:
|
|||||||
if spec.concurrent_tools and len(batch) > 1:
|
if spec.concurrent_tools and len(batch) > 1:
|
||||||
batch_results = await asyncio.gather(*(
|
batch_results = await asyncio.gather(*(
|
||||||
self._run_tool(
|
self._run_tool(
|
||||||
spec,
|
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||||
tool_call,
|
|
||||||
external_lookup_counts,
|
|
||||||
workspace_violation_counts,
|
|
||||||
)
|
)
|
||||||
for tool_call in batch
|
for tool_call in batch
|
||||||
))
|
))
|
||||||
@@ -1161,10 +1048,7 @@ class AgentRunner:
|
|||||||
batch_results = []
|
batch_results = []
|
||||||
for tool_call in batch:
|
for tool_call in batch:
|
||||||
result = await self._run_tool(
|
result = await self._run_tool(
|
||||||
spec,
|
spec, tool_call, external_lookup_counts, workspace_violation_counts,
|
||||||
tool_call,
|
|
||||||
external_lookup_counts,
|
|
||||||
workspace_violation_counts,
|
|
||||||
)
|
)
|
||||||
tool_results.append(result)
|
tool_results.append(result)
|
||||||
batch_results.append(result)
|
batch_results.append(result)
|
||||||
@@ -1286,7 +1170,7 @@ class AgentRunner:
|
|||||||
return payload, event, exc
|
return payload, event, exc
|
||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if is_tool_error_result(tool_call.name, result):
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
if file_edit_trackers and progress_callback is not None:
|
if file_edit_trackers and progress_callback is not None:
|
||||||
await invoke_file_edit_progress(
|
await invoke_file_edit_progress(
|
||||||
progress_callback,
|
progress_callback,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Agent tools module."""
|
"""Agent tools module."""
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Schema, Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Schema, Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ToolContext
|
from nanobot.agent.tools.context import ToolContext
|
||||||
from nanobot.agent.tools.loader import ToolLoader
|
from nanobot.agent.tools.loader import ToolLoader
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
@@ -25,7 +25,6 @@ __all__ = [
|
|||||||
"Tool",
|
"Tool",
|
||||||
"ToolContext",
|
"ToolContext",
|
||||||
"ToolLoader",
|
"ToolLoader",
|
||||||
"ToolResult",
|
|
||||||
"ToolRegistry",
|
"ToolRegistry",
|
||||||
"tool_parameters",
|
"tool_parameters",
|
||||||
"tool_parameters_schema",
|
"tool_parameters_schema",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import ToolResult, tool_parameters
|
from nanobot.agent.tools.base import tool_parameters
|
||||||
from nanobot.agent.tools.filesystem import _FsTool
|
from nanobot.agent.tools.filesystem import _FsTool
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
@@ -289,8 +289,8 @@ class ApplyPatchTool(_FsTool):
|
|||||||
_format_summary(summary) for summary in summaries
|
_format_summary(summary) for summary in summaries
|
||||||
)
|
)
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
return ToolResult.error(f"Error: {exc}")
|
return f"Error: {exc}"
|
||||||
except _PatchError as exc:
|
except _PatchError as exc:
|
||||||
return ToolResult.error(f"Error applying patch: {exc}")
|
return f"Error applying patch: {exc}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ToolResult.error(f"Error applying patch: {exc}")
|
return f"Error applying patch: {exc}"
|
||||||
|
|||||||
@@ -128,21 +128,6 @@ class Schema(ABC):
|
|||||||
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
|
return Schema.validate_json_schema_value(value, self.to_json_schema(), path)
|
||||||
|
|
||||||
|
|
||||||
class ToolResult(str):
|
|
||||||
"""String-compatible tool output with structured status."""
|
|
||||||
|
|
||||||
is_error: bool
|
|
||||||
|
|
||||||
def __new__(cls, content: str, *, is_error: bool = False) -> ToolResult:
|
|
||||||
obj = str.__new__(cls, content)
|
|
||||||
obj.is_error = is_error
|
|
||||||
return obj
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def error(cls, content: str) -> ToolResult:
|
|
||||||
return cls(content, is_error=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Tool(ABC):
|
class Tool(ABC):
|
||||||
"""Agent capability: read files, run commands, etc."""
|
"""Agent capability: read files, run commands, etc."""
|
||||||
|
|
||||||
@@ -208,13 +193,9 @@ class Tool(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
async def execute(self, **kwargs: Any) -> Any:
|
||||||
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
|
"""Run the tool; returns a string or list of content blocks."""
|
||||||
...
|
...
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def error(content: str) -> ToolResult:
|
|
||||||
return ToolResult.error(content)
|
|
||||||
|
|
||||||
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
|
||||||
if not isinstance(obj, dict):
|
if not isinstance(obj, dict):
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@@ -136,4 +136,4 @@ class CliAppsTool(Tool):
|
|||||||
restrict_to_workspace=access.restrict_to_workspace,
|
restrict_to_workspace=access.restrict_to_workspace,
|
||||||
)
|
)
|
||||||
except CliAppError as exc:
|
except CliAppError as exc:
|
||||||
return ToolResult.error(f"Error: {exc.message}")
|
return f"Error: {exc.message}"
|
||||||
|
|||||||
+10
-10
@@ -6,7 +6,7 @@ from contextvars import ContextVar
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@@ -99,7 +99,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
try:
|
try:
|
||||||
ZoneInfo(tz)
|
ZoneInfo(tz)
|
||||||
except (KeyError, Exception):
|
except (KeyError, Exception):
|
||||||
return ToolResult.error(f"Error: unknown timezone '{tz}'")
|
return f"Error: unknown timezone '{tz}'"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _display_timezone(self, schedule: CronSchedule) -> str:
|
def _display_timezone(self, schedule: CronSchedule) -> str:
|
||||||
@@ -148,7 +148,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
) -> str:
|
) -> str:
|
||||||
if action == "add":
|
if action == "add":
|
||||||
if self._in_cron_context.get():
|
if self._in_cron_context.get():
|
||||||
return ToolResult.error("Error: cannot schedule new jobs from within a cron job execution")
|
return "Error: cannot schedule new jobs from within a cron job execution"
|
||||||
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
|
return self._add_job(name, message, every_seconds, cron_expr, tz, at)
|
||||||
elif action == "list":
|
elif action == "list":
|
||||||
return self._list_jobs()
|
return self._list_jobs()
|
||||||
@@ -166,20 +166,20 @@ class CronTool(Tool, ContextAware):
|
|||||||
at: str | None,
|
at: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
if not message:
|
if not message:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: cron action='add' requires a non-empty 'message' parameter "
|
"Error: cron action='add' requires a non-empty 'message' parameter "
|
||||||
"describing what to do when the job triggers "
|
"describing what to do when the job triggers "
|
||||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||||
)
|
)
|
||||||
session_key = self._session_key.get()
|
session_key = self._session_key.get()
|
||||||
if not session_key:
|
if not session_key:
|
||||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
return "Error: scheduled cron jobs must be created from a chat session"
|
||||||
origin_channel = self._origin_channel.get()
|
origin_channel = self._origin_channel.get()
|
||||||
origin_chat_id = self._origin_chat_id.get()
|
origin_chat_id = self._origin_chat_id.get()
|
||||||
if not origin_channel or not origin_chat_id:
|
if not origin_channel or not origin_chat_id:
|
||||||
return ToolResult.error("Error: scheduled cron jobs must be created from a chat session")
|
return "Error: scheduled cron jobs must be created from a chat session"
|
||||||
if tz and not cron_expr:
|
if tz and not cron_expr:
|
||||||
return ToolResult.error("Error: tz can only be used with cron_expr")
|
return "Error: tz can only be used with cron_expr"
|
||||||
if tz:
|
if tz:
|
||||||
if err := self._validate_timezone(tz):
|
if err := self._validate_timezone(tz):
|
||||||
return err
|
return err
|
||||||
@@ -199,7 +199,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(at)
|
dt = datetime.fromisoformat(at)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return ToolResult.error(f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS")
|
return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS"
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
if err := self._validate_timezone(self._default_timezone):
|
if err := self._validate_timezone(self._default_timezone):
|
||||||
return err
|
return err
|
||||||
@@ -208,7 +208,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
||||||
delete_after = True
|
delete_after = True
|
||||||
else:
|
else:
|
||||||
return ToolResult.error("Error: either every_seconds, cron_expr, or at is required")
|
return "Error: either every_seconds, cron_expr, or at is required"
|
||||||
|
|
||||||
job = self._cron.add_job(
|
job = self._cron.add_job(
|
||||||
name=name or message[:30],
|
name=name or message[:30],
|
||||||
@@ -279,7 +279,7 @@ class CronTool(Tool, ContextAware):
|
|||||||
|
|
||||||
def _remove_job(self, job_id: str | None) -> str:
|
def _remove_job(self, job_id: str | None) -> str:
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return ToolResult.error("Error: job_id is required for remove")
|
return "Error: job_id is required for remove"
|
||||||
result = self._cron.remove_job(job_id)
|
result = self._cron.remove_job(job_id)
|
||||||
if result == "removed":
|
if result == "removed":
|
||||||
return f"Removed job {job_id}"
|
return f"Removed job {job_id}"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_session_key
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
@@ -492,12 +492,11 @@ class WriteStdinTool(Tool):
|
|||||||
max_output_chars=output_limit,
|
max_output_chars=output_limit,
|
||||||
owner_session_key=current_request_session_key(),
|
owner_session_key=current_request_session_key(),
|
||||||
)
|
)
|
||||||
result = format_session_poll(session_id, poll)
|
return format_session_poll(session_id, poll)
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return ToolResult.error(f"Error: exec session not found: {session_id!r}")
|
return f"Error: exec session not found: {session_id}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ToolResult.error(f"Error writing to exec session: {exc}")
|
return f"Error writing to exec session: {exc}"
|
||||||
|
|
||||||
async def _wait_for_output(
|
async def _wait_for_output(
|
||||||
self,
|
self,
|
||||||
@@ -533,14 +532,13 @@ class WriteStdinTool(Tool):
|
|||||||
joined = "".join(aggregate)
|
joined = "".join(aggregate)
|
||||||
if wait_for in joined:
|
if wait_for in joined:
|
||||||
poll.output = joined
|
poll.output = joined
|
||||||
result = format_session_poll(session_id, poll)
|
return format_session_poll(session_id, poll)
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
|
||||||
if poll.done or remaining_ms <= 0:
|
if poll.done or remaining_ms <= 0:
|
||||||
poll.output = "".join(aggregate)
|
poll.output = "".join(aggregate)
|
||||||
result = format_session_poll(session_id, poll)
|
result = format_session_poll(session_id, poll)
|
||||||
if wait_for not in poll.output:
|
if wait_for not in poll.output:
|
||||||
result += f"\nWait target not observed: {wait_for!r}"
|
result += f"\nWait target not observed: {wait_for!r}"
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(tool_parameters_schema())
|
@tool_parameters(tool_parameters_schema())
|
||||||
@@ -608,4 +606,4 @@ class ListExecSessionsTool(Tool):
|
|||||||
)
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ToolResult.error(f"Error listing exec sessions: {exc}")
|
return f"Error listing exec sessions: {exc}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
@@ -268,19 +268,19 @@ class ReadFileTool(_FsTool):
|
|||||||
) -> Any:
|
) -> Any:
|
||||||
try:
|
try:
|
||||||
if not path:
|
if not path:
|
||||||
return ToolResult.error("Error reading file: Unknown path")
|
return "Error reading file: Unknown path"
|
||||||
|
|
||||||
# Device path blacklist
|
# Device path blacklist
|
||||||
if _is_blocked_device(path):
|
if _is_blocked_device(path):
|
||||||
return ToolResult.error(f"Error: Reading {path} is blocked (device path that could hang or produce infinite output).")
|
return f"Error: Reading {path} is blocked (device path that could hang or produce infinite output)."
|
||||||
|
|
||||||
fp = self._resolve_read(path)
|
fp = self._resolve_read(path)
|
||||||
if _is_blocked_device(fp):
|
if _is_blocked_device(fp):
|
||||||
return ToolResult.error(f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output).")
|
return f"Error: Reading {fp} is blocked (device path that could hang or produce infinite output)."
|
||||||
if not fp.exists():
|
if not fp.exists():
|
||||||
return ToolResult.error(f"Error: File not found: {path}")
|
return f"Error: File not found: {path}"
|
||||||
if not fp.is_file():
|
if not fp.is_file():
|
||||||
return ToolResult.error(f"Error: Not a file: {path}")
|
return f"Error: Not a file: {path}"
|
||||||
|
|
||||||
# PDF support
|
# PDF support
|
||||||
if fp.suffix.lower() == ".pdf":
|
if fp.suffix.lower() == ".pdf":
|
||||||
@@ -343,7 +343,7 @@ class ReadFileTool(_FsTool):
|
|||||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||||
if mime and mime.startswith("image/"):
|
if mime and mime.startswith("image/"):
|
||||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||||
return ToolResult.error(f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported.")
|
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
|
||||||
|
|
||||||
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
||||||
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
||||||
@@ -357,7 +357,7 @@ class ReadFileTool(_FsTool):
|
|||||||
if offset < 1:
|
if offset < 1:
|
||||||
offset = 1
|
offset = 1
|
||||||
if offset > total:
|
if offset > total:
|
||||||
return ToolResult.error(f"Error: offset {offset} is beyond end of file ({total} lines)")
|
return f"Error: offset {offset} is beyond end of file ({total} lines)"
|
||||||
|
|
||||||
start = offset - 1
|
start = offset - 1
|
||||||
end = min(start + (limit or self._DEFAULT_LIMIT), total)
|
end = min(start + (limit or self._DEFAULT_LIMIT), total)
|
||||||
@@ -381,20 +381,20 @@ class ReadFileTool(_FsTool):
|
|||||||
self._file_states.record_read(fp, offset=offset, limit=limit)
|
self._file_states.record_read(fp, offset=offset, limit=limit)
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error reading file: {e}")
|
return f"Error reading file: {e}"
|
||||||
|
|
||||||
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
def _read_pdf(self, fp: Path, pages: str | None) -> str:
|
||||||
try:
|
try:
|
||||||
import fitz # pymupdf
|
import fitz # pymupdf
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error("Error: PDF reading requires pymupdf. Install with: pip install pymupdf")
|
return "Error: PDF reading requires pymupdf. Install with: pip install pymupdf"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
doc = fitz.open(str(fp))
|
doc = fitz.open(str(fp))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error reading PDF: {e}")
|
return f"Error reading PDF: {e}"
|
||||||
|
|
||||||
total_pages = len(doc)
|
total_pages = len(doc)
|
||||||
if pages:
|
if pages:
|
||||||
@@ -402,10 +402,10 @@ class ReadFileTool(_FsTool):
|
|||||||
start, end = _parse_page_range(pages, total_pages)
|
start, end = _parse_page_range(pages, total_pages)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
doc.close()
|
doc.close()
|
||||||
return ToolResult.error(f"Error: Invalid page range '{pages}'. Use format like '1-5'.")
|
return f"Error: Invalid page range '{pages}'. Use format like '1-5'."
|
||||||
if start > end or start >= total_pages:
|
if start > end or start >= total_pages:
|
||||||
doc.close()
|
doc.close()
|
||||||
return ToolResult.error(f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages).")
|
return f"Error: Page range '{pages}' is out of bounds (document has {total_pages} pages)."
|
||||||
else:
|
else:
|
||||||
start = 0
|
start = 0
|
||||||
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
end = min(total_pages - 1, self._MAX_PDF_PAGES - 1)
|
||||||
@@ -437,10 +437,10 @@ class ReadFileTool(_FsTool):
|
|||||||
result = extract_text(fp)
|
result = extract_text(fp)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
return ToolResult.error(f"Error: Unsupported file format: {fp.suffix}")
|
return f"Error: Unsupported file format: {fp.suffix}"
|
||||||
|
|
||||||
if result.startswith("[error:"):
|
if result.startswith("[error:"):
|
||||||
return ToolResult.error(f"Error reading {fp.suffix.upper()} file: {result}")
|
return f"Error reading {fp.suffix.upper()} file: {result}"
|
||||||
|
|
||||||
if not result:
|
if not result:
|
||||||
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
return f"({fp.suffix.upper().lstrip('.')} has no extractable text: {fp})"
|
||||||
@@ -492,9 +492,9 @@ class WriteFileTool(_FsTool):
|
|||||||
self._file_states.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
return f"Successfully wrote {len(content)} characters to {fp}"
|
return f"Successfully wrote {len(content)} characters to {fp}"
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error writing file: {e}")
|
return f"Error writing file: {e}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -830,11 +830,11 @@ class EditFileTool(_FsTool):
|
|||||||
if new_text is None:
|
if new_text is None:
|
||||||
raise ValueError("Unknown new_text")
|
raise ValueError("Unknown new_text")
|
||||||
if occurrence is not None and occurrence < 1:
|
if occurrence is not None and occurrence < 1:
|
||||||
return ToolResult.error("Error: occurrence must be >= 1.")
|
return "Error: occurrence must be >= 1."
|
||||||
if line_hint is not None and line_hint < 1:
|
if line_hint is not None and line_hint < 1:
|
||||||
return ToolResult.error("Error: line_hint must be >= 1.")
|
return "Error: line_hint must be >= 1."
|
||||||
if expected_replacements is not None and expected_replacements < 1:
|
if expected_replacements is not None and expected_replacements < 1:
|
||||||
return ToolResult.error("Error: expected_replacements must be >= 1.")
|
return "Error: expected_replacements must be >= 1."
|
||||||
|
|
||||||
fp = self._resolve_write(path)
|
fp = self._resolve_write(path)
|
||||||
|
|
||||||
@@ -853,14 +853,14 @@ class EditFileTool(_FsTool):
|
|||||||
except OSError:
|
except OSError:
|
||||||
fsize = 0
|
fsize = 0
|
||||||
if fsize > self._MAX_EDIT_FILE_SIZE:
|
if fsize > self._MAX_EDIT_FILE_SIZE:
|
||||||
return ToolResult.error(f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB.")
|
return f"Error: File too large to edit ({fsize / (1024**3):.1f} GiB). Maximum is 1 GiB."
|
||||||
|
|
||||||
# Create-file: old_text='' but file exists and not empty → reject
|
# Create-file: old_text='' but file exists and not empty → reject
|
||||||
if old_text == "":
|
if old_text == "":
|
||||||
raw = fp.read_bytes()
|
raw = fp.read_bytes()
|
||||||
content = raw.decode("utf-8")
|
content = raw.decode("utf-8")
|
||||||
if content.strip():
|
if content.strip():
|
||||||
return ToolResult.error(f"Error: Cannot create file — {path} already exists and is not empty.")
|
return f"Error: Cannot create file — {path} already exists and is not empty."
|
||||||
fp.write_text(new_text, encoding="utf-8")
|
fp.write_text(new_text, encoding="utf-8")
|
||||||
self._file_states.record_write(fp)
|
self._file_states.record_write(fp)
|
||||||
return f"Successfully edited {fp}"
|
return f"Successfully edited {fp}"
|
||||||
@@ -878,15 +878,15 @@ class EditFileTool(_FsTool):
|
|||||||
return self._not_found_msg(old_text, content, path)
|
return self._not_found_msg(old_text, content, path)
|
||||||
count = len(matches)
|
count = len(matches)
|
||||||
if replace_all and occurrence is not None:
|
if replace_all and occurrence is not None:
|
||||||
return ToolResult.error("Error: occurrence cannot be used with replace_all=true.")
|
return "Error: occurrence cannot be used with replace_all=true."
|
||||||
if replace_all and line_hint is not None:
|
if replace_all and line_hint is not None:
|
||||||
return ToolResult.error("Error: line_hint cannot be used with replace_all=true.")
|
return "Error: line_hint cannot be used with replace_all=true."
|
||||||
if occurrence is not None and line_hint is not None:
|
if occurrence is not None and line_hint is not None:
|
||||||
return ToolResult.error("Error: line_hint cannot be used with occurrence.")
|
return "Error: line_hint cannot be used with occurrence."
|
||||||
if count > 1 and not replace_all:
|
if count > 1 and not replace_all:
|
||||||
if occurrence is not None:
|
if occurrence is not None:
|
||||||
if occurrence > count:
|
if occurrence > count:
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: occurrence {occurrence} is out of range; "
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
f"old_text appears {count} times."
|
f"old_text appears {count} times."
|
||||||
)
|
)
|
||||||
@@ -894,7 +894,7 @@ class EditFileTool(_FsTool):
|
|||||||
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
|
||||||
distance = abs(nearest.line - line_hint)
|
distance = abs(nearest.line - line_hint)
|
||||||
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: line_hint {line_hint} is ambiguous; "
|
f"Error: line_hint {line_hint} is ambiguous; "
|
||||||
f"old_text appears {count} times."
|
f"old_text appears {count} times."
|
||||||
)
|
)
|
||||||
@@ -910,7 +910,7 @@ class EditFileTool(_FsTool):
|
|||||||
"or set replace_all=true."
|
"or set replace_all=true."
|
||||||
)
|
)
|
||||||
elif occurrence is not None and occurrence > count:
|
elif occurrence is not None and occurrence > count:
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: occurrence {occurrence} is out of range; "
|
f"Error: occurrence {occurrence} is out of range; "
|
||||||
f"old_text appears {count} time."
|
f"old_text appears {count} time."
|
||||||
)
|
)
|
||||||
@@ -928,7 +928,7 @@ class EditFileTool(_FsTool):
|
|||||||
else:
|
else:
|
||||||
selected = [matches[occurrence - 1 if occurrence else 0]]
|
selected = [matches[occurrence - 1 if occurrence else 0]]
|
||||||
if expected_replacements is not None and len(selected) != expected_replacements:
|
if expected_replacements is not None and len(selected) != expected_replacements:
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: expected {expected_replacements} replacements but "
|
f"Error: expected {expected_replacements} replacements but "
|
||||||
f"would make {len(selected)}."
|
f"would make {len(selected)}."
|
||||||
)
|
)
|
||||||
@@ -954,9 +954,9 @@ class EditFileTool(_FsTool):
|
|||||||
msg = f"{warning}\n{msg}"
|
msg = f"{warning}\n{msg}"
|
||||||
return msg
|
return msg
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error editing file: {e}")
|
return f"Error editing file: {e}"
|
||||||
|
|
||||||
def _file_not_found_msg(self, path: str, fp: Path) -> str:
|
def _file_not_found_msg(self, path: str, fp: Path) -> str:
|
||||||
"""Build an error message with 'Did you mean ...?' suggestions."""
|
"""Build an error message with 'Did you mean ...?' suggestions."""
|
||||||
@@ -969,7 +969,7 @@ class EditFileTool(_FsTool):
|
|||||||
parts = [f"Error: File not found: {path}"]
|
parts = [f"Error: File not found: {path}"]
|
||||||
if suggestions:
|
if suggestions:
|
||||||
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
|
parts.append("Did you mean: " + ", ".join(suggestions) + "?")
|
||||||
return ToolResult.error("\n".join(parts))
|
return "\n".join(parts)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
def _not_found_msg(old_text: str, content: str, path: str) -> str:
|
||||||
@@ -985,18 +985,18 @@ class EditFileTool(_FsTool):
|
|||||||
hint_text = ""
|
hint_text = ""
|
||||||
if hints:
|
if hints:
|
||||||
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
|
hint_text = "\nPossible cause: " + ", ".join(hints) + "."
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: old_text not found in {path}."
|
f"Error: old_text not found in {path}."
|
||||||
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
f"{hint_text}\nBest match ({best_ratio:.0%} similar) at line {best_start + 1}:\n{diff}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if hints:
|
if hints:
|
||||||
return ToolResult.error(
|
return (
|
||||||
f"Error: old_text not found in {path}. "
|
f"Error: old_text not found in {path}. "
|
||||||
f"Possible cause: {', '.join(hints)}. "
|
f"Possible cause: {', '.join(hints)}. "
|
||||||
"Copy the exact text from read_file and try again."
|
"Copy the exact text from read_file and try again."
|
||||||
)
|
)
|
||||||
return ToolResult.error(f"Error: old_text not found in {path}. No similar text found. Verify the file content.")
|
return f"Error: old_text not found in {path}. No similar text found. Verify the file content."
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1051,9 +1051,9 @@ class ListDirTool(_FsTool):
|
|||||||
raise ValueError("Unknown path")
|
raise ValueError("Unknown path")
|
||||||
dp = self._resolve(path)
|
dp = self._resolve(path)
|
||||||
if not dp.exists():
|
if not dp.exists():
|
||||||
return ToolResult.error(f"Error: Directory not found: {path}")
|
return f"Error: Directory not found: {path}"
|
||||||
if not dp.is_dir():
|
if not dp.is_dir():
|
||||||
return ToolResult.error(f"Error: Not a directory: {path}")
|
return f"Error: Not a directory: {path}"
|
||||||
|
|
||||||
cap = max_entries or self._DEFAULT_MAX
|
cap = max_entries or self._DEFAULT_MAX
|
||||||
items: list[str] = []
|
items: list[str] = []
|
||||||
@@ -1084,6 +1084,6 @@ class ListDirTool(_FsTool):
|
|||||||
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
|
result += f"\n\n(truncated, showing first {cap} of {total} entries)"
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error listing directory: {e}")
|
return f"Error listing directory: {e}"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
ArraySchema,
|
ArraySchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@@ -172,11 +172,11 @@ class ImageGenerationTool(Tool):
|
|||||||
) -> str:
|
) -> str:
|
||||||
client = self._provider_client()
|
client = self._provider_client()
|
||||||
if client is None:
|
if client is None:
|
||||||
return ToolResult.error(f"Error: unsupported image generation provider '{self.config.provider}'")
|
return f"Error: unsupported image generation provider '{self.config.provider}'"
|
||||||
|
|
||||||
requested = count or 1
|
requested = count or 1
|
||||||
if requested > self.config.max_images_per_turn:
|
if requested > self.config.max_images_per_turn:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
|
"Error: count exceeds tools.imageGeneration.maxImagesPerTurn "
|
||||||
f"({self.config.max_images_per_turn})"
|
f"({self.config.max_images_per_turn})"
|
||||||
)
|
)
|
||||||
@@ -206,4 +206,4 @@ class ImageGenerationTool(Tool):
|
|||||||
break
|
break
|
||||||
return generated_image_tool_result(artifacts)
|
return generated_image_tool_result(artifacts)
|
||||||
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
except (ArtifactError, ImageGenerationError, OSError) as exc:
|
||||||
return ToolResult.error(f"Error: {exc}")
|
return f"Error: {exc}"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
@@ -96,8 +96,6 @@ class ToolLoader:
|
|||||||
if not tool_cls.enabled(ctx):
|
if not tool_cls.enabled(ctx):
|
||||||
continue
|
continue
|
||||||
tool = tool_cls.create(ctx)
|
tool = tool_cls.create(ctx)
|
||||||
if is_plugin_source:
|
|
||||||
tool = _LegacyErrorPrefixTool(tool)
|
|
||||||
if registry.has(tool.name):
|
if registry.has(tool.name):
|
||||||
if is_plugin_source and tool.name in builtin_names:
|
if is_plugin_source and tool.name in builtin_names:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -116,67 +114,3 @@ class ToolLoader:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register tool: %s", cls_label)
|
logger.exception("Failed to register tool: %s", cls_label)
|
||||||
return registered
|
return registered
|
||||||
|
|
||||||
|
|
||||||
class _LegacyErrorPrefixTool(Tool):
|
|
||||||
"""Compatibility wrapper for external tools using the old error-string contract."""
|
|
||||||
|
|
||||||
_plugin_discoverable = False
|
|
||||||
|
|
||||||
def __init__(self, wrapped: Tool) -> None:
|
|
||||||
self._wrapped = wrapped
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._wrapped.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return self._wrapped.description
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return self._wrapped.parameters
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return self._wrapped.read_only
|
|
||||||
|
|
||||||
@property
|
|
||||||
def exclusive(self) -> bool:
|
|
||||||
return self._wrapped.exclusive
|
|
||||||
|
|
||||||
@property
|
|
||||||
def concurrency_safe(self) -> bool:
|
|
||||||
return self._wrapped.concurrency_safe
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config_key(self) -> str:
|
|
||||||
return getattr(self._wrapped, "config_key", "")
|
|
||||||
|
|
||||||
def set_context(self, ctx: Any) -> None:
|
|
||||||
set_context = getattr(self._wrapped, "set_context", None)
|
|
||||||
if callable(set_context):
|
|
||||||
set_context(ctx)
|
|
||||||
|
|
||||||
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return self._wrapped.cast_params(params)
|
|
||||||
|
|
||||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
||||||
return self._wrapped.validate_params(params)
|
|
||||||
|
|
||||||
def to_schema(self) -> dict[str, Any]:
|
|
||||||
return self._wrapped.to_schema()
|
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> Any:
|
|
||||||
result = await self._wrapped.execute(**kwargs)
|
|
||||||
if (
|
|
||||||
isinstance(result, str)
|
|
||||||
and not isinstance(result, ToolResult)
|
|
||||||
and result.startswith("Error:")
|
|
||||||
):
|
|
||||||
return ToolResult.error(result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
return getattr(self._wrapped, name)
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from contextvars import ContextVar
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
@@ -150,12 +150,12 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
|||||||
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, goal: str, ui_summary: str | None = None, **kwargs: Any) -> str:
|
||||||
sess = self._session()
|
sess = self._session()
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: long_task requires an active chat session (missing routing context)."
|
"Error: long_task requires an active chat session (missing routing context)."
|
||||||
)
|
)
|
||||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||||
if isinstance(prior, dict) and prior.get("status") == "active":
|
if isinstance(prior, dict) and prior.get("status") == "active":
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: a sustained goal is already active. "
|
"Error: a sustained goal is already active. "
|
||||||
"Use complete_goal when finished, or ask the user before replacing it."
|
"Use complete_goal when finished, or ask the user before replacing it."
|
||||||
)
|
)
|
||||||
@@ -230,7 +230,7 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|||||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
||||||
sess = self._session()
|
sess = self._session()
|
||||||
if sess is None:
|
if sess is None:
|
||||||
return ToolResult.error("Error: complete_goal requires an active chat session.")
|
return "Error: complete_goal requires an active chat session."
|
||||||
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
prior = parse_goal_state(goal_state_raw(sess.metadata))
|
||||||
if not isinstance(prior, dict) or prior.get("status") != "active":
|
if not isinstance(prior, dict) or prior.get("status") != "active":
|
||||||
return "No active goal to complete."
|
return "No active goal to complete."
|
||||||
|
|||||||
+15
-128
@@ -1,7 +1,6 @@
|
|||||||
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -14,7 +13,7 @@ from weakref import WeakKeyDictionary
|
|||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
INBOUND_META_RUNTIME_CONTROL,
|
||||||
@@ -166,31 +165,12 @@ async def _probe_http_url(url: str, timeout: float = 3.0) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _redact_url(url: str) -> str:
|
|
||||||
"""Strip credentials and query/fragment before logging an MCP URL.
|
|
||||||
|
|
||||||
Server URLs may embed secrets (``https://user:token@host/sse`` or a
|
|
||||||
``?token=`` query). Some deployments also put opaque tokens in the path, so
|
|
||||||
log only the origin and a path placeholder.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
parts = urllib.parse.urlsplit(url)
|
|
||||||
hostname = parts.hostname or ""
|
|
||||||
netloc = f"[{hostname}]" if ":" in hostname else hostname
|
|
||||||
if parts.port:
|
|
||||||
netloc = f"{netloc}:{parts.port}"
|
|
||||||
path = "/..." if parts.path and parts.path != "/" else parts.path
|
|
||||||
return urllib.parse.urlunsplit((parts.scheme, netloc, path, "", ""))
|
|
||||||
except Exception:
|
|
||||||
return "<redacted-url>"
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
async def _validate_mcp_request_url(request: httpx.Request) -> None:
|
||||||
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
"""Validate each outgoing MCP HTTP request, including redirect targets."""
|
||||||
ok, error = validate_url_target(str(request.url))
|
ok, error = validate_url_target(str(request.url))
|
||||||
if not ok:
|
if not ok:
|
||||||
raise httpx.RequestError(
|
raise httpx.RequestError(
|
||||||
f"Blocked unsafe MCP URL {_redact_url(str(request.url))} ({error})",
|
f"Blocked unsafe MCP URL {request.url} ({error})",
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -333,52 +313,6 @@ class _MCPWrapperBase(Tool):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|
||||||
"""Return a base64 ``data:`` URL for an MCP image-bearing content block.
|
|
||||||
|
|
||||||
Handles ``ImageContent`` directly and ``EmbeddedResource`` wrapping a binary
|
|
||||||
blob with an ``image/*`` MIME type. Returns ``None`` for anything else.
|
|
||||||
``getattr`` guards keep this safe when the installed/faked ``mcp`` SDK does
|
|
||||||
not expose a given type.
|
|
||||||
"""
|
|
||||||
image_cls = getattr(types, "ImageContent", None)
|
|
||||||
if image_cls is not None and isinstance(block, image_cls):
|
|
||||||
mime = getattr(block, "mimeType", None) or "image/png"
|
|
||||||
return f"data:{mime};base64,{block.data}"
|
|
||||||
|
|
||||||
embedded_cls = getattr(types, "EmbeddedResource", None)
|
|
||||||
blob_cls = getattr(types, "BlobResourceContents", None)
|
|
||||||
if embedded_cls is not None and isinstance(block, embedded_cls):
|
|
||||||
resource = getattr(block, "resource", None)
|
|
||||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
|
||||||
mime = getattr(resource, "mimeType", None) or ""
|
|
||||||
if isinstance(mime, str) and mime.startswith("image/"):
|
|
||||||
return f"data:{mime};base64,{resource.blob}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _mcp_image_tool_result(text_parts: list[str], artifacts: list[dict[str, Any]]) -> str:
|
|
||||||
"""Build the compact tool result for an MCP call that returned image(s).
|
|
||||||
|
|
||||||
The base64 stays out of the model context entirely — only artifact paths and
|
|
||||||
metadata are returned, so the result is small and the channel can deliver the
|
|
||||||
saved file via the message tool.
|
|
||||||
"""
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"artifacts": artifacts,
|
|
||||||
"next_step": (
|
|
||||||
"These images were returned by an MCP tool and saved as local artifacts. "
|
|
||||||
"Call the message tool with the artifact 'path' values in the media "
|
|
||||||
"parameter to deliver the images to the user. Do not paste base64 or raw "
|
|
||||||
"paths into your reply unless the user asks for debug details."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
text = "\n".join(part for part in text_parts if part)
|
|
||||||
if text:
|
|
||||||
payload["text"] = text
|
|
||||||
return json.dumps(payload, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
class MCPToolWrapper(_MCPWrapperBase):
|
class MCPToolWrapper(_MCPWrapperBase):
|
||||||
"""Wraps a single MCP server tool as a nanobot Tool."""
|
"""Wraps a single MCP server tool as a nanobot Tool."""
|
||||||
|
|
||||||
@@ -406,6 +340,8 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
return self._parameters
|
return self._parameters
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
|
from mcp import types
|
||||||
|
|
||||||
retried_transient = False
|
retried_transient = False
|
||||||
refreshed_session = False
|
refreshed_session = False
|
||||||
while True:
|
while True:
|
||||||
@@ -460,66 +396,17 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
)
|
)
|
||||||
return f"(MCP tool call failed: {type(exc).__name__})"
|
return f"(MCP tool call failed: {type(exc).__name__})"
|
||||||
else:
|
else:
|
||||||
# Success — extract text and persist any image content as artifacts.
|
# Success — extract result
|
||||||
rendered = self._render_call_result(result.content, kwargs)
|
parts = []
|
||||||
if getattr(result, "isError", False):
|
for block in result.content:
|
||||||
return ToolResult.error(rendered)
|
if isinstance(block, types.TextContent):
|
||||||
return rendered
|
parts.append(block.text)
|
||||||
|
else:
|
||||||
|
parts.append(str(block))
|
||||||
|
return "\n".join(parts) or "(no output)"
|
||||||
|
|
||||||
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
return "(MCP tool call failed)" # Unreachable, but satisfies type checkers
|
||||||
|
|
||||||
def _render_call_result(self, content: Any, arguments: Mapping[str, Any]) -> str:
|
|
||||||
"""Turn MCP content blocks into a tool result string.
|
|
||||||
|
|
||||||
Text is concatenated as before. Image blocks are decoded and saved as
|
|
||||||
local artifacts (mirroring the built-in image generation tool) so the
|
|
||||||
model can deliver them via the message tool instead of trying to forward
|
|
||||||
base64 — which would be truncated and bloat the context window.
|
|
||||||
"""
|
|
||||||
from mcp import types
|
|
||||||
|
|
||||||
text_parts: list[str] = []
|
|
||||||
artifacts: list[dict[str, Any]] = []
|
|
||||||
for block in content:
|
|
||||||
if isinstance(block, types.TextContent):
|
|
||||||
text_parts.append(block.text)
|
|
||||||
continue
|
|
||||||
data_url = _image_block_data_url(block, types)
|
|
||||||
if data_url is not None:
|
|
||||||
stored = self._store_image_block(data_url, arguments)
|
|
||||||
if stored is not None:
|
|
||||||
artifacts.append(stored)
|
|
||||||
else:
|
|
||||||
text_parts.append("(MCP tool returned an image that could not be stored)")
|
|
||||||
continue
|
|
||||||
text_parts.append(str(block))
|
|
||||||
|
|
||||||
if artifacts:
|
|
||||||
return _mcp_image_tool_result(text_parts, artifacts)
|
|
||||||
return "\n".join(text_parts) or "(no output)"
|
|
||||||
|
|
||||||
def _store_image_block(
|
|
||||||
self, data_url: str, arguments: Mapping[str, Any]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Persist one image data URL as an artifact; return its metadata or None."""
|
|
||||||
from nanobot.utils.artifacts import ArtifactError, store_generated_image_artifact
|
|
||||||
|
|
||||||
try:
|
|
||||||
return store_generated_image_artifact(
|
|
||||||
data_url,
|
|
||||||
prompt=str(arguments.get("prompt") or ""),
|
|
||||||
model=str(arguments.get("model") or ""),
|
|
||||||
save_dir="generated",
|
|
||||||
provider=f"mcp:{self._server_name}",
|
|
||||||
)
|
|
||||||
except (ArtifactError, OSError) as exc:
|
|
||||||
logger.warning(
|
|
||||||
"MCP tool '{}' returned an image that could not be stored: {}",
|
|
||||||
self._name,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class MCPResourceWrapper(_MCPWrapperBase):
|
class MCPResourceWrapper(_MCPWrapperBase):
|
||||||
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
|
"""Wraps an MCP resource URI as a read-only nanobot Tool."""
|
||||||
@@ -796,7 +683,7 @@ async def connect_mcp_servers(
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"MCP server '{}': blocked unsafe URL {} ({})",
|
"MCP server '{}': blocked unsafe URL {} ({})",
|
||||||
name,
|
name,
|
||||||
_redact_url(cfg.url),
|
cfg.url,
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
@@ -817,7 +704,7 @@ async def connect_mcp_servers(
|
|||||||
read, write = await server_stack.enter_async_context(stdio_client(params))
|
read, write = await server_stack.enter_async_context(stdio_client(params))
|
||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
return name, None
|
return name, None
|
||||||
|
|
||||||
@@ -844,7 +731,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, cfg.url)
|
||||||
await server_stack.aclose()
|
await server_stack.aclose()
|
||||||
return name, None
|
return name, None
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||||
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
|
||||||
@@ -198,7 +198,7 @@ class MessageTool(Tool, ContextAware):
|
|||||||
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
|
||||||
for row in buttons
|
for row in buttons
|
||||||
):
|
):
|
||||||
return ToolResult.error("Error: buttons must be a list of list of strings")
|
return "Error: buttons must be a list of list of strings"
|
||||||
default_channel = self._default_channel.get()
|
default_channel = self._default_channel.get()
|
||||||
default_chat_id = self._default_chat_id.get()
|
default_chat_id = self._default_chat_id.get()
|
||||||
channel = channel or default_channel
|
channel = channel or default_channel
|
||||||
@@ -210,7 +210,7 @@ class MessageTool(Tool, ContextAware):
|
|||||||
and str(explicit_chat_id).strip() != ""
|
and str(explicit_chat_id).strip() != ""
|
||||||
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
|
and str(explicit_chat_id).strip() != str(default_chat_id).strip()
|
||||||
):
|
):
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: chat_id does not match the active WebSocket conversation. "
|
"Error: chat_id does not match the active WebSocket conversation. "
|
||||||
"Omit chat_id (and usually channel) so delivery uses the current "
|
"Omit chat_id (and usually channel) so delivery uses the current "
|
||||||
"conversation id from context — WebSocket client_id strings "
|
"conversation id from context — WebSocket client_id strings "
|
||||||
@@ -229,16 +229,16 @@ class MessageTool(Tool, ContextAware):
|
|||||||
message_id = None
|
message_id = None
|
||||||
|
|
||||||
if not channel or not chat_id:
|
if not channel or not chat_id:
|
||||||
return ToolResult.error("Error: No target channel/chat specified")
|
return "Error: No target channel/chat specified"
|
||||||
|
|
||||||
if not self._send_callback:
|
if not self._send_callback:
|
||||||
return ToolResult.error("Error: Message sending not configured")
|
return "Error: Message sending not configured"
|
||||||
|
|
||||||
if media:
|
if media:
|
||||||
try:
|
try:
|
||||||
media = self._resolve_media(media)
|
media = self._resolve_media(media)
|
||||||
except (OSError, PermissionError, ValueError) as e:
|
except (OSError, PermissionError, ValueError) as e:
|
||||||
return ToolResult.error(f"Error: media path is not allowed: {str(e)}")
|
return f"Error: media path is not allowed: {str(e)}"
|
||||||
|
|
||||||
metadata = dict(self._default_metadata.get()) if same_target else {}
|
metadata = dict(self._default_metadata.get()) if same_target else {}
|
||||||
if message_id:
|
if message_id:
|
||||||
@@ -270,4 +270,4 @@ class MessageTool(Tool, ContextAware):
|
|||||||
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
||||||
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error sending message: {str(e)}")
|
return f"Error sending message: {str(e)}"
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool
|
||||||
|
|
||||||
|
|
||||||
def is_tool_error_result(name: str, result: Any) -> bool:
|
|
||||||
return isinstance(result, ToolResult) and result.is_error
|
|
||||||
|
|
||||||
|
|
||||||
class ToolRegistry:
|
class ToolRegistry:
|
||||||
@@ -104,26 +100,22 @@ class ToolRegistry:
|
|||||||
suggestion = self._suggest_name(str(name))
|
suggestion = self._suggest_name(str(name))
|
||||||
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
|
hint = f" Did you mean '{suggestion}'? Tool names must match exactly." if suggestion else ""
|
||||||
return None, params, (
|
return None, params, (
|
||||||
ToolResult.error(
|
|
||||||
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
|
f"Error: Tool '{name}' not found.{hint} Available: {', '.join(self.tool_names)}"
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
params = self._coerce_params(tool, params)
|
params = self._coerce_params(tool, params)
|
||||||
if not isinstance(params, dict):
|
if not isinstance(params, dict):
|
||||||
return tool, params, (
|
return tool, params, (
|
||||||
ToolResult.error(
|
|
||||||
f"Error: Tool '{name}' parameters must be a JSON object, got "
|
f"Error: Tool '{name}' parameters must be a JSON object, got "
|
||||||
f"{type(params).__name__}. Use named parameters like "
|
f"{type(params).__name__}. Use named parameters like "
|
||||||
'tool_name(param1="value1", param2="value2") matching the tool schema.'
|
'tool_name(param1="value1", param2="value2") matching the tool schema.'
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
cast_params = tool.cast_params(params)
|
cast_params = tool.cast_params(params)
|
||||||
errors = tool.validate_params(cast_params)
|
errors = tool.validate_params(cast_params)
|
||||||
if errors:
|
if errors:
|
||||||
return tool, cast_params, (
|
return tool, cast_params, (
|
||||||
ToolResult.error(f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors))
|
f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors)
|
||||||
)
|
)
|
||||||
return tool, cast_params, None
|
return tool, cast_params, None
|
||||||
|
|
||||||
@@ -167,16 +159,16 @@ class ToolRegistry:
|
|||||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||||
tool, params, error = self.prepare_call(name, params)
|
tool, params, error = self.prepare_call(name, params)
|
||||||
if error:
|
if error:
|
||||||
return ToolResult.error(str(error) + hint)
|
return error + hint
|
||||||
|
|
||||||
try:
|
try:
|
||||||
assert tool is not None # guarded by prepare_call()
|
assert tool is not None # guarded by prepare_call()
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
if is_tool_error_result(name, result):
|
if isinstance(result, str) and result.startswith("Error"):
|
||||||
return ToolResult.error(str(result) + hint)
|
return result + hint
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
|
return f"Error executing {name}: {str(e)}" + hint
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from contextlib import suppress
|
|||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Iterable, TypeVar
|
from typing import Any, Iterable, TypeVar
|
||||||
|
|
||||||
from nanobot.agent.tools.base import ToolResult
|
|
||||||
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
|
||||||
|
|
||||||
_DEFAULT_HEAD_LIMIT = 250
|
_DEFAULT_HEAD_LIMIT = 250
|
||||||
@@ -219,12 +218,12 @@ class FindFilesTool(_SearchTool):
|
|||||||
try:
|
try:
|
||||||
target = self._resolve(path or ".")
|
target = self._resolve(path or ".")
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
return ToolResult.error(f"Error: Path not found: {path}")
|
return f"Error: Path not found: {path}"
|
||||||
if not (target.is_dir() or target.is_file()):
|
if not (target.is_dir() or target.is_file()):
|
||||||
return ToolResult.error(f"Error: Unsupported path: {path}")
|
return f"Error: Unsupported path: {path}"
|
||||||
|
|
||||||
if sort not in {"path", "modified"}:
|
if sort not in {"path", "modified"}:
|
||||||
return ToolResult.error("Error: sort must be 'path' or 'modified'")
|
return "Error: sort must be 'path' or 'modified'"
|
||||||
|
|
||||||
limit = (
|
limit = (
|
||||||
_DEFAULT_FILE_HEAD_LIMIT
|
_DEFAULT_FILE_HEAD_LIMIT
|
||||||
@@ -272,9 +271,9 @@ class FindFilesTool(_SearchTool):
|
|||||||
result += "\n\n" + note
|
result += "\n\n" + note
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error finding files: {e}")
|
return f"Error finding files: {e}"
|
||||||
|
|
||||||
|
|
||||||
class GrepTool(_SearchTool):
|
class GrepTool(_SearchTool):
|
||||||
@@ -426,16 +425,16 @@ class GrepTool(_SearchTool):
|
|||||||
try:
|
try:
|
||||||
target = self._resolve(path or ".")
|
target = self._resolve(path or ".")
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
return ToolResult.error(f"Error: Path not found: {path}")
|
return f"Error: Path not found: {path}"
|
||||||
if not (target.is_dir() or target.is_file()):
|
if not (target.is_dir() or target.is_file()):
|
||||||
return ToolResult.error(f"Error: Unsupported path: {path}")
|
return f"Error: Unsupported path: {path}"
|
||||||
|
|
||||||
flags = re.IGNORECASE if case_insensitive else 0
|
flags = re.IGNORECASE if case_insensitive else 0
|
||||||
try:
|
try:
|
||||||
needle = re.escape(pattern) if fixed_strings else pattern
|
needle = re.escape(pattern) if fixed_strings else pattern
|
||||||
regex = re.compile(needle, flags)
|
regex = re.compile(needle, flags)
|
||||||
except re.error as e:
|
except re.error as e:
|
||||||
return ToolResult.error(f"Error: invalid regex pattern: {e}")
|
return f"Error: invalid regex pattern: {e}"
|
||||||
|
|
||||||
if head_limit is not None:
|
if head_limit is not None:
|
||||||
limit = None if head_limit == 0 else head_limit
|
limit = None if head_limit == 0 else head_limit
|
||||||
@@ -580,6 +579,6 @@ class GrepTool(_SearchTool):
|
|||||||
result += "\n\n" + "\n".join(notes)
|
result += "\n\n" + "\n".join(notes)
|
||||||
return result
|
return result
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error searching files: {e}")
|
return f"Error searching files: {e}"
|
||||||
|
|||||||
+24
-27
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.context import ContextAware, RequestContext
|
from nanobot.agent.tools.context import ContextAware, RequestContext
|
||||||
from nanobot.agent.tools.runtime_state import RuntimeState
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
@@ -216,7 +216,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate_key(key: str | None, label: str = "key") -> str | None:
|
def _validate_key(key: str | None, label: str = "key") -> str | None:
|
||||||
if not key or not key.strip():
|
if not key or not key.strip():
|
||||||
return ToolResult.error(f"Error: '{label}' cannot be empty or whitespace")
|
return f"Error: '{label}' cannot be empty or whitespace"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -321,7 +321,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
if action in ("inspect", "check"):
|
if action in ("inspect", "check"):
|
||||||
return self._inspect(key)
|
return self._inspect(key)
|
||||||
if not self._modify_allowed:
|
if not self._modify_allowed:
|
||||||
return ToolResult.error("Error: set is disabled (tools.my.allow_set is false)")
|
return "Error: set is disabled (tools.my.allow_set is false)"
|
||||||
if action in ("modify", "set"):
|
if action in ("modify", "set"):
|
||||||
return self._modify(key, value)
|
return self._modify(key, value)
|
||||||
return f"Unknown action: {action}"
|
return f"Unknown action: {action}"
|
||||||
@@ -333,7 +333,7 @@ class MyTool(Tool, ContextAware):
|
|||||||
return self._inspect_all()
|
return self._inspect_all()
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return f"Error: '{top}' is not accessible"
|
||||||
obj, err = self._resolve_path(key)
|
obj, err = self._resolve_path(key)
|
||||||
if err:
|
if err:
|
||||||
# "scratchpad" alias for _runtime_vars
|
# "scratchpad" alias for _runtime_vars
|
||||||
@@ -343,12 +343,12 @@ class MyTool(Tool, ContextAware):
|
|||||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||||
if "." not in key and key in self._runtime_state._runtime_vars:
|
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||||
return ToolResult.error(f"Error: {err}")
|
return f"Error: {err}"
|
||||||
# Guard against mock auto-generated attributes
|
# Guard against mock auto-generated attributes
|
||||||
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||||
if key in self._runtime_state._runtime_vars:
|
if key in self._runtime_state._runtime_vars:
|
||||||
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
return f"Error: '{key}' not found"
|
||||||
return self._format_value(obj, key)
|
return self._format_value(obj, key)
|
||||||
|
|
||||||
def _inspect_all(self) -> str:
|
def _inspect_all(self) -> str:
|
||||||
@@ -379,21 +379,21 @@ class MyTool(Tool, ContextAware):
|
|||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED {key}")
|
self._audit("modify", f"BLOCKED {key}")
|
||||||
return ToolResult.error(f"Error: '{key}' is protected and cannot be modified")
|
return f"Error: '{key}' is protected and cannot be modified"
|
||||||
if top in self.READ_ONLY:
|
if top in self.READ_ONLY:
|
||||||
self._audit("modify", f"READ_ONLY {key}")
|
self._audit("modify", f"READ_ONLY {key}")
|
||||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
return f"Error: '{key}' is read-only and cannot be modified"
|
||||||
if "." in key:
|
if "." in key:
|
||||||
parent_path, leaf = key.rsplit(".", 1)
|
parent_path, leaf = key.rsplit(".", 1)
|
||||||
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
|
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
|
||||||
self._audit("modify", f"BLOCKED leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
return f"Error: '{leaf}' is not accessible"
|
||||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
return f"Error: '{leaf}' is not accessible"
|
||||||
parent, err = self._resolve_path(parent_path)
|
parent, err = self._resolve_path(parent_path)
|
||||||
if err:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return f"Error: {err}"
|
||||||
if isinstance(parent, dict):
|
if isinstance(parent, dict):
|
||||||
parent[leaf] = value
|
parent[leaf] = value
|
||||||
else:
|
else:
|
||||||
@@ -408,11 +408,11 @@ class MyTool(Tool, ContextAware):
|
|||||||
|
|
||||||
def _modify_model_preset(self, value: Any) -> str:
|
def _modify_model_preset(self, value: Any) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return "Error: 'model_preset' must be a non-empty string"
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
result = self._modify_free("model_preset", name)
|
result = self._modify_free("model_preset", name)
|
||||||
if isinstance(result, ToolResult) and result.is_error:
|
if result.startswith("Error:"):
|
||||||
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
return result if result.endswith((".", "!", "?")) else f"{result}."
|
||||||
return (
|
return (
|
||||||
f"{result}; model is now {self._runtime_state.model!r}; "
|
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||||
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||||
@@ -422,25 +422,22 @@ class MyTool(Tool, ContextAware):
|
|||||||
spec = self.RESTRICTED[key]
|
spec = self.RESTRICTED[key]
|
||||||
expected = spec["type"]
|
expected = spec["type"]
|
||||||
if expected is int and isinstance(value, bool):
|
if expected is int and isinstance(value, bool):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
|
return f"Error: '{key}' must be {expected.__name__}, got bool"
|
||||||
if not isinstance(value, expected):
|
if not isinstance(value, expected):
|
||||||
try:
|
try:
|
||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||||
old = getattr(self._runtime_state, key)
|
old = getattr(self._runtime_state, key)
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
return f"Error: '{key}' must be >= {spec['min']}"
|
||||||
if "max" in spec and value > spec["max"]:
|
if "max" in spec and value > spec["max"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be <= {spec['max']}")
|
return f"Error: '{key}' must be <= {spec['max']}"
|
||||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be at least {spec['min_len']} characters")
|
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||||
setattr(self._runtime_state, key, value)
|
setattr(self._runtime_state, key, value)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_state._active_preset = None
|
self._runtime_state._active_preset = None
|
||||||
sync_replay = getattr(self._runtime_state, "_sync_replay_max_messages", None)
|
|
||||||
if key == "context_window_tokens" and callable(sync_replay):
|
|
||||||
sync_replay()
|
|
||||||
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
|
if key == "max_iterations" and hasattr(self._runtime_state, "_sync_subagent_runtime_limits"):
|
||||||
self._runtime_state._sync_subagent_runtime_limits()
|
self._runtime_state._sync_subagent_runtime_limits()
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
@@ -458,25 +455,25 @@ class MyTool(Tool, ContextAware):
|
|||||||
"modify",
|
"modify",
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
)
|
)
|
||||||
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||||
try:
|
try:
|
||||||
setattr(self._runtime_state, key, value)
|
setattr(self._runtime_state, key, value)
|
||||||
except (ValueError, KeyError) as e:
|
except (ValueError, KeyError) as e:
|
||||||
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||||
self._audit("modify", f"REJECTED {key}: {message}")
|
self._audit("modify", f"REJECTED {key}: {message}")
|
||||||
return ToolResult.error(f"Error: {message}")
|
return f"Error: {message}"
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
if callable(value):
|
if callable(value):
|
||||||
self._audit("modify", f"REJECTED callable {key}")
|
self._audit("modify", f"REJECTED callable {key}")
|
||||||
return ToolResult.error("Error: cannot store callable values")
|
return "Error: cannot store callable values"
|
||||||
err = self._validate_json_safe(value)
|
err = self._validate_json_safe(value)
|
||||||
if err:
|
if err:
|
||||||
self._audit("modify", f"REJECTED {key}: {err}")
|
self._audit("modify", f"REJECTED {key}: {err}")
|
||||||
return ToolResult.error(f"Error: {err}")
|
return f"Error: {err}"
|
||||||
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||||
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
|
||||||
old = self._runtime_state._runtime_vars.get(key)
|
old = self._runtime_state._runtime_vars.get(key)
|
||||||
self._runtime_state._runtime_vars[key] = value
|
self._runtime_state._runtime_vars[key] = value
|
||||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.context import current_request_session_key
|
from nanobot.agent.tools.context import current_request_session_key
|
||||||
from nanobot.agent.tools.exec_session import (
|
from nanobot.agent.tools.exec_session import (
|
||||||
DEFAULT_EXEC_SESSION_MANAGER,
|
DEFAULT_EXEC_SESSION_MANAGER,
|
||||||
@@ -256,7 +256,7 @@ class ExecTool(Tool):
|
|||||||
command = command or cmd
|
command = command or cmd
|
||||||
working_dir = working_dir or workdir
|
working_dir = working_dir or workdir
|
||||||
if not command:
|
if not command:
|
||||||
return ToolResult.error("Error: Missing command. Provide command or cmd.")
|
return "Error: Missing command. Provide command or cmd."
|
||||||
if max_output_chars is None:
|
if max_output_chars is None:
|
||||||
max_output_chars = max_output_tokens
|
max_output_chars = max_output_tokens
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ class ExecTool(Tool):
|
|||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
return ToolResult.error(f"Error: Command timed out after {prepared.timeout} seconds")
|
return f"Error: Command timed out after {prepared.timeout} seconds"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
@@ -314,7 +314,7 @@ class ExecTool(Tool):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error executing command: {str(e)}")
|
return f"Error executing command: {str(e)}"
|
||||||
|
|
||||||
async def _execute_session(
|
async def _execute_session(
|
||||||
self,
|
self,
|
||||||
@@ -339,10 +339,9 @@ class ExecTool(Tool):
|
|||||||
MAX_OUTPUT_CHARS,
|
MAX_OUTPUT_CHARS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
result = format_session_poll(session_id, poll)
|
return format_session_poll(session_id, poll)
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ToolResult.error(f"Error executing command: {exc}")
|
return f"Error executing command: {exc}"
|
||||||
|
|
||||||
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
def _resolve_timeout(self, timeout: int | None) -> int | None:
|
||||||
"""Resolve the effective hard timeout in seconds (None = no limit).
|
"""Resolve the effective hard timeout in seconds (None = no limit).
|
||||||
@@ -384,12 +383,12 @@ class ExecTool(Tool):
|
|||||||
requested = Path(cwd).expanduser().resolve()
|
requested = Path(cwd).expanduser().resolve()
|
||||||
resolved_root = Path(workspace_root).expanduser().resolve()
|
resolved_root = Path(workspace_root).expanduser().resolve()
|
||||||
except Exception:
|
except Exception:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: working_dir could not be resolved"
|
"Error: working_dir could not be resolved"
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
)
|
)
|
||||||
if not is_path_within(requested, resolved_root):
|
if not is_path_within(requested, resolved_root):
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: working_dir is outside the configured workspace"
|
"Error: working_dir is outside the configured workspace"
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
)
|
)
|
||||||
@@ -505,24 +504,24 @@ class ExecTool(Tool):
|
|||||||
if not shell:
|
if not shell:
|
||||||
return None, None
|
return None, None
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
return None, ToolResult.error("Error: shell parameter is not supported on Windows")
|
return None, "Error: shell parameter is not supported on Windows"
|
||||||
if "\0" in shell or "\n" in shell or "\r" in shell:
|
if "\0" in shell or "\n" in shell or "\r" in shell:
|
||||||
return None, ToolResult.error("Error: shell contains invalid characters")
|
return None, "Error: shell contains invalid characters"
|
||||||
allowed = {"sh", "bash", "zsh"}
|
allowed = {"sh", "bash", "zsh"}
|
||||||
path = Path(shell).expanduser()
|
path = Path(shell).expanduser()
|
||||||
if path.is_absolute():
|
if path.is_absolute():
|
||||||
if path.name not in allowed:
|
if path.name not in allowed:
|
||||||
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
if not path.is_file() or not os.access(path, os.X_OK):
|
if not path.is_file() or not os.access(path, os.X_OK):
|
||||||
return None, ToolResult.error(f"Error: shell is not executable: {shell}")
|
return None, f"Error: shell is not executable: {shell}"
|
||||||
return str(path), None
|
return str(path), None
|
||||||
if "/" in shell or "\\" in shell:
|
if "/" in shell or "\\" in shell:
|
||||||
return None, ToolResult.error("Error: shell must be a shell name or absolute path")
|
return None, "Error: shell must be a shell name or absolute path"
|
||||||
if shell not in allowed:
|
if shell not in allowed:
|
||||||
return None, ToolResult.error(f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh")
|
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
|
||||||
resolved = shutil.which(shell)
|
resolved = shutil.which(shell)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
return None, ToolResult.error(f"Error: shell not found: {shell}")
|
return None, f"Error: shell not found: {shell}"
|
||||||
return resolved, None
|
return resolved, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -609,10 +608,10 @@ class ExecTool(Tool):
|
|||||||
if not explicitly_allowed:
|
if not explicitly_allowed:
|
||||||
for pattern in self.deny_patterns:
|
for pattern in self.deny_patterns:
|
||||||
if re.search(pattern, lower):
|
if re.search(pattern, lower):
|
||||||
return ToolResult.error("Error: Command blocked by deny pattern filter")
|
return "Error: Command blocked by deny pattern filter"
|
||||||
|
|
||||||
if self.allow_patterns:
|
if self.allow_patterns:
|
||||||
return ToolResult.error("Error: Command blocked by allowlist filter (not in allowlist)")
|
return "Error: Command blocked by allowlist filter (not in allowlist)"
|
||||||
|
|
||||||
from nanobot.security.network import contains_internal_url
|
from nanobot.security.network import contains_internal_url
|
||||||
if contains_internal_url(
|
if contains_internal_url(
|
||||||
@@ -622,12 +621,12 @@ class ExecTool(Tool):
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
# The runner turns this marker into a non-retryable security hint.
|
# The runner turns this marker into a non-retryable security hint.
|
||||||
return ToolResult.error("Error: Command blocked by safety guard (internal/private URL detected)")
|
return "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
|
|
||||||
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
|
should_restrict = self.restrict_to_workspace if restrict_to_workspace is None else restrict_to_workspace
|
||||||
if should_restrict:
|
if should_restrict:
|
||||||
if "..\\" in cmd or "../" in cmd:
|
if "..\\" in cmd or "../" in cmd:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: Command blocked by safety guard (path traversal detected)"
|
"Error: Command blocked by safety guard (path traversal detected)"
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
)
|
)
|
||||||
@@ -662,7 +661,7 @@ class ExecTool(Tool):
|
|||||||
if not allowed and resolved_workspace is not None:
|
if not allowed and resolved_workspace is not None:
|
||||||
allowed = is_path_within(p, resolved_workspace)
|
allowed = is_path_within(p, resolved_workspace)
|
||||||
if p.is_absolute() and not allowed:
|
if p.is_absolute() and not allowed:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
"Error: Command blocked by safety guard (path outside working dir)"
|
||||||
+ _WORKSPACE_BOUNDARY_NOTE
|
+ _WORKSPACE_BOUNDARY_NOTE
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-28
@@ -14,7 +14,7 @@ import httpx
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
|
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||||
from nanobot.agent.tools.schema import (
|
from nanobot.agent.tools.schema import (
|
||||||
BooleanSchema,
|
BooleanSchema,
|
||||||
IntegerSchema,
|
IntegerSchema,
|
||||||
@@ -395,13 +395,13 @@ class WebSearchTool(Tool):
|
|||||||
elif provider == "keenable":
|
elif provider == "keenable":
|
||||||
return await self._search_keenable(query, n)
|
return await self._search_keenable(query, n)
|
||||||
else:
|
else:
|
||||||
return ToolResult.error(f"Error: unknown search provider '{provider}'")
|
return f"Error: unknown search provider '{provider}'"
|
||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import AsyncOlostep, Olostep_BaseError
|
from olostep import AsyncOlostep, Olostep_BaseError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
return "Error: olostep package not installed. Run: pip install olostep"
|
||||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
|
||||||
@@ -445,9 +445,9 @@ class WebSearchTool(Tool):
|
|||||||
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Olostep_BaseError as e:
|
except Olostep_BaseError as e:
|
||||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
|
return f"Olostep search error: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
async def _search_brave(self, query: str, n: int) -> str:
|
async def _search_brave(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||||
@@ -481,13 +481,13 @@ class WebSearchTool(Tool):
|
|||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
return ToolResult.error(
|
return (
|
||||||
"Error: Brave search rate limited after retry. "
|
"Error: Brave search rate limited after retry. "
|
||||||
"Retry later or reduce consecutive web_search calls."
|
"Retry later or reduce consecutive web_search calls."
|
||||||
)
|
)
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
async def _search_tavily(self, query: str, n: int) -> str:
|
async def _search_tavily(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
|
||||||
@@ -505,7 +505,7 @@ class WebSearchTool(Tool):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return _format_results(query, r.json().get("results", []), n)
|
return _format_results(query, r.json().get("results", []), n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
async def _search_keenable(self, query: str, n: int) -> str:
|
async def _search_keenable(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("KEENABLE_API_KEY", "")
|
||||||
@@ -540,10 +540,10 @@ class WebSearchTool(Tool):
|
|||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
return ToolResult.error("Error: Keenable search rate limited. Try again later or reduce search frequency.")
|
return "Error: Keenable search rate limited. Try again later or reduce search frequency."
|
||||||
return ToolResult.error(f"Error: Keenable search failed ({e.response.status_code}): {e}")
|
return f"Error: Keenable search failed ({e.response.status_code}): {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Keenable search failed: {e}")
|
return f"Error: Keenable search failed: {e}"
|
||||||
|
|
||||||
async def _search_searxng(self, query: str, n: int) -> str:
|
async def _search_searxng(self, query: str, n: int) -> str:
|
||||||
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
|
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
|
||||||
@@ -553,7 +553,7 @@ class WebSearchTool(Tool):
|
|||||||
endpoint = f"{base_url.rstrip('/')}/search"
|
endpoint = f"{base_url.rstrip('/')}/search"
|
||||||
is_valid, error_msg = _validate_url(endpoint)
|
is_valid, error_msg = _validate_url(endpoint)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return ToolResult.error(f"Error: invalid SearXNG URL: {error_msg}")
|
return f"Error: invalid SearXNG URL: {error_msg}"
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
async with httpx.AsyncClient(proxy=self.proxy) as client:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
@@ -565,7 +565,7 @@ class WebSearchTool(Tool):
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return _format_results(query, r.json().get("results", []), n)
|
return _format_results(query, r.json().get("results", []), n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
async def _search_jina(self, query: str, n: int) -> str:
|
async def _search_jina(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
|
||||||
@@ -616,7 +616,7 @@ class WebSearchTool(Tool):
|
|||||||
]
|
]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
async def _search_exa(self, query: str, n: int) -> str:
|
async def _search_exa(self, query: str, n: int) -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("EXA_API_KEY", "")
|
||||||
@@ -663,10 +663,10 @@ class WebSearchTool(Tool):
|
|||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
return ToolResult.error("Error: Exa search rate limited. Try again later or reduce search frequency.")
|
return "Error: Exa search rate limited. Try again later or reduce search frequency."
|
||||||
return ToolResult.error(f"Error: Exa search failed ({e.response.status_code}): {e}")
|
return f"Error: Exa search failed ({e.response.status_code}): {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Exa search failed: {e}")
|
return f"Error: Exa search failed: {e}"
|
||||||
|
|
||||||
async def _search_volcengine(
|
async def _search_volcengine(
|
||||||
self,
|
self,
|
||||||
@@ -690,7 +690,7 @@ class WebSearchTool(Tool):
|
|||||||
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
|
normalized_time_range = _normalize_volcengine_time_range(time_range) if time_range else None
|
||||||
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
|
normalized_auth_level = _normalize_volcengine_auth_level(auth_level) if auth_level is not None else None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"Query": query,
|
"Query": query,
|
||||||
@@ -723,18 +723,18 @@ class WebSearchTool(Tool):
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if e.response.status_code == 429:
|
if e.response.status_code == 429:
|
||||||
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
|
return "Error: Volcengine search rate limited. Try again later or reduce search frequency."
|
||||||
return ToolResult.error(f"Error: Volcengine search failed ({e.response.status_code}): {e}")
|
return f"Error: Volcengine search failed ({e.response.status_code}): {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: Volcengine search failed: {e}")
|
return f"Error: Volcengine search failed: {e}"
|
||||||
|
|
||||||
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
|
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
|
||||||
if error:
|
if error:
|
||||||
if isinstance(error, dict):
|
if isinstance(error, dict):
|
||||||
code = error.get("Code") or error.get("code") or "unknown"
|
code = error.get("Code") or error.get("code") or "unknown"
|
||||||
message = error.get("Message") or error.get("message") or error
|
message = error.get("Message") or error.get("message") or error
|
||||||
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
|
return f"Error: Volcengine search error {code}: {message}"
|
||||||
return ToolResult.error(f"Error: Volcengine search error: {error}")
|
return f"Error: Volcengine search error: {error}"
|
||||||
|
|
||||||
result = data.get("Result") or data
|
result = data.get("Result") or data
|
||||||
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
|
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
|
||||||
@@ -791,7 +791,7 @@ class WebSearchTool(Tool):
|
|||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("DuckDuckGo search failed: {}", e)
|
logger.warning("DuckDuckGo search failed: {}", e)
|
||||||
return ToolResult.error(f"Error: DuckDuckGo search failed ({e})")
|
return f"Error: DuckDuckGo search failed ({e})"
|
||||||
|
|
||||||
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str:
|
async def _search_bocha(self, query: str, n: int, freshness: str = "noLimit") -> str:
|
||||||
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("BOCHA_API_KEY", "")
|
||||||
@@ -819,7 +819,7 @@ class WebSearchTool(Tool):
|
|||||||
timeout=self.config.timeout,
|
timeout=self.config.timeout,
|
||||||
)
|
)
|
||||||
if r.status_code == 429:
|
if r.status_code == 429:
|
||||||
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
|
return "Error: Bocha search rate-limited (HTTP 429). Wait and retry."
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
wrapped_data = data.get("data") if isinstance(data, dict) else None
|
wrapped_data = data.get("data") if isinstance(data, dict) else None
|
||||||
@@ -839,9 +839,9 @@ class WebSearchTool(Tool):
|
|||||||
]
|
]
|
||||||
return _format_results(query, items, n)
|
return _format_results(query, items, n)
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
return ToolResult.error(f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}")
|
return f"Error: Bocha search HTTP {e.response.status_code}: {e.response.text[:200]}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.error(f"Error: {e}")
|
return f"Error: {e}"
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
|
|||||||
+1
-22
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import hmac
|
|
||||||
import json as _json
|
import json as _json
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -393,10 +392,7 @@ async def handle_health(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
agent_loop,
|
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
|
||||||
model_name: str = "nanobot",
|
|
||||||
request_timeout: float = 120.0,
|
|
||||||
api_key: str = "",
|
|
||||||
) -> web.Application:
|
) -> web.Application:
|
||||||
"""Create the aiohttp application.
|
"""Create the aiohttp application.
|
||||||
|
|
||||||
@@ -404,7 +400,6 @@ def create_app(
|
|||||||
agent_loop: An initialized AgentLoop instance.
|
agent_loop: An initialized AgentLoop instance.
|
||||||
model_name: Model name reported in responses.
|
model_name: Model name reported in responses.
|
||||||
request_timeout: Per-request timeout in seconds.
|
request_timeout: Per-request timeout in seconds.
|
||||||
api_key: Optional API key for Bearer-token authentication.
|
|
||||||
"""
|
"""
|
||||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||||
app["agent_loop"] = agent_loop
|
app["agent_loop"] = agent_loop
|
||||||
@@ -412,22 +407,6 @@ def create_app(
|
|||||||
app["request_timeout"] = request_timeout
|
app["request_timeout"] = request_timeout
|
||||||
app["session_locks"] = {} # per-user locks, keyed by session_key
|
app["session_locks"] = {} # per-user locks, keyed by session_key
|
||||||
|
|
||||||
@web.middleware
|
|
||||||
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
|
|
||||||
if not api_key:
|
|
||||||
return await handler(request)
|
|
||||||
# Allow unauthenticated health checks.
|
|
||||||
if request.path == "/health":
|
|
||||||
return await handler(request)
|
|
||||||
auth = request.headers.get("Authorization", "")
|
|
||||||
if not auth.startswith("Bearer "):
|
|
||||||
return _error_json(401, "Missing Authorization header. Use: Bearer <api_key>")
|
|
||||||
if not hmac.compare_digest(auth[len("Bearer "):], api_key):
|
|
||||||
return _error_json(401, "Invalid API key")
|
|
||||||
return await handler(request)
|
|
||||||
|
|
||||||
app.middlewares.append(auth_middleware)
|
|
||||||
|
|
||||||
app.router.add_post("/v1/chat/completions", handle_chat_completions)
|
app.router.add_post("/v1/chat/completions", handle_chat_completions)
|
||||||
app.router.add_get("/v1/models", handle_models)
|
app.router.add_get("/v1/models", handle_models)
|
||||||
app.router.add_get("/health", handle_health)
|
app.router.add_get("/health", handle_health)
|
||||||
|
|||||||
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.bus.outbound_events import OutboundEvent
|
|
||||||
|
|
||||||
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
|
# Optional ``OutboundMessage.metadata`` key for structured, channel-agnostic UI
|
||||||
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
|
# payloads. Value is JSON-serializable with at least ``kind``; rich clients may
|
||||||
@@ -42,9 +39,9 @@ class InboundMessage:
|
|||||||
class OutboundMessage:
|
class OutboundMessage:
|
||||||
"""Message to send to a chat channel.
|
"""Message to send to a chat channel.
|
||||||
|
|
||||||
``event`` carries internal runtime/UI semantics. ``metadata`` is reserved
|
``metadata`` can carry routing (``message_id``, …), trace flags (``_progress``),
|
||||||
for channel routing context (``message_id``, thread ids, etc.) and optional
|
and optional ``OUTBOUND_META_AGENT_UI`` blobs for rich clients; non-WebUI
|
||||||
``OUTBOUND_META_AGENT_UI`` blobs for rich clients.
|
channels may ignore unknown keys.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
channel: str
|
channel: str
|
||||||
@@ -54,4 +51,3 @@ class OutboundMessage:
|
|||||||
media: list[str] = field(default_factory=list)
|
media: list[str] = field(default_factory=list)
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
buttons: list[list[str]] = field(default_factory=list)
|
buttons: list[list[str]] = field(default_factory=list)
|
||||||
event: "OutboundEvent | None" = None
|
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
"""Typed outbound events carried by :class:`OutboundMessage`.
|
|
||||||
|
|
||||||
The message bus still transports :class:`nanobot.bus.events.OutboundMessage`
|
|
||||||
because channels need chat routing fields. Runtime/UI semantics live on the
|
|
||||||
message's explicit ``event`` field rather than in reserved metadata flags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
|
|
||||||
|
|
||||||
class OutboundEvent:
|
|
||||||
"""Marker base for internal outbound runtime events."""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProgressEvent(OutboundEvent):
|
|
||||||
content: str = ""
|
|
||||||
tool_hint: bool = False
|
|
||||||
reasoning: bool = False
|
|
||||||
reasoning_delta: bool = False
|
|
||||||
reasoning_end: bool = False
|
|
||||||
stream_id: str | None = None
|
|
||||||
tool_events: list[dict[str, Any]] | None = None
|
|
||||||
file_edit_events: list[dict[str, Any]] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RetryWaitEvent(OutboundEvent):
|
|
||||||
content: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StreamDeltaEvent(OutboundEvent):
|
|
||||||
content: str = ""
|
|
||||||
stream_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StreamEndEvent(OutboundEvent):
|
|
||||||
content: str = ""
|
|
||||||
stream_id: str | None = None
|
|
||||||
resuming: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class StreamedResponseEvent(OutboundEvent):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TurnEndEvent(OutboundEvent):
|
|
||||||
latency_ms: int | None = None
|
|
||||||
goal_state: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GoalStatusEvent(OutboundEvent):
|
|
||||||
status: str
|
|
||||||
started_at: float | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class GoalStateSyncEvent(OutboundEvent):
|
|
||||||
goal_state: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SessionUpdatedEvent(OutboundEvent):
|
|
||||||
scope: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RuntimeModelUpdatedEvent(OutboundEvent):
|
|
||||||
model: str | None
|
|
||||||
model_preset: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def outbound_message_for_event(
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
chat_id: str,
|
|
||||||
event: OutboundEvent,
|
|
||||||
content: str | None = None,
|
|
||||||
metadata: Mapping[str, Any] | None = None,
|
|
||||||
) -> OutboundMessage:
|
|
||||||
"""Build an :class:`OutboundMessage` for a typed event."""
|
|
||||||
|
|
||||||
return OutboundMessage(
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
content=_event_content(event) if content is None else content,
|
|
||||||
event=event,
|
|
||||||
metadata=dict(metadata or {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def outbound_event_from_message(msg: OutboundMessage) -> OutboundEvent | None:
|
|
||||||
"""Return the typed outbound event carried by *msg*, if any."""
|
|
||||||
|
|
||||||
if msg.event is not None:
|
|
||||||
return msg.event
|
|
||||||
return _legacy_event_from_metadata(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def replace_outbound_event(
|
|
||||||
msg: OutboundMessage,
|
|
||||||
event: OutboundEvent,
|
|
||||||
*,
|
|
||||||
content: str | None = None,
|
|
||||||
) -> OutboundMessage:
|
|
||||||
"""Return *msg* with a new event and optional content."""
|
|
||||||
|
|
||||||
return replace(
|
|
||||||
msg,
|
|
||||||
content=_event_content(event) if content is None else content,
|
|
||||||
event=event,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _event_content(event: OutboundEvent) -> str:
|
|
||||||
if isinstance(event, ProgressEvent | RetryWaitEvent | StreamDeltaEvent | StreamEndEvent):
|
|
||||||
return event.content
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
|
|
||||||
"""Bridge pre-typed outbound metadata flags into typed events.
|
|
||||||
|
|
||||||
New code should set ``OutboundMessage.event`` directly. The fallback keeps
|
|
||||||
older in-process extensions and channel plugins from losing runtime events
|
|
||||||
while they migrate off reserved metadata flags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
meta = msg.metadata or {}
|
|
||||||
if meta.get("_runtime_model_updated"):
|
|
||||||
return RuntimeModelUpdatedEvent(
|
|
||||||
model=_metadata_str(meta, "model"),
|
|
||||||
model_preset=_metadata_str(meta, "model_preset"),
|
|
||||||
)
|
|
||||||
if meta.get("_goal_state_sync"):
|
|
||||||
goal_state = meta.get("goal_state")
|
|
||||||
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False})
|
|
||||||
if meta.get("_goal_status"):
|
|
||||||
status = meta.get("goal_status")
|
|
||||||
if not isinstance(status, str) or not status:
|
|
||||||
return None
|
|
||||||
return GoalStatusEvent(
|
|
||||||
status=status,
|
|
||||||
started_at=_metadata_float(meta, "started_at", "goal_started_at"),
|
|
||||||
)
|
|
||||||
if meta.get("_turn_end"):
|
|
||||||
goal_state = meta.get("goal_state")
|
|
||||||
return TurnEndEvent(
|
|
||||||
latency_ms=_metadata_int(meta, "latency_ms"),
|
|
||||||
goal_state=goal_state if isinstance(goal_state, dict) else None,
|
|
||||||
)
|
|
||||||
if meta.get("_session_updated"):
|
|
||||||
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
|
|
||||||
if meta.get("_retry_wait"):
|
|
||||||
return RetryWaitEvent(content=msg.content)
|
|
||||||
if meta.get("_stream_end"):
|
|
||||||
return StreamEndEvent(
|
|
||||||
content=msg.content,
|
|
||||||
stream_id=_metadata_str(meta, "_stream_id"),
|
|
||||||
resuming=bool(meta.get("_resuming")),
|
|
||||||
)
|
|
||||||
if meta.get("_stream_delta"):
|
|
||||||
return StreamDeltaEvent(
|
|
||||||
content=msg.content,
|
|
||||||
stream_id=_metadata_str(meta, "_stream_id"),
|
|
||||||
)
|
|
||||||
if meta.get("_streamed"):
|
|
||||||
return StreamedResponseEvent()
|
|
||||||
if (
|
|
||||||
meta.get("_progress")
|
|
||||||
or meta.get("_reasoning_delta")
|
|
||||||
or meta.get("_reasoning_end")
|
|
||||||
or meta.get("_reasoning")
|
|
||||||
or meta.get("_file_edit_events")
|
|
||||||
or meta.get("_tool_events")
|
|
||||||
):
|
|
||||||
tool_events = meta.get("_tool_events")
|
|
||||||
file_edit_events = meta.get("_file_edit_events")
|
|
||||||
return ProgressEvent(
|
|
||||||
content=msg.content,
|
|
||||||
tool_hint=bool(meta.get("_tool_hint")),
|
|
||||||
reasoning=bool(meta.get("_reasoning")),
|
|
||||||
reasoning_delta=bool(meta.get("_reasoning_delta")),
|
|
||||||
reasoning_end=bool(meta.get("_reasoning_end")),
|
|
||||||
stream_id=_metadata_str(meta, "_stream_id"),
|
|
||||||
tool_events=tool_events if isinstance(tool_events, list) else None,
|
|
||||||
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_str(meta: Mapping[str, Any], key: str) -> str | None:
|
|
||||||
value = meta.get(key)
|
|
||||||
return value if isinstance(value, str) and value else None
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_int(meta: Mapping[str, Any], key: str) -> int | None:
|
|
||||||
value = meta.get(key)
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return None
|
|
||||||
if isinstance(value, int):
|
|
||||||
return value
|
|
||||||
if isinstance(value, float) and value.is_integer():
|
|
||||||
return int(value)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_float(meta: Mapping[str, Any], *keys: str) -> float | None:
|
|
||||||
for key in keys:
|
|
||||||
value = meta.get(key)
|
|
||||||
if isinstance(value, bool):
|
|
||||||
continue
|
|
||||||
if isinstance(value, int | float):
|
|
||||||
return float(value)
|
|
||||||
return None
|
|
||||||
+14
-11
@@ -10,8 +10,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent, outbound_message_for_event
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
|
||||||
|
|
||||||
@@ -30,19 +29,23 @@ def build_bus_progress_callback(
|
|||||||
reasoning: bool = False,
|
reasoning: bool = False,
|
||||||
reasoning_end: bool = False,
|
reasoning_end: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
meta = dict(msg.metadata or {})
|
||||||
|
meta["_progress"] = True
|
||||||
|
meta["_tool_hint"] = tool_hint
|
||||||
|
if reasoning:
|
||||||
|
meta["_reasoning_delta"] = True
|
||||||
|
if reasoning_end:
|
||||||
|
meta["_reasoning_end"] = True
|
||||||
|
if tool_events:
|
||||||
|
meta["_tool_events"] = tool_events
|
||||||
|
if file_edit_events:
|
||||||
|
meta["_file_edit_events"] = file_edit_events
|
||||||
await bus.publish_outbound(
|
await bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
event=ProgressEvent(
|
|
||||||
content=content,
|
content=content,
|
||||||
tool_hint=tool_hint,
|
metadata=meta,
|
||||||
reasoning_delta=reasoning,
|
|
||||||
reasoning_end=reasoning_end,
|
|
||||||
tool_events=tool_events,
|
|
||||||
file_edit_events=file_edit_events,
|
|
||||||
),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+17
-37
@@ -101,33 +101,20 @@ class BaseChannel(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Deliver a streaming text chunk.
|
"""Deliver a streaming text chunk.
|
||||||
|
|
||||||
Override in subclasses to enable streaming. Implementations should
|
Override in subclasses to enable streaming. Implementations should
|
||||||
raise on delivery failure so the channel manager can retry.
|
raise on delivery failure so the channel manager can retry.
|
||||||
|
|
||||||
Stateful implementations should key buffers by ``stream_id`` rather
|
Streaming contract: ``_stream_delta`` is a chunk, ``_stream_end`` ends
|
||||||
than only by ``chat_id`` when it is provided.
|
the current segment, and stateful implementations must key buffers by
|
||||||
|
``_stream_id`` rather than only by ``chat_id``.
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def send_reasoning_delta(
|
async def send_reasoning_delta(
|
||||||
self,
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream a chunk of model reasoning/thinking content.
|
"""Stream a chunk of model reasoning/thinking content.
|
||||||
|
|
||||||
@@ -136,17 +123,15 @@ class BaseChannel(ABC):
|
|||||||
subtext, WebUI italic bubble, ...) override to render reasoning
|
subtext, WebUI italic bubble, ...) override to render reasoning
|
||||||
as a subordinate trace that updates in place as the model thinks.
|
as a subordinate trace that updates in place as the model thinks.
|
||||||
|
|
||||||
Streaming contract mirrors :meth:`send_delta`: stateful implementations
|
Streaming contract mirrors :meth:`send_delta`: ``_reasoning_delta``
|
||||||
should key buffers by ``stream_id`` rather than only by ``chat_id``.
|
is a chunk, ``_reasoning_end`` ends the current reasoning segment,
|
||||||
|
and stateful implementations should key buffers by ``_stream_id``
|
||||||
|
rather than only by ``chat_id``.
|
||||||
"""
|
"""
|
||||||
return
|
return
|
||||||
|
|
||||||
async def send_reasoning_end(
|
async def send_reasoning_end(
|
||||||
self,
|
self, chat_id: str, metadata: dict[str, Any] | None = None
|
||||||
chat_id: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Mark the end of a reasoning stream segment.
|
"""Mark the end of a reasoning stream segment.
|
||||||
|
|
||||||
@@ -180,18 +165,13 @@ class BaseChannel(ABC):
|
|||||||
"""
|
"""
|
||||||
if not msg.content:
|
if not msg.content:
|
||||||
return
|
return
|
||||||
stream_id = getattr(msg.event, "stream_id", None)
|
meta = dict(msg.metadata or {})
|
||||||
await self.send_reasoning_delta(
|
meta.setdefault("_reasoning_delta", True)
|
||||||
msg.chat_id,
|
await self.send_reasoning_delta(msg.chat_id, msg.content, meta)
|
||||||
msg.content,
|
end_meta = dict(meta)
|
||||||
msg.metadata,
|
end_meta.pop("_reasoning_delta", None)
|
||||||
stream_id=stream_id,
|
end_meta["_reasoning_end"] = True
|
||||||
)
|
await self.send_reasoning_end(msg.chat_id, end_meta)
|
||||||
await self.send_reasoning_end(
|
|
||||||
msg.chat_id,
|
|
||||||
msg.metadata,
|
|
||||||
stream_id=stream_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_streaming(self) -> bool:
|
def supports_streaming(self) -> bool:
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import TYPE_CHECKING, Any, Literal
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.command.builtin import build_help_text
|
from nanobot.command.builtin import build_help_text
|
||||||
@@ -218,16 +217,6 @@ if DISCORD_AVAILABLE:
|
|||||||
command_text = f"/model {preset}" if preset else "/model"
|
command_text = f"/model {preset}" if preset else "/model"
|
||||||
await self._forward_slash_command(interaction, command_text)
|
await self._forward_slash_command(interaction, command_text)
|
||||||
|
|
||||||
@self.tree.command(name="trigger", description="Create a named local trigger for this chat")
|
|
||||||
@app_commands.describe(name="Trigger name")
|
|
||||||
async def trigger_command(
|
|
||||||
interaction: discord.Interaction,
|
|
||||||
name: str,
|
|
||||||
) -> None:
|
|
||||||
name = name.strip()
|
|
||||||
command_text = f"/trigger {name}" if name else "/trigger"
|
|
||||||
await self._forward_slash_command(interaction, command_text)
|
|
||||||
|
|
||||||
@self.tree.command(name="help", description="Show available commands")
|
@self.tree.command(name="help", description="Show available commands")
|
||||||
async def help_command(interaction: discord.Interaction) -> None:
|
async def help_command(interaction: discord.Interaction) -> None:
|
||||||
sender_id = str(interaction.user.id)
|
sender_id = str(interaction.user.id)
|
||||||
@@ -469,7 +458,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
self.logger.warning("client not ready; dropping outbound message")
|
self.logger.warning("client not ready; dropping outbound message")
|
||||||
return
|
return
|
||||||
|
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await client.send_outbound(msg)
|
await client.send_outbound(msg)
|
||||||
@@ -482,14 +471,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
await self._clear_reactions(msg.chat_id)
|
await self._clear_reactions(msg.chat_id)
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(
|
||||||
self,
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
"""Progressive Discord delivery: send once, then edit until the stream ends."""
|
||||||
client = self._client
|
client = self._client
|
||||||
@@ -497,7 +479,10 @@ class DiscordChannel(BaseChannel):
|
|||||||
self.logger.warning("client not ready; dropping stream delta")
|
self.logger.warning("client not ready; dropping stream delta")
|
||||||
return
|
return
|
||||||
|
|
||||||
if stream_end:
|
meta = metadata or {}
|
||||||
|
stream_id = meta.get("_stream_id")
|
||||||
|
|
||||||
|
if meta.get("_stream_end"):
|
||||||
buf = self._stream_bufs.get(chat_id)
|
buf = self._stream_bufs.get(chat_id)
|
||||||
if not buf or buf.message is None or not buf.text:
|
if not buf or buf.message is None or not buf.text:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -219,7 +218,7 @@ class EmailChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Skip progress messages to prevent sending an empty email after each tool call
|
# Skip progress messages to prevent sending an empty email after each tool call
|
||||||
if isinstance(msg.event, ProgressEvent):
|
if (msg.metadata or {}).get("_progress"):
|
||||||
self.logger.debug("Skip progress message to {}", msg.chat_id)
|
self.logger.debug("Skip progress message to {}", msg.chat_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ from rich.panel import Panel
|
|||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -1798,19 +1797,14 @@ class FeishuChannel(BaseChannel):
|
|||||||
return self._stream_update_text_sync(card_id, content, sequence), sequence
|
return self._stream_update_text_sync(card_id, content, sequence), sequence
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(
|
||||||
self,
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
|
"""Progressive streaming via CardKit: create card on first delta, stream-update on subsequent.
|
||||||
|
|
||||||
Supported metadata keys:
|
Supported metadata keys:
|
||||||
message_id: Original message id (used with stream end for reaction cleanup).
|
_stream_end: Finalize the streaming card.
|
||||||
|
_tool_hint: Delta is a formatted tool hint (for display only).
|
||||||
|
message_id: Original message id (used with _stream_end for reaction cleanup).
|
||||||
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
|
||||||
"""
|
"""
|
||||||
if not self._client:
|
if not self._client:
|
||||||
@@ -1821,14 +1815,14 @@ class FeishuChannel(BaseChannel):
|
|||||||
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
|
||||||
|
|
||||||
# --- stream end: final update or fallback ---
|
# --- stream end: final update or fallback ---
|
||||||
if stream_end:
|
if meta.get("_stream_end"):
|
||||||
message_id = meta.get("message_id")
|
message_id = meta.get("message_id")
|
||||||
# Only finalize the OnIt -> DONE reaction transition on the truly
|
# Only finalize the OnIt -> DONE reaction transition on the truly
|
||||||
# final stream end. resuming=True means the agent will keep
|
# final stream end. _resuming=True means the agent will keep
|
||||||
# working (more tool-call rounds), so leave the reaction state
|
# working (more tool-call rounds), so leave the reaction state
|
||||||
# in place — otherwise the OnIt indicator disappears prematurely
|
# in place — otherwise the OnIt indicator disappears prematurely
|
||||||
# and the DONE reaction fires after every tool call.
|
# and the DONE reaction fires after every tool call.
|
||||||
if message_id and not resuming:
|
if message_id and not meta.get("_resuming"):
|
||||||
reaction_id = self._reaction_ids.pop(message_id, None)
|
reaction_id = self._reaction_ids.pop(message_id, None)
|
||||||
if reaction_id:
|
if reaction_id:
|
||||||
await self._remove_reaction(message_id, reaction_id)
|
await self._remove_reaction(message_id, reaction_id)
|
||||||
@@ -1971,9 +1965,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
# Handle tool hint messages. When a streaming card is active for
|
# Handle tool hint messages. When a streaming card is active for
|
||||||
# this chat, inline the hint into the card instead of sending a
|
# this chat, inline the hint into the card instead of sending a
|
||||||
# separate message so the user experience stays cohesive.
|
# separate message so the user experience stays cohesive.
|
||||||
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
|
if msg.metadata.get("_tool_hint"):
|
||||||
|
|
||||||
if progress_event and progress_event.tool_hint:
|
|
||||||
hint = (msg.content or "").strip()
|
hint = (msg.content or "").strip()
|
||||||
if not hint:
|
if not hint:
|
||||||
return
|
return
|
||||||
@@ -1984,7 +1976,6 @@ class FeishuChannel(BaseChannel):
|
|||||||
await self.send_delta(
|
await self.send_delta(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# No active streaming card — send as a regular interactive card
|
# No active streaming card — send as a regular interactive card
|
||||||
@@ -2018,7 +2009,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
reply_message_id: str | None = None
|
reply_message_id: str | None = None
|
||||||
_msg_id = msg.metadata.get("message_id")
|
_msg_id = msg.metadata.get("message_id")
|
||||||
has_thread_id = msg.metadata.get("thread_id")
|
has_thread_id = msg.metadata.get("thread_id")
|
||||||
if self.config.reply_to_message and progress_event is None:
|
if self.config.reply_to_message and not msg.metadata.get("_progress", False):
|
||||||
reply_message_id = _msg_id
|
reply_message_id = _msg_id
|
||||||
# For topic group messages, always reply to keep context in thread
|
# For topic group messages, always reply to keep context in thread
|
||||||
elif has_thread_id:
|
elif has_thread_id:
|
||||||
|
|||||||
+48
-163
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -13,16 +12,6 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
ProgressEvent,
|
|
||||||
RetryWaitEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
outbound_event_from_message,
|
|
||||||
replace_outbound_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
@@ -68,10 +57,8 @@ class ChannelManager:
|
|||||||
*,
|
*,
|
||||||
session_manager: "SessionManager | None" = None,
|
session_manager: "SessionManager | None" = None,
|
||||||
cron_service: Any | None = None,
|
cron_service: Any | None = None,
|
||||||
local_trigger_store: Any | None = None,
|
|
||||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
|
||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
@@ -80,10 +67,8 @@ class ChannelManager:
|
|||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
self._local_trigger_store = local_trigger_store
|
|
||||||
self._webui_runtime_model_name = webui_runtime_model_name
|
self._webui_runtime_model_name = webui_runtime_model_name
|
||||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||||
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
|
||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
@@ -143,9 +128,7 @@ class ChannelManager:
|
|||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
runtime_capabilities_overrides=self._webui_runtime_capabilities,
|
runtime_capabilities_overrides=self._webui_runtime_capabilities,
|
||||||
cron_service=self._cron_service,
|
cron_service=self._cron_service,
|
||||||
local_trigger_store=self._local_trigger_store,
|
|
||||||
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
kwargs["gateway"] = gateway
|
kwargs["gateway"] = gateway
|
||||||
@@ -283,7 +266,7 @@ class ChannelManager:
|
|||||||
|
|
||||||
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
|
def _should_suppress_outbound(self, msg: OutboundMessage) -> bool:
|
||||||
metadata = msg.metadata or {}
|
metadata = msg.metadata or {}
|
||||||
if isinstance(outbound_event_from_message(msg), ProgressEvent):
|
if metadata.get("_progress"):
|
||||||
return False
|
return False
|
||||||
fingerprint = self._fingerprint_content(msg.content)
|
fingerprint = self._fingerprint_content(msg.content)
|
||||||
if not fingerprint:
|
if not fingerprint:
|
||||||
@@ -322,59 +305,57 @@ class ChannelManager:
|
|||||||
timeout=1.0
|
timeout=1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
event = outbound_event_from_message(msg)
|
if (
|
||||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
msg.metadata.get("_reasoning_delta")
|
||||||
if progress_event and (
|
or msg.metadata.get("_reasoning_end")
|
||||||
progress_event.reasoning_delta
|
or msg.metadata.get("_reasoning")
|
||||||
or progress_event.reasoning_end
|
|
||||||
or progress_event.reasoning
|
|
||||||
):
|
):
|
||||||
# Reasoning rides its own plugin channel: only delivered
|
# Reasoning rides its own plugin channel: only delivered
|
||||||
# when the destination channel opts in via ``show_reasoning``
|
# when the destination channel opts in via ``show_reasoning``
|
||||||
# and overrides the streaming primitives. Channels without
|
# and overrides the streaming primitives. Channels without
|
||||||
# a low-emphasis UI affordance keep the base no-op and the
|
# a low-emphasis UI affordance keep the base no-op and the
|
||||||
# content silently drops here.
|
# content silently drops here. ``_reasoning`` (one-shot)
|
||||||
|
# is accepted for backward compatibility with hooks that
|
||||||
|
# haven't migrated to delta/end yet.
|
||||||
channel = self.channels.get(msg.channel)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel is not None and channel.show_reasoning:
|
if channel is not None and channel.show_reasoning:
|
||||||
await self._send_with_retry(channel, msg)
|
await self._send_with_retry(channel, msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if progress_event:
|
if msg.metadata.get("_progress"):
|
||||||
if progress_event.tool_hint and not self._should_send_progress(
|
if msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
||||||
msg.channel, tool_hint=True,
|
msg.channel, tool_hint=True,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
if not progress_event.tool_hint and not self._should_send_progress(
|
if not msg.metadata.get("_tool_hint") and not self._should_send_progress(
|
||||||
msg.channel, tool_hint=False,
|
msg.channel, tool_hint=False,
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if isinstance(event, RetryWaitEvent):
|
if msg.metadata.get("_retry_wait"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isinstance(event, RuntimeModelUpdatedEvent)
|
msg.metadata.get("_runtime_model_updated")
|
||||||
and msg.channel == "websocket"
|
and msg.channel == "websocket"
|
||||||
and "websocket" not in self.channels
|
and "websocket" not in self.channels
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Coalesce consecutive stream delta messages for the same (channel, chat_id)
|
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
|
||||||
# to reduce API calls and improve streaming latency
|
# to reduce API calls and improve streaming latency
|
||||||
if isinstance(event, StreamDeltaEvent):
|
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
|
||||||
msg, extra_pending = self._coalesce_stream_deltas(msg)
|
msg, extra_pending = self._coalesce_stream_deltas(msg)
|
||||||
pending.extend(extra_pending)
|
pending.extend(extra_pending)
|
||||||
event = outbound_event_from_message(msg)
|
|
||||||
|
|
||||||
channel = self.channels.get(msg.channel)
|
channel = self.channels.get(msg.channel)
|
||||||
if channel:
|
if channel:
|
||||||
# Duplicate suppression is scoped to a known source message
|
# Duplicate suppression is scoped to a known source message
|
||||||
# so repeated content from separate turns is still delivered.
|
# so repeated content from separate turns is still delivered.
|
||||||
if (
|
if (
|
||||||
not isinstance(
|
not msg.metadata.get("_stream_delta")
|
||||||
event,
|
and not msg.metadata.get("_stream_end")
|
||||||
StreamDeltaEvent | StreamEndEvent | StreamedResponseEvent,
|
and not msg.metadata.get("_streamed")
|
||||||
)
|
|
||||||
):
|
):
|
||||||
if self._should_suppress_outbound(msg):
|
if self._should_suppress_outbound(msg):
|
||||||
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
|
logger.info("Suppressing duplicate outbound message to {}:{}", msg.channel, msg.chat_id)
|
||||||
@@ -388,116 +369,34 @@ class ChannelManager:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _accepts_keyword(callable_obj: Callable[..., Any], name: str) -> bool:
|
|
||||||
try:
|
|
||||||
signature = inspect.signature(callable_obj)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return True
|
|
||||||
return any(
|
|
||||||
parameter.kind is inspect.Parameter.VAR_KEYWORD or parameter.name == name
|
|
||||||
for parameter in signature.parameters.values()
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _send_reasoning_delta(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
|
|
||||||
metadata = msg.metadata
|
|
||||||
kwargs: dict[str, Any] = {}
|
|
||||||
if cls._accepts_keyword(channel.send_reasoning_delta, "stream_id"):
|
|
||||||
kwargs["stream_id"] = event.stream_id
|
|
||||||
else:
|
|
||||||
metadata = dict(metadata or {})
|
|
||||||
metadata["_reasoning_delta"] = True
|
|
||||||
if event.stream_id is not None:
|
|
||||||
metadata["_stream_id"] = event.stream_id
|
|
||||||
await channel.send_reasoning_delta(
|
|
||||||
msg.chat_id,
|
|
||||||
msg.content,
|
|
||||||
metadata,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _send_reasoning_end(cls, channel: BaseChannel, msg: OutboundMessage, event: ProgressEvent) -> None:
|
|
||||||
metadata = msg.metadata
|
|
||||||
kwargs: dict[str, Any] = {}
|
|
||||||
if cls._accepts_keyword(channel.send_reasoning_end, "stream_id"):
|
|
||||||
kwargs["stream_id"] = event.stream_id
|
|
||||||
else:
|
|
||||||
metadata = dict(metadata or {})
|
|
||||||
metadata["_reasoning_end"] = True
|
|
||||||
if event.stream_id is not None:
|
|
||||||
metadata["_stream_id"] = event.stream_id
|
|
||||||
await channel.send_reasoning_end(
|
|
||||||
msg.chat_id,
|
|
||||||
metadata,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _send_stream_event(
|
|
||||||
cls,
|
|
||||||
channel: BaseChannel,
|
|
||||||
msg: OutboundMessage,
|
|
||||||
event: StreamDeltaEvent | StreamEndEvent,
|
|
||||||
) -> None:
|
|
||||||
metadata = msg.metadata
|
|
||||||
kwargs: dict[str, Any] = {}
|
|
||||||
if cls._accepts_keyword(channel.send_delta, "stream_id"):
|
|
||||||
kwargs["stream_id"] = event.stream_id
|
|
||||||
else:
|
|
||||||
metadata = dict(metadata or {})
|
|
||||||
if event.stream_id is not None:
|
|
||||||
metadata["_stream_id"] = event.stream_id
|
|
||||||
|
|
||||||
if isinstance(event, StreamEndEvent):
|
|
||||||
if cls._accepts_keyword(channel.send_delta, "stream_end"):
|
|
||||||
kwargs["stream_end"] = True
|
|
||||||
else:
|
|
||||||
metadata = dict(metadata or {})
|
|
||||||
metadata["_stream_end"] = True
|
|
||||||
if cls._accepts_keyword(channel.send_delta, "resuming"):
|
|
||||||
kwargs["resuming"] = event.resuming
|
|
||||||
elif not kwargs:
|
|
||||||
metadata = dict(metadata or {})
|
|
||||||
metadata["_stream_delta"] = True
|
|
||||||
|
|
||||||
await channel.send_delta(
|
|
||||||
msg.chat_id,
|
|
||||||
msg.content,
|
|
||||||
metadata,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
async def _send_once(channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||||
"""Send one outbound message without retry policy."""
|
"""Send one outbound message without retry policy."""
|
||||||
event = outbound_event_from_message(msg)
|
if msg.metadata.get("_reasoning_end"):
|
||||||
if isinstance(event, ProgressEvent) and event.reasoning_end:
|
await channel.send_reasoning_end(msg.chat_id, msg.metadata)
|
||||||
await ChannelManager._send_reasoning_end(channel, msg, event)
|
elif msg.metadata.get("_reasoning_delta"):
|
||||||
elif isinstance(event, ProgressEvent) and event.reasoning_delta:
|
await channel.send_reasoning_delta(msg.chat_id, msg.content, msg.metadata)
|
||||||
await ChannelManager._send_reasoning_delta(channel, msg, event)
|
elif msg.metadata.get("_reasoning"):
|
||||||
elif isinstance(event, ProgressEvent) and event.reasoning:
|
# Back-compat: one-shot reasoning. BaseChannel translates this
|
||||||
# BaseChannel translates one-shot reasoning to a single delta +
|
# to a single delta + end pair so plugins only implement the
|
||||||
# end pair so plugins only implement the streaming primitives.
|
# streaming primitives.
|
||||||
await channel.send_reasoning(msg)
|
await channel.send_reasoning(msg)
|
||||||
elif isinstance(event, ProgressEvent) and event.file_edit_events:
|
elif msg.metadata.get("_file_edit_events"):
|
||||||
|
edits = msg.metadata.get("_file_edit_events")
|
||||||
await channel.send_file_edit_events(
|
await channel.send_file_edit_events(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
event.file_edit_events,
|
edits if isinstance(edits, list) else [],
|
||||||
msg.metadata,
|
msg.metadata,
|
||||||
)
|
)
|
||||||
elif isinstance(event, StreamDeltaEvent):
|
elif msg.metadata.get("_stream_delta") or msg.metadata.get("_stream_end"):
|
||||||
await ChannelManager._send_stream_event(channel, msg, event)
|
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
||||||
elif isinstance(event, StreamEndEvent):
|
elif not msg.metadata.get("_streamed"):
|
||||||
await ChannelManager._send_stream_event(channel, msg, event)
|
|
||||||
elif not isinstance(event, StreamedResponseEvent):
|
|
||||||
await channel.send(msg)
|
await channel.send(msg)
|
||||||
|
|
||||||
def _coalesce_stream_deltas(
|
def _coalesce_stream_deltas(
|
||||||
self, first_msg: OutboundMessage
|
self, first_msg: OutboundMessage
|
||||||
) -> tuple[OutboundMessage, list[OutboundMessage]]:
|
) -> tuple[OutboundMessage, list[OutboundMessage]]:
|
||||||
"""Merge consecutive stream deltas for the same (channel, chat_id, stream_id).
|
"""Merge consecutive _stream_delta messages for the same (channel, chat_id).
|
||||||
|
|
||||||
This reduces the number of API calls when the queue has accumulated multiple
|
This reduces the number of API calls when the queue has accumulated multiple
|
||||||
deltas, which happens when LLM generates faster than the channel can process.
|
deltas, which happens when LLM generates faster than the channel can process.
|
||||||
@@ -505,15 +404,9 @@ class ChannelManager:
|
|||||||
Returns:
|
Returns:
|
||||||
tuple of (merged_message, list_of_non_matching_messages)
|
tuple of (merged_message, list_of_non_matching_messages)
|
||||||
"""
|
"""
|
||||||
first_event = outbound_event_from_message(first_msg)
|
target_key = (first_msg.channel, first_msg.chat_id)
|
||||||
first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None
|
|
||||||
target_key = (first_msg.channel, first_msg.chat_id, first_stream_id)
|
|
||||||
combined_content = first_msg.content
|
combined_content = first_msg.content
|
||||||
final_event: StreamDeltaEvent | StreamEndEvent = (
|
final_metadata = dict(first_msg.metadata or {})
|
||||||
first_event
|
|
||||||
if isinstance(first_event, StreamDeltaEvent)
|
|
||||||
else StreamDeltaEvent(stream_id=first_stream_id)
|
|
||||||
)
|
|
||||||
non_matching: list[OutboundMessage] = []
|
non_matching: list[OutboundMessage] = []
|
||||||
|
|
||||||
# Only merge consecutive deltas. As soon as we hit any other message,
|
# Only merge consecutive deltas. As soon as we hit any other message,
|
||||||
@@ -525,29 +418,16 @@ class ChannelManager:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Check if this message belongs to the same stream
|
# Check if this message belongs to the same stream
|
||||||
next_event = outbound_event_from_message(next_msg)
|
same_target = (next_msg.channel, next_msg.chat_id) == target_key
|
||||||
next_stream_id = (
|
is_delta = next_msg.metadata and next_msg.metadata.get("_stream_delta")
|
||||||
next_event.stream_id
|
is_end = next_msg.metadata and next_msg.metadata.get("_stream_end")
|
||||||
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
same_target = (
|
|
||||||
next_msg.channel,
|
|
||||||
next_msg.chat_id,
|
|
||||||
next_stream_id,
|
|
||||||
) == target_key
|
|
||||||
is_delta = isinstance(next_event, StreamDeltaEvent)
|
|
||||||
is_end = isinstance(next_event, StreamEndEvent)
|
|
||||||
|
|
||||||
if same_target and (is_delta or (is_end and next_msg.content)):
|
if same_target and is_delta and not final_metadata.get("_stream_end"):
|
||||||
# Accumulate content
|
# Accumulate content
|
||||||
combined_content += next_msg.content
|
combined_content += next_msg.content
|
||||||
# If we see stream_end, remember it and stop coalescing this stream
|
# If we see _stream_end, remember it and stop coalescing this stream
|
||||||
if isinstance(next_event, StreamEndEvent):
|
if is_end:
|
||||||
final_event = StreamEndEvent(
|
final_metadata["_stream_end"] = True
|
||||||
stream_id=next_stream_id,
|
|
||||||
resuming=next_event.resuming,
|
|
||||||
)
|
|
||||||
# Stream ended - stop coalescing this stream
|
# Stream ended - stop coalescing this stream
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -555,7 +435,12 @@ class ChannelManager:
|
|||||||
non_matching.append(next_msg)
|
non_matching.append(next_msg)
|
||||||
break
|
break
|
||||||
|
|
||||||
merged = replace_outbound_event(first_msg, final_event, content=combined_content)
|
merged = OutboundMessage(
|
||||||
|
channel=first_msg.channel,
|
||||||
|
chat_id=first_msg.chat_id,
|
||||||
|
content=combined_content,
|
||||||
|
metadata=final_metadata,
|
||||||
|
)
|
||||||
return merged, non_matching
|
return merged, non_matching
|
||||||
|
|
||||||
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
|
async def _send_with_retry(self, channel: BaseChannel, msg: OutboundMessage) -> None:
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ except ImportError as e:
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_data_dir, get_media_dir
|
from nanobot.config.paths import get_data_dir, get_media_dir
|
||||||
@@ -505,7 +504,7 @@ class MatrixChannel(BaseChannel):
|
|||||||
text = msg.content or ""
|
text = msg.content or ""
|
||||||
candidates = self._collect_outbound_media_candidates(msg.media)
|
candidates = self._collect_outbound_media_candidates(msg.media)
|
||||||
relates_to = self._build_thread_relates_to(msg.metadata)
|
relates_to = self._build_thread_relates_to(msg.metadata)
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = bool((msg.metadata or {}).get("_progress"))
|
||||||
try:
|
try:
|
||||||
failures: list[str] = []
|
failures: list[str] = []
|
||||||
if candidates:
|
if candidates:
|
||||||
@@ -529,19 +528,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
if not is_progress:
|
if not is_progress:
|
||||||
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
await self._stop_typing_keepalive(msg.chat_id, clear_typing=True)
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
self,
|
meta = metadata or {}
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
relates_to = self._build_thread_relates_to(metadata)
|
relates_to = self._build_thread_relates_to(metadata)
|
||||||
|
|
||||||
if stream_end:
|
if meta.get("_stream_end"):
|
||||||
buf = self._stream_bufs.pop(chat_id, None)
|
buf = self._stream_bufs.pop(chat_id, None)
|
||||||
if not buf or not buf.event_id or not buf.text:
|
if not buf or not buf.event_id or not buf.text:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import httpx
|
|||||||
from pydantic import Field, computed_field, field_validator
|
from pydantic import Field, computed_field, field_validator
|
||||||
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -540,7 +539,7 @@ class SignalChannel(BaseChannel):
|
|||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
"""Send a message through Signal."""
|
"""Send a message through Signal."""
|
||||||
is_progress_message = isinstance(msg.event, ProgressEvent)
|
is_progress_message = bool(msg.metadata.get("_progress"))
|
||||||
try:
|
try:
|
||||||
plain_text, text_styles = _markdown_to_signal(msg.content)
|
plain_text, text_styles = _markdown_to_signal(msg.content)
|
||||||
if not plain_text and not msg.media:
|
if not plain_text and not msg.media:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from slack_sdk.web.async_client import AsyncWebClient
|
|||||||
from slackify_markdown import slackify_markdown
|
from slackify_markdown import slackify_markdown
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -165,7 +164,7 @@ class SlackChannel(BaseChannel):
|
|||||||
# only makes sense within the originating conversation.
|
# only makes sense within the originating conversation.
|
||||||
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
|
thread_ts_param = thread_ts if thread_ts and target_chat_id == origin_chat_id else None
|
||||||
|
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = (msg.metadata or {}).get("_progress", False)
|
||||||
if is_progress and not msg.content:
|
if is_progress and not msg.content:
|
||||||
pass # skip empty progress messages (e.g. tool-event-only updates)
|
pass # skip empty progress messages (e.g. tool-event-only updates)
|
||||||
elif msg.content or not (msg.media or []):
|
elif msg.content or not (msg.media or []):
|
||||||
@@ -191,7 +190,7 @@ class SlackChannel(BaseChannel):
|
|||||||
self.logger.exception("Failed to upload file {}", media_path)
|
self.logger.exception("Failed to upload file {}", media_path)
|
||||||
|
|
||||||
# Update reaction emoji when the final (non-progress) response is sent
|
# Update reaction emoji when the final (non-progress) response is sent
|
||||||
if not is_progress:
|
if not (msg.metadata or {}).get("_progress"):
|
||||||
event = slack_meta.get("event", {})
|
event = slack_meta.get("event", {})
|
||||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ from telegram.ext import Application, CallbackQueryHandler, ContextTypes, Messag
|
|||||||
from telegram.request import HTTPXRequest
|
from telegram.request import HTTPXRequest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.command.builtin import build_help_text
|
from nanobot.command.builtin import build_help_text
|
||||||
@@ -37,7 +36,7 @@ from nanobot.utils.helpers import split_message
|
|||||||
|
|
||||||
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
|
||||||
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
|
# Telegram's actual API limit is 4096; we split raw markdown at 4000 as a
|
||||||
# safety margin for mid-stream edits (plain text). On stream end, we split
|
# safety margin for mid-stream edits (plain text). For _stream_end, we split
|
||||||
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
|
# raw markdown into chunks whose rendered HTML fits Telegram's true 4096-char
|
||||||
# boundary so the final rendered message never overflows.
|
# boundary so the final rendered message never overflows.
|
||||||
TELEGRAM_HTML_MAX_LEN = 4096
|
TELEGRAM_HTML_MAX_LEN = 4096
|
||||||
@@ -411,7 +410,6 @@ class TelegramChannel(BaseChannel):
|
|||||||
BotCommand("status", "Show bot status"),
|
BotCommand("status", "Show bot status"),
|
||||||
BotCommand("history", "Show recent conversation messages"),
|
BotCommand("history", "Show recent conversation messages"),
|
||||||
BotCommand("goal", "Start a sustained objective (long-running task)"),
|
BotCommand("goal", "Start a sustained objective (long-running task)"),
|
||||||
BotCommand("trigger", "Create a named local trigger"),
|
|
||||||
BotCommand("pairing", "Manage DM pairing (approve/deny/list)"),
|
BotCommand("pairing", "Manage DM pairing (approve/deny/list)"),
|
||||||
BotCommand("model", "Switch runtime model preset"),
|
BotCommand("model", "Switch runtime model preset"),
|
||||||
BotCommand("skill", "List enabled skills"),
|
BotCommand("skill", "List enabled skills"),
|
||||||
@@ -424,7 +422,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Regex for slash commands routed to AgentLoop via ``_forward_command``.
|
# Regex for slash commands routed to AgentLoop via ``_forward_command``.
|
||||||
# Hyphenated ``dream-*`` commands stay on a separate handler (below).
|
# Hyphenated ``dream-*`` commands stay on a separate handler (below).
|
||||||
TELEGRAM_BUS_SLASH_COMMAND_RE = re.compile(
|
TELEGRAM_BUS_SLASH_COMMAND_RE = re.compile(
|
||||||
r"^/(?:new|stop|restart|status|dream|history|goal|trigger|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$"
|
r"^/(?:new|stop|restart|status|dream|history|goal|pairing|model|skill)(?:@\w+)?(?:\s+.*)?$"
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -708,10 +706,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
self.logger.warning("bot not running")
|
self.logger.warning("bot not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
progress_event = msg.event if isinstance(msg.event, ProgressEvent) else None
|
|
||||||
|
|
||||||
# Only stop typing indicator and remove reaction for final responses
|
# Only stop typing indicator and remove reaction for final responses
|
||||||
if progress_event is None:
|
if not msg.metadata.get("_progress", False):
|
||||||
self._stop_typing(msg.chat_id)
|
self._stop_typing(msg.chat_id)
|
||||||
if reply_to_message_id := msg.metadata.get("message_id"):
|
if reply_to_message_id := msg.metadata.get("message_id"):
|
||||||
with suppress(ValueError):
|
with suppress(ValueError):
|
||||||
@@ -796,7 +792,7 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
# Send text content
|
# Send text content
|
||||||
if msg.content and msg.content != "[empty message]":
|
if msg.content and msg.content != "[empty message]":
|
||||||
render_as_blockquote = bool(progress_event and progress_event.tool_hint)
|
render_as_blockquote = bool(msg.metadata.get("_tool_hint"))
|
||||||
buttons = getattr(msg, "buttons", None) or []
|
buttons = getattr(msg, "buttons", None) or []
|
||||||
reply_markup = self._build_keyboard(buttons) if buttons else None
|
reply_markup = self._build_keyboard(buttons) if buttons else None
|
||||||
text = msg.content
|
text = msg.content
|
||||||
@@ -891,23 +887,15 @@ class TelegramChannel(BaseChannel):
|
|||||||
def _is_not_modified_error(exc: Exception) -> bool:
|
def _is_not_modified_error(exc: Exception) -> bool:
|
||||||
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
|
return isinstance(exc, BadRequest) and "message is not modified" in str(exc).lower()
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Progressive message editing: send on first delta, edit on subsequent ones."""
|
"""Progressive message editing: send on first delta, edit on subsequent ones."""
|
||||||
if not self._app:
|
if not self._app:
|
||||||
return
|
return
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
int_chat_id = int(chat_id)
|
int_chat_id = int(chat_id)
|
||||||
|
stream_id = meta.get("_stream_id")
|
||||||
|
|
||||||
if stream_end:
|
if meta.get("_stream_end"):
|
||||||
buf = self._stream_bufs.get(chat_id)
|
buf = self._stream_bufs.get(chat_id)
|
||||||
if not buf or not buf.message_id or not buf.text:
|
if not buf or not buf.message_id or not buf.text:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,16 +19,6 @@ from websockets.exceptions import ConnectionClosed
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
GoalStateSyncEvent,
|
|
||||||
GoalStatusEvent,
|
|
||||||
ProgressEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
|
||||||
SessionUpdatedEvent,
|
|
||||||
TurnEndEvent,
|
|
||||||
outbound_event_from_message,
|
|
||||||
outbound_message_for_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -158,13 +148,16 @@ def publish_runtime_model_update(
|
|||||||
model_preset: str | None,
|
model_preset: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
|
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
|
||||||
bus.outbound.put_nowait(
|
bus.outbound.put_nowait(OutboundMessage(
|
||||||
outbound_message_for_event(
|
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="*",
|
chat_id="*",
|
||||||
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
content="",
|
||||||
)
|
metadata={
|
||||||
)
|
"_runtime_model_updated": True,
|
||||||
|
"model": model,
|
||||||
|
"model_preset": model_preset,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def _parse_inbound_payload(raw: str) -> str | None:
|
def _parse_inbound_payload(raw: str) -> str | None:
|
||||||
@@ -858,63 +851,70 @@ class WebSocketChannel(BaseChannel):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
event = outbound_event_from_message(msg)
|
if msg.metadata.get("_runtime_model_updated"):
|
||||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
|
||||||
if isinstance(event, RuntimeModelUpdatedEvent):
|
|
||||||
await self.send_runtime_model_updated(
|
await self.send_runtime_model_updated(
|
||||||
model_name=event.model,
|
model_name=msg.metadata.get("model"),
|
||||||
model_preset=event.model_preset,
|
model_preset=msg.metadata.get("model_preset"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
||||||
conns = list(self._subs.get(msg.chat_id, ()))
|
conns = list(self._subs.get(msg.chat_id, ()))
|
||||||
if not conns:
|
if not conns:
|
||||||
if isinstance(
|
if (
|
||||||
event,
|
msg.metadata.get("_progress")
|
||||||
ProgressEvent
|
or msg.metadata.get("_file_edit_events")
|
||||||
| TurnEndEvent
|
or msg.metadata.get("_turn_end")
|
||||||
| SessionUpdatedEvent
|
or msg.metadata.get("_session_updated")
|
||||||
| GoalStatusEvent
|
or msg.metadata.get("_goal_status")
|
||||||
| GoalStateSyncEvent,
|
or msg.metadata.get("_goal_state_sync")
|
||||||
):
|
):
|
||||||
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.debug("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
else:
|
else:
|
||||||
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
self.logger.warning("no active subscribers for chat_id={}", msg.chat_id)
|
||||||
if isinstance(event, GoalStateSyncEvent):
|
if msg.metadata.get("_goal_state_sync"):
|
||||||
if conns:
|
if conns:
|
||||||
await self.send_goal_state(msg.chat_id, event.goal_state or {"active": False})
|
blob = msg.metadata.get("goal_state")
|
||||||
|
await self.send_goal_state(msg.chat_id, blob if isinstance(blob, dict) else {"active": False})
|
||||||
return
|
return
|
||||||
if isinstance(event, GoalStatusEvent):
|
if msg.metadata.get("_goal_status"):
|
||||||
if conns:
|
if conns:
|
||||||
if event.status in ("running", "idle"):
|
status = msg.metadata.get("goal_status")
|
||||||
|
if status in ("running", "idle"):
|
||||||
|
started_raw = msg.metadata.get("started_at", msg.metadata.get("goal_started_at"))
|
||||||
await self.send_goal_status(
|
await self.send_goal_status(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
event.status,
|
status,
|
||||||
started_at=event.started_at,
|
started_at=float(started_raw) if isinstance(started_raw, int | float) else None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
# Signal that the agent has fully finished processing the current turn.
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
if isinstance(event, TurnEndEvent):
|
if msg.metadata.get("_turn_end"):
|
||||||
|
lat = msg.metadata.get("latency_ms")
|
||||||
|
lat_i = int(lat) if isinstance(lat, (int, float)) else None
|
||||||
|
gs = msg.metadata.get("goal_state")
|
||||||
|
gs_blob = gs if isinstance(gs, dict) else None
|
||||||
await self.send_turn_end(
|
await self.send_turn_end(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
latency_ms=event.latency_ms,
|
latency_ms=lat_i,
|
||||||
goal_state=event.goal_state,
|
goal_state=gs_blob,
|
||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
await self.send_session_updated(msg.chat_id, scope="thread")
|
||||||
return
|
return
|
||||||
if isinstance(event, SessionUpdatedEvent):
|
if msg.metadata.get("_session_updated"):
|
||||||
if conns:
|
if conns:
|
||||||
|
scope = msg.metadata.get("_session_update_scope")
|
||||||
await self.send_session_updated(
|
await self.send_session_updated(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
scope=event.scope,
|
scope=scope if isinstance(scope, str) else None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if progress_event and progress_event.file_edit_events:
|
if msg.metadata.get("_file_edit_events"):
|
||||||
|
edits = msg.metadata.get("_file_edit_events")
|
||||||
await self.send_file_edit_events(
|
await self.send_file_edit_events(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
progress_event.file_edit_events,
|
edits if isinstance(edits, list) else [],
|
||||||
msg.metadata,
|
msg.metadata,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -939,17 +939,17 @@ class WebSocketChannel(BaseChannel):
|
|||||||
lat = msg.metadata.get("latency_ms")
|
lat = msg.metadata.get("latency_ms")
|
||||||
if isinstance(lat, (int, float)):
|
if isinstance(lat, (int, float)):
|
||||||
payload["latency_ms"] = int(lat)
|
payload["latency_ms"] = int(lat)
|
||||||
if progress_event and progress_event.tool_events:
|
if msg.metadata.get("_tool_events"):
|
||||||
payload["tool_events"] = progress_event.tool_events
|
payload["tool_events"] = msg.metadata["_tool_events"]
|
||||||
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
|
agent_ui = msg.metadata.get(OUTBOUND_META_AGENT_UI)
|
||||||
if agent_ui is not None:
|
if agent_ui is not None:
|
||||||
payload["agent_ui"] = agent_ui
|
payload["agent_ui"] = agent_ui
|
||||||
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||||
# progress strings) so WS clients can render them as subordinate
|
# progress strings) so WS clients can render them as subordinate
|
||||||
# trace rows rather than conversational replies.
|
# trace rows rather than conversational replies.
|
||||||
if progress_event and progress_event.tool_hint:
|
if msg.metadata.get("_tool_hint"):
|
||||||
payload["kind"] = "tool_hint"
|
payload["kind"] = "tool_hint"
|
||||||
elif progress_event:
|
elif msg.metadata.get("_progress"):
|
||||||
payload["kind"] = "progress"
|
payload["kind"] = "progress"
|
||||||
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
phase = "activity" if payload.get("kind") in ("tool_hint", "progress") else "answer"
|
||||||
self._transcripts.prepare_and_append(
|
self._transcripts.prepare_and_append(
|
||||||
@@ -971,8 +971,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
chat_id: str,
|
chat_id: str,
|
||||||
delta: str,
|
delta: str,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
|
"""Push one chunk of model reasoning. Mirrors ``send_delta`` shape so
|
||||||
clients receive a stream that opens, updates in place, and closes —
|
clients receive a stream that opens, updates in place, and closes —
|
||||||
@@ -988,6 +986,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
"text": delta,
|
"text": delta,
|
||||||
}
|
}
|
||||||
|
stream_id = meta.get("_stream_id")
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._transcripts.prepare_and_append(
|
self._transcripts.prepare_and_append(
|
||||||
@@ -1006,8 +1005,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Close the current reasoning stream segment for in-place renderers."""
|
"""Close the current reasoning stream segment for in-place renderers."""
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
@@ -1016,6 +1013,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"event": "reasoning_end",
|
"event": "reasoning_end",
|
||||||
"chat_id": chat_id,
|
"chat_id": chat_id,
|
||||||
}
|
}
|
||||||
|
stream_id = meta.get("_stream_id")
|
||||||
if stream_id is not None:
|
if stream_id is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = stream_id
|
||||||
self._transcripts.prepare_and_append(
|
self._transcripts.prepare_and_append(
|
||||||
@@ -1059,15 +1057,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
chat_id: str,
|
chat_id: str,
|
||||||
delta: str,
|
delta: str,
|
||||||
metadata: dict[str, Any] | None = None,
|
metadata: dict[str, Any] | None = None,
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
conns = list(self._subs.get(chat_id, ()))
|
conns = list(self._subs.get(chat_id, ()))
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
stream_key = (chat_id, str(stream_id or ""))
|
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
|
||||||
if stream_end:
|
if meta.get("_stream_end"):
|
||||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||||
buffered = self._stream_text_buffers.pop(stream_key, [])
|
buffered = self._stream_text_buffers.pop(stream_key, [])
|
||||||
if delta:
|
if delta:
|
||||||
@@ -1083,8 +1077,8 @@ class WebSocketChannel(BaseChannel):
|
|||||||
"text": delta,
|
"text": delta,
|
||||||
}
|
}
|
||||||
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
|
||||||
if stream_id is not None:
|
if meta.get("_stream_id") is not None:
|
||||||
body["stream_id"] = stream_id
|
body["stream_id"] = meta["_stream_id"]
|
||||||
self._transcripts.prepare_and_append(
|
self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from typing import Any
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
@@ -498,7 +497,7 @@ class WecomChannel(BaseChannel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
content = (msg.content or "").strip()
|
content = (msg.content or "").strip()
|
||||||
is_progress = isinstance(msg.event, ProgressEvent)
|
is_progress = bool(msg.metadata.get("_progress"))
|
||||||
|
|
||||||
# Get the stored frame for this chat
|
# Get the stored frame for this chat
|
||||||
frame = self._chat_frames.get(msg.chat_id)
|
frame = self._chat_frames.get(msg.chat_id)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ from loguru import logger
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
||||||
@@ -130,13 +129,6 @@ class WeixinConfig(Base):
|
|||||||
token: str = "" # Manually set token, or obtained via QR login
|
token: str = "" # Manually set token, or obtained via QR login
|
||||||
state_dir: str = "" # Default: ~/.nanobot/weixin/
|
state_dir: str = "" # Default: ~/.nanobot/weixin/
|
||||||
poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll
|
poll_timeout: int = DEFAULT_LONG_POLL_TIMEOUT_S # seconds for long-poll
|
||||||
# Default on: WeChat iLink has no native incremental delivery (send_delta is
|
|
||||||
# buffered and the final answer is still sent in one shot), so streaming has
|
|
||||||
# zero user-facing effect here — it only switches the LLM call to the
|
|
||||||
# streaming API. That avoids upstream Anthropic relays that drop tool_use
|
|
||||||
# id/name/input on the non-streaming Messages path (a common third-party
|
|
||||||
# relay bug). Set to false only if a relay's streaming/SSE path is broken.
|
|
||||||
streaming: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class WeixinChannel(BaseChannel):
|
class WeixinChannel(BaseChannel):
|
||||||
@@ -175,10 +167,6 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
self._typing_tickets: dict[str, dict[str, Any]] = {}
|
||||||
self._context_token_at: dict[str, float] = {}
|
self._context_token_at: dict[str, float] = {}
|
||||||
self._pending_tool_hints: dict[str, list[str]] = {}
|
self._pending_tool_hints: dict[str, list[str]] = {}
|
||||||
# Buffers streamed content deltas per chat. WeChat iLink has no native
|
|
||||||
# incremental delivery, so when streaming is enabled we accumulate the
|
|
||||||
# deltas and flush the full reply in one shot at _stream_end.
|
|
||||||
self._stream_buffers: dict[str, list[str]] = {}
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# State persistence
|
# State persistence
|
||||||
@@ -1102,13 +1090,11 @@ class WeixinChannel(BaseChannel):
|
|||||||
raise RuntimeError("WeChat client not initialized or not authenticated")
|
raise RuntimeError("WeChat client not initialized or not authenticated")
|
||||||
self._assert_session_active()
|
self._assert_session_active()
|
||||||
|
|
||||||
event = getattr(msg, "event", None)
|
is_progress = bool((msg.metadata or {}).get("_progress", False))
|
||||||
progress_event = event if isinstance(event, ProgressEvent) else None
|
|
||||||
is_progress = progress_event is not None
|
|
||||||
|
|
||||||
# Buffer tool hints to coalesce consecutive ones and avoid burning
|
# Buffer tool hints to coalesce consecutive ones and avoid burning
|
||||||
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
|
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
|
||||||
if progress_event and progress_event.tool_hint:
|
if is_progress and (msg.metadata or {}).get("_tool_hint"):
|
||||||
if not self.send_tool_hints:
|
if not self.send_tool_hints:
|
||||||
return
|
return
|
||||||
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
|
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
|
||||||
@@ -1121,7 +1107,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
|
|
||||||
# Reasoning deltas are invisible in WeChat (there is no reasoning
|
# Reasoning deltas are invisible in WeChat (there is no reasoning
|
||||||
# UI). Skip them entirely — do not send and do not flush buffer.
|
# UI). Skip them entirely — do not send and do not flush buffer.
|
||||||
if progress_event and (progress_event.reasoning_delta or progress_event.reasoning):
|
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
"Dropped invisible reasoning delta for {}", msg.chat_id
|
"Dropped invisible reasoning delta for {}", msg.chat_id
|
||||||
)
|
)
|
||||||
@@ -1235,46 +1221,16 @@ class WeixinChannel(BaseChannel):
|
|||||||
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(
|
||||||
self,
|
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Deliver a streamed reply to WeChat.
|
"""Weixin iLink does not support native streaming deltas.
|
||||||
|
|
||||||
WeChat iLink has no native incremental delivery, and the manager
|
We only hook ``_stream_end`` so buffered tool hints are flushed even
|
||||||
bypasses :meth:`send` for the ``_streamed`` final answer. So we
|
when the final answer carries the ``_streamed`` flag and bypasses
|
||||||
accumulate content deltas and flush the full reply as a single message
|
:meth:`send`.
|
||||||
at stream end. Reasoning deltas are invisible in WeChat and are dropped.
|
|
||||||
"""
|
"""
|
||||||
meta = metadata or {}
|
if metadata and metadata.get("_stream_end"):
|
||||||
if meta.get("_reasoning_delta") or meta.get("_reasoning"):
|
|
||||||
return
|
|
||||||
is_end = stream_end or bool(meta.get("_stream_end"))
|
|
||||||
buffer_key = stream_id or chat_id
|
|
||||||
# Accumulate intermediate deltas. The stream_end message's own content
|
|
||||||
# (present when the manager coalesces deltas into the end message) is
|
|
||||||
# folded into `full` below instead of appended here, so a send retry
|
|
||||||
# recomputes the same `full` from an unchanged buffer rather than
|
|
||||||
# double-counting that delta.
|
|
||||||
if delta and not is_end:
|
|
||||||
self._stream_buffers.setdefault(buffer_key, []).append(delta)
|
|
||||||
if not is_end:
|
|
||||||
return
|
|
||||||
full = ("".join(self._stream_buffers.get(buffer_key, [])) + (delta or "")).strip()
|
|
||||||
await self._flush_tool_hints(chat_id)
|
await self._flush_tool_hints(chat_id)
|
||||||
if full:
|
|
||||||
# Send before clearing the buffer: if the send raises, the buffer is
|
|
||||||
# left intact so ChannelManager._send_with_retry can re-deliver the
|
|
||||||
# same stream_end message instead of silently losing the reply.
|
|
||||||
await self.send(
|
|
||||||
OutboundMessage(channel=self.name, chat_id=chat_id, content=full)
|
|
||||||
)
|
|
||||||
self._stream_buffers.pop(buffer_key, None)
|
|
||||||
|
|
||||||
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
|
||||||
"""Start typing indicator immediately when a message is received."""
|
"""Start typing indicator immediately when a message is received."""
|
||||||
|
|||||||
+176
-27
@@ -29,6 +29,8 @@ class WhatsAppConfig(Base):
|
|||||||
group_policy: Literal["open", "mention"] = "open"
|
group_policy: Literal["open", "mention"] = "open"
|
||||||
database_path: str = ""
|
database_path: str = ""
|
||||||
lid_mappings: dict[str, str] = Field(default_factory=dict)
|
lid_mappings: dict[str, str] = Field(default_factory=dict)
|
||||||
|
typing_presence: bool = True
|
||||||
|
react_emoji: str = "👀"
|
||||||
|
|
||||||
|
|
||||||
class _NeonizeAPI(NamedTuple):
|
class _NeonizeAPI(NamedTuple):
|
||||||
@@ -38,6 +40,8 @@ class _NeonizeAPI(NamedTuple):
|
|||||||
MessageEv: Any
|
MessageEv: Any
|
||||||
PairStatusEv: Any
|
PairStatusEv: Any
|
||||||
build_jid: Any
|
build_jid: Any
|
||||||
|
ChatPresence: Any
|
||||||
|
ChatPresenceMedia: Any
|
||||||
|
|
||||||
|
|
||||||
class _MediaInfo(NamedTuple):
|
class _MediaInfo(NamedTuple):
|
||||||
@@ -48,6 +52,11 @@ class _MediaInfo(NamedTuple):
|
|||||||
is_voice: bool = False
|
is_voice: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class _ReactionTarget(NamedTuple):
|
||||||
|
message_id: str
|
||||||
|
sender_jid: str
|
||||||
|
|
||||||
|
|
||||||
_NEONIZE_API: _NeonizeAPI | None = None
|
_NEONIZE_API: _NeonizeAPI | None = None
|
||||||
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
||||||
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
|
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
|
||||||
@@ -69,6 +78,7 @@ def _load_neonize() -> _NeonizeAPI:
|
|||||||
try:
|
try:
|
||||||
from neonize.aioze.client import NewAClient
|
from neonize.aioze.client import NewAClient
|
||||||
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
|
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
|
||||||
|
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
|
||||||
from neonize.utils.jid import build_jid
|
from neonize.utils.jid import build_jid
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -82,6 +92,8 @@ def _load_neonize() -> _NeonizeAPI:
|
|||||||
MessageEv=MessageEv,
|
MessageEv=MessageEv,
|
||||||
PairStatusEv=PairStatusEv,
|
PairStatusEv=PairStatusEv,
|
||||||
build_jid=build_jid,
|
build_jid=build_jid,
|
||||||
|
ChatPresence=ChatPresence,
|
||||||
|
ChatPresenceMedia=ChatPresenceMedia,
|
||||||
)
|
)
|
||||||
return _NEONIZE_API
|
return _NEONIZE_API
|
||||||
|
|
||||||
@@ -176,6 +188,61 @@ def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]:
|
|||||||
return phone_id, lid_id
|
return phone_id, lid_id
|
||||||
|
|
||||||
|
|
||||||
|
def _mention_token(raw: Any) -> tuple[str, bool]:
|
||||||
|
text = _normalize_jid(raw)
|
||||||
|
if not text:
|
||||||
|
return "", False
|
||||||
|
|
||||||
|
is_lid = False
|
||||||
|
match = _JID_RE.match(text)
|
||||||
|
if match:
|
||||||
|
text = match.group("user")
|
||||||
|
is_lid = match.group("server") in {"lid", "lid.whatsapp.net"}
|
||||||
|
|
||||||
|
token = re.sub(r"\D+", "", text.split(":", 1)[0])
|
||||||
|
return token, is_lid
|
||||||
|
|
||||||
|
|
||||||
|
def _ghost_mentions_from_metadata(metadata: dict[str, Any]) -> tuple[str | None, bool]:
|
||||||
|
raw_mentions = (
|
||||||
|
metadata.get("mentions")
|
||||||
|
or metadata.get("mentioned_jids")
|
||||||
|
or metadata.get("mentionedJids")
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
if isinstance(raw_mentions, (str, int)):
|
||||||
|
raw_mentions = [raw_mentions]
|
||||||
|
if not isinstance(raw_mentions, list | tuple | set):
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
phone_tokens: list[str] = []
|
||||||
|
lid_tokens: list[str] = []
|
||||||
|
seen: set[tuple[bool, str]] = set()
|
||||||
|
for value in raw_mentions:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = (
|
||||||
|
value.get("jid")
|
||||||
|
or value.get("id")
|
||||||
|
or value.get("phone")
|
||||||
|
or value.get("lid")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
token, is_lid = _mention_token(value)
|
||||||
|
if not token or (is_lid, token) in seen:
|
||||||
|
continue
|
||||||
|
seen.add((is_lid, token))
|
||||||
|
if is_lid:
|
||||||
|
lid_tokens.append(token)
|
||||||
|
else:
|
||||||
|
phone_tokens.append(token)
|
||||||
|
|
||||||
|
if phone_tokens:
|
||||||
|
return " ".join(f"@{token}" for token in phone_tokens), False
|
||||||
|
if lid_tokens:
|
||||||
|
return " ".join(f"@{token}" for token in lid_tokens), True
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
def _context_infos(message: Any) -> list[Any]:
|
def _context_infos(message: Any) -> list[Any]:
|
||||||
infos: list[Any] = []
|
infos: list[Any] = []
|
||||||
for container in (
|
for container in (
|
||||||
@@ -293,6 +360,8 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
self._lid_to_phone = self._load_lid_mappings()
|
self._lid_to_phone = self._load_lid_mappings()
|
||||||
self._self_jids: set[str] = set()
|
self._self_jids: set[str] = set()
|
||||||
self._started_at = 0.0
|
self._started_at = 0.0
|
||||||
|
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
|
||||||
|
self._reaction_targets: dict[str, _ReactionTarget] = {}
|
||||||
|
|
||||||
def _database_path(self) -> Path:
|
def _database_path(self) -> Path:
|
||||||
configured = self.config.database_path.strip()
|
configured = self.config.database_path.strip()
|
||||||
@@ -359,6 +428,8 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
self._connected = False
|
self._connected = False
|
||||||
|
for chat_id in list(self._typing_tasks):
|
||||||
|
self._stop_typing(chat_id)
|
||||||
client = self._client
|
client = self._client
|
||||||
self._client = None
|
self._client = None
|
||||||
if client is not None:
|
if client is not None:
|
||||||
@@ -394,7 +465,19 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
raise RuntimeError("WhatsApp channel is not connected")
|
raise RuntimeError("WhatsApp channel is not connected")
|
||||||
|
|
||||||
to = self._build_jid(msg.chat_id)
|
to = self._build_jid(msg.chat_id)
|
||||||
|
if not msg.metadata.get("_progress", False):
|
||||||
|
await self._finish_activity(msg.chat_id)
|
||||||
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
|
ghost_mentions, mentions_are_lids = _ghost_mentions_from_metadata(msg.metadata)
|
||||||
|
if ghost_mentions:
|
||||||
|
await client.send_message(
|
||||||
|
to,
|
||||||
|
msg.content,
|
||||||
|
ghost_mentions=ghost_mentions,
|
||||||
|
mentions_are_lids=mentions_are_lids,
|
||||||
|
)
|
||||||
|
else:
|
||||||
await client.send_message(to, msg.content)
|
await client.send_message(to, msg.content)
|
||||||
|
|
||||||
for media_path in msg.media or []:
|
for media_path in msg.media or []:
|
||||||
@@ -429,6 +512,91 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
mimetype=mimetype,
|
mimetype=mimetype,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _start_typing(self, chat_id: str) -> None:
|
||||||
|
if not self.config.typing_presence or not self._client or not self._connected:
|
||||||
|
return
|
||||||
|
self._stop_typing(chat_id)
|
||||||
|
self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id))
|
||||||
|
|
||||||
|
def _stop_typing(self, chat_id: str) -> bool:
|
||||||
|
task = self._typing_tasks.pop(chat_id, None)
|
||||||
|
if not task:
|
||||||
|
return False
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _typing_loop(self, chat_id: str) -> None:
|
||||||
|
try:
|
||||||
|
while self._client and self._connected:
|
||||||
|
await self._send_presence(chat_id, composing=True)
|
||||||
|
await asyncio.sleep(4)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("WhatsApp typing indicator stopped for {}: {}", chat_id, exc)
|
||||||
|
|
||||||
|
async def _send_presence(self, chat_id: str, *, composing: bool) -> None:
|
||||||
|
client = self._client
|
||||||
|
if client is None or not self._connected:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
api = _load_neonize()
|
||||||
|
state = (
|
||||||
|
api.ChatPresence.CHAT_PRESENCE_COMPOSING
|
||||||
|
if composing
|
||||||
|
else api.ChatPresence.CHAT_PRESENCE_PAUSED
|
||||||
|
)
|
||||||
|
await client.send_chat_presence(
|
||||||
|
self._build_jid(chat_id),
|
||||||
|
state,
|
||||||
|
api.ChatPresenceMedia.CHAT_PRESENCE_MEDIA_TEXT,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("WhatsApp presence update failed: {}", exc)
|
||||||
|
|
||||||
|
async def _send_reaction(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
sender_jid: str,
|
||||||
|
message_id: str,
|
||||||
|
emoji: str,
|
||||||
|
) -> None:
|
||||||
|
client = self._client
|
||||||
|
if client is None or not self._connected or not message_id or not sender_jid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
reaction_message = await client.build_reaction(
|
||||||
|
self._build_jid(chat_id),
|
||||||
|
self._build_jid(sender_jid),
|
||||||
|
message_id,
|
||||||
|
emoji,
|
||||||
|
)
|
||||||
|
await client.send_message(self._build_jid(chat_id), reaction_message)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.debug("WhatsApp reaction update failed: {}", exc)
|
||||||
|
|
||||||
|
async def _start_activity(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
sender_jid: str,
|
||||||
|
) -> None:
|
||||||
|
self._start_typing(chat_id)
|
||||||
|
if self.config.react_emoji and message_id and sender_jid:
|
||||||
|
self._reaction_targets[chat_id] = _ReactionTarget(message_id, sender_jid)
|
||||||
|
await self._send_reaction(chat_id, sender_jid, message_id, self.config.react_emoji)
|
||||||
|
|
||||||
|
async def _finish_activity(self, chat_id: str) -> None:
|
||||||
|
stopped_typing = self._stop_typing(chat_id)
|
||||||
|
if stopped_typing:
|
||||||
|
await self._send_presence(chat_id, composing=False)
|
||||||
|
|
||||||
|
target = self._reaction_targets.pop(chat_id, None)
|
||||||
|
if target is not None:
|
||||||
|
await self._send_reaction(chat_id, target.sender_jid, target.message_id, "")
|
||||||
|
|
||||||
def _register_handlers(
|
def _register_handlers(
|
||||||
self,
|
self,
|
||||||
client: Any,
|
client: Any,
|
||||||
@@ -499,30 +667,6 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
self._self_jids.add(jid)
|
self._self_jids.add(jid)
|
||||||
self._self_jids.add(_bare_jid(jid))
|
self._self_jids.add(_bare_jid(jid))
|
||||||
|
|
||||||
async def _send_read_receipt(self, client: Any, source: Any, message_id: str) -> None:
|
|
||||||
"""Send a read receipt (blue double-check) for an incoming message.
|
|
||||||
|
|
||||||
Best-effort: any failure is logged at debug level and swallowed so it
|
|
||||||
never blocks message processing.
|
|
||||||
"""
|
|
||||||
if not message_id:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
from neonize.utils.enum import ReceiptType
|
|
||||||
|
|
||||||
chat = _safe_attr(source, "Chat")
|
|
||||||
sender = _safe_attr(source, "Sender")
|
|
||||||
if chat is None or sender is None:
|
|
||||||
return
|
|
||||||
await client.mark_read(
|
|
||||||
message_id,
|
|
||||||
chat=chat,
|
|
||||||
sender=sender,
|
|
||||||
receipt=ReceiptType.READ,
|
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001 - read receipt is best-effort
|
|
||||||
self.logger.debug("Failed to send WhatsApp read receipt: {}", exc)
|
|
||||||
|
|
||||||
async def _handle_neonize_message(self, client: Any, event: Any) -> None:
|
async def _handle_neonize_message(self, client: Any, event: Any) -> None:
|
||||||
info = _safe_attr(event, "Info")
|
info = _safe_attr(event, "Info")
|
||||||
message = _safe_attr(event, "Message")
|
message = _safe_attr(event, "Message")
|
||||||
@@ -556,14 +700,12 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
while len(self._processed_message_ids) > 1000:
|
while len(self._processed_message_ids) > 1000:
|
||||||
self._processed_message_ids.popitem(last=False)
|
self._processed_message_ids.popitem(last=False)
|
||||||
|
|
||||||
# Mark the incoming message as read (blue double-check). Best-effort.
|
|
||||||
await self._send_read_receipt(client, source, message_id)
|
|
||||||
|
|
||||||
participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
|
participant_jid = _normalize_jid(_safe_attr(source, "Sender"))
|
||||||
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
|
sender_alt_jid = _normalize_jid(_safe_attr(source, "SenderAlt"))
|
||||||
sender_candidates = [sender_alt_jid, participant_jid]
|
sender_candidates = [sender_alt_jid, participant_jid]
|
||||||
if not is_group:
|
if not is_group:
|
||||||
sender_candidates.append(chat_jid)
|
sender_candidates.append(chat_jid)
|
||||||
|
reaction_sender_jid = sender_alt_jid or participant_jid or chat_jid
|
||||||
|
|
||||||
phone_id, lid_id = _classify_sender_ids(sender_candidates)
|
phone_id, lid_id = _classify_sender_ids(sender_candidates)
|
||||||
if phone_id and lid_id:
|
if phone_id and lid_id:
|
||||||
@@ -579,6 +721,7 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
"is_forwarded": self._is_forwarded(message),
|
"is_forwarded": self._is_forwarded(message),
|
||||||
"participant": participant_jid or None,
|
"participant": participant_jid or None,
|
||||||
"sender_alt": sender_alt_jid or None,
|
"sender_alt": sender_alt_jid or None,
|
||||||
|
"reaction_sender": reaction_sender_jid or None,
|
||||||
"lid": lid_id or None,
|
"lid": lid_id or None,
|
||||||
"phone": phone_id or None,
|
"phone": phone_id or None,
|
||||||
"is_reply_to_bot": self._is_reply_to_bot(message),
|
"is_reply_to_bot": self._is_reply_to_bot(message),
|
||||||
@@ -621,6 +764,12 @@ class WhatsAppChannel(BaseChannel):
|
|||||||
if not text and not media_paths:
|
if not text and not media_paths:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await self._start_activity(
|
||||||
|
chat_id=chat_jid,
|
||||||
|
message_id=message_id,
|
||||||
|
sender_jid=reaction_sender_jid,
|
||||||
|
)
|
||||||
|
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
sender_id=sender_id,
|
sender_id=sender_id,
|
||||||
chat_id=chat_jid,
|
chat_id=chat_jid,
|
||||||
|
|||||||
+19
-158
@@ -50,14 +50,6 @@ from rich.text import Text # noqa: E402
|
|||||||
|
|
||||||
from nanobot import __logo__, __version__ # noqa: E402
|
from nanobot import __logo__, __version__ # noqa: E402
|
||||||
from nanobot.agent.loop import AgentLoop # noqa: E402
|
from nanobot.agent.loop import AgentLoop # noqa: E402
|
||||||
from nanobot.bus.outbound_events import ( # noqa: E402
|
|
||||||
ProgressEvent,
|
|
||||||
RetryWaitEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
outbound_event_from_message,
|
|
||||||
)
|
|
||||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||||
@@ -469,25 +461,25 @@ async def _maybe_print_interactive_progress(
|
|||||||
renderer: StreamRenderer | None = None,
|
renderer: StreamRenderer | None = None,
|
||||||
reasoning_buffer: _ReasoningBuffer | None = None,
|
reasoning_buffer: _ReasoningBuffer | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
event = outbound_event_from_message(msg)
|
metadata = msg.metadata or {}
|
||||||
if isinstance(event, RetryWaitEvent):
|
if metadata.get("_retry_wait"):
|
||||||
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
await _print_interactive_progress_line(msg.content, thinking, renderer)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not isinstance(event, ProgressEvent):
|
if not metadata.get("_progress"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
|
||||||
|
|
||||||
if event.reasoning_end:
|
if metadata.get("_reasoning_end"):
|
||||||
if channels_config and not channels_config.show_reasoning:
|
if channels_config and not channels_config.show_reasoning:
|
||||||
reasoning_buffer.clear()
|
reasoning_buffer.clear()
|
||||||
else:
|
else:
|
||||||
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
is_tool_hint = event.tool_hint
|
is_tool_hint = metadata.get("_tool_hint", False)
|
||||||
is_reasoning = event.reasoning or event.reasoning_delta
|
is_reasoning = metadata.get("_reasoning", False) or metadata.get("_reasoning_delta", False)
|
||||||
if is_reasoning:
|
if is_reasoning:
|
||||||
if channels_config and not channels_config.show_reasoning:
|
if channels_config and not channels_config.show_reasoning:
|
||||||
reasoning_buffer.clear()
|
reasoning_buffer.clear()
|
||||||
@@ -718,21 +710,6 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
|||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
def _read_trigger_cli_message(message: str | None) -> str:
|
|
||||||
"""Read a trigger message from an argument or stdin."""
|
|
||||||
if message and message.strip():
|
|
||||||
return message
|
|
||||||
try:
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
content = sys.stdin.read()
|
|
||||||
if content.strip():
|
|
||||||
return content
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
console.print("[red]Error: trigger message is required[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
||||||
"""Hint users to remove obsolete keys from their config file."""
|
"""Hint users to remove obsolete keys from their config file."""
|
||||||
import json
|
import json
|
||||||
@@ -764,35 +741,6 @@ def _migrate_cron_store(config: "Config") -> None:
|
|||||||
shutil.move(str(legacy_path), str(new_path))
|
shutil.move(str(legacy_path), str(new_path))
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def trigger(
|
|
||||||
trigger_id: str = typer.Argument(..., help="Trigger ID returned by /trigger"),
|
|
||||||
message: str | None = typer.Argument(None, help="Message to deliver; stdin is used when omitted"),
|
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
||||||
):
|
|
||||||
"""Deliver a local trigger message to its bound chat session."""
|
|
||||||
from nanobot.triggers.local_store import (
|
|
||||||
LocalTriggerStore,
|
|
||||||
TriggerDisabledError,
|
|
||||||
TriggerNotFoundError,
|
|
||||||
TriggerStoreError,
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_config = _load_runtime_config(config, workspace)
|
|
||||||
content = _read_trigger_cli_message(message)
|
|
||||||
store = LocalTriggerStore(runtime_config.workspace_path)
|
|
||||||
try:
|
|
||||||
delivery = store.enqueue(trigger_id, content)
|
|
||||||
except (TriggerNotFoundError, TriggerDisabledError) as exc:
|
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
except (TriggerStoreError, ValueError) as exc:
|
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
console.print(f"[green]Queued[/green] {delivery.trigger_id} ({delivery.id})")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OpenAI-Compatible API Server
|
# OpenAI-Compatible API Server
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -850,24 +798,14 @@ def serve(
|
|||||||
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
console.print(f" [cyan]Model[/cyan] : {model_name}{preset_tag}")
|
||||||
console.print(" [cyan]Session[/cyan] : api:default")
|
console.print(" [cyan]Session[/cyan] : api:default")
|
||||||
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
console.print(f" [cyan]Timeout[/cyan] : {timeout}s")
|
||||||
api_key = api_cfg.api_key.strip() if api_cfg.api_key else ""
|
|
||||||
if host in {"0.0.0.0", "::"}:
|
if host in {"0.0.0.0", "::"}:
|
||||||
if not api_key:
|
|
||||||
console.print(
|
console.print(
|
||||||
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
|
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
|
||||||
"Set api.api_key in config to prevent unauthenticated access.[/red]"
|
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
console.print(
|
|
||||||
"[yellow]API is bound to all interfaces "
|
|
||||||
"(authentication required).[/yellow]"
|
|
||||||
)
|
)
|
||||||
console.print()
|
console.print()
|
||||||
|
|
||||||
api_app = create_app(
|
api_app = create_app(agent_loop, model_name=model_name, request_timeout=timeout)
|
||||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
|
||||||
api_key=api_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_startup(_app):
|
async def on_startup(_app):
|
||||||
await agent_loop._connect_mcp()
|
await agent_loop._connect_mcp()
|
||||||
@@ -909,8 +847,6 @@ def _run_gateway(
|
|||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||||
from nanobot.triggers.local_runner import run_local_trigger_queue
|
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
|
||||||
from nanobot.webui.token_usage import TokenUsageHook
|
from nanobot.webui.token_usage import TokenUsageHook
|
||||||
|
|
||||||
port = port if port is not None else config.gateway.port
|
port = port if port is not None else config.gateway.port
|
||||||
@@ -933,7 +869,6 @@ def _run_gateway(
|
|||||||
# Create cron service with workspace-scoped store
|
# Create cron service with workspace-scoped store
|
||||||
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
trigger_store = LocalTriggerStore(config.workspace_path)
|
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
agent = AgentLoop.from_config(
|
agent = AgentLoop.from_config(
|
||||||
@@ -948,13 +883,13 @@ def _run_gateway(
|
|||||||
runtime_events=runtime_events,
|
runtime_events=runtime_events,
|
||||||
provider_signature=provider_snapshot.signature,
|
provider_signature=provider_snapshot.signature,
|
||||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||||
local_trigger_store=trigger_store,
|
|
||||||
)
|
)
|
||||||
WebuiTurnCoordinator(
|
WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
schedule_background=lambda coro: agent._schedule_background(coro),
|
schedule_background=lambda coro: agent._schedule_background(coro),
|
||||||
).subscribe(runtime_events)
|
).subscribe(runtime_events)
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.session.keys import session_key_for_channel
|
from nanobot.session.keys import session_key_for_channel
|
||||||
|
|
||||||
@@ -1150,14 +1085,8 @@ def _run_gateway(
|
|||||||
bus,
|
bus,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
cron_service=cron,
|
cron_service=cron,
|
||||||
local_trigger_store=trigger_store,
|
|
||||||
webui_runtime_model_name=_webui_runtime_model_name,
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
|
webui_cron_pending_job_ids=getattr(agent, "pending_cron_job_ids_for_session", None),
|
||||||
webui_local_trigger_pending_ids=getattr(
|
|
||||||
agent,
|
|
||||||
"pending_local_trigger_ids_for_session",
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
@@ -1298,13 +1227,6 @@ def _run_gateway(
|
|||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
|
||||||
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
|
||||||
asyncio.create_task(
|
|
||||||
run_local_trigger_queue(
|
|
||||||
store=trigger_store,
|
|
||||||
submit_turn=getattr(agent, "submit_local_trigger_turn", None),
|
|
||||||
),
|
|
||||||
name="nanobot-local-triggers",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
if health_server_enabled:
|
if health_server_enabled:
|
||||||
tasks.append(asyncio.create_task(
|
tasks.append(asyncio.create_task(
|
||||||
@@ -1524,7 +1446,7 @@ def agent(
|
|||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
turn_done = asyncio.Event()
|
turn_done = asyncio.Event()
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
turn_response: list[Any] = []
|
turn_response: list[tuple[str, dict]] = []
|
||||||
renderer: StreamRenderer | None = None
|
renderer: StreamRenderer | None = None
|
||||||
reasoning_buffer = _ReasoningBuffer()
|
reasoning_buffer = _ReasoningBuffer()
|
||||||
|
|
||||||
@@ -1532,19 +1454,18 @@ def agent(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
|
||||||
event = outbound_event_from_message(msg)
|
|
||||||
|
|
||||||
if isinstance(event, StreamDeltaEvent):
|
if msg.metadata.get("_stream_delta"):
|
||||||
if renderer:
|
if renderer:
|
||||||
await renderer.on_delta(msg.content)
|
await renderer.on_delta(msg.content)
|
||||||
continue
|
continue
|
||||||
if isinstance(event, StreamEndEvent):
|
if msg.metadata.get("_stream_end"):
|
||||||
if renderer:
|
if renderer:
|
||||||
await renderer.on_end(
|
await renderer.on_end(
|
||||||
resuming=event.resuming,
|
resuming=msg.metadata.get("_resuming", False),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if isinstance(event, StreamedResponseEvent):
|
if msg.metadata.get("_streamed"):
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1559,7 +1480,7 @@ def agent(
|
|||||||
|
|
||||||
if not turn_done.is_set():
|
if not turn_done.is_set():
|
||||||
if msg.content:
|
if msg.content:
|
||||||
turn_response.append(msg)
|
turn_response.append((msg.content, dict(msg.metadata or {})))
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
elif msg.content:
|
elif msg.content:
|
||||||
await _print_interactive_response(
|
await _print_interactive_response(
|
||||||
@@ -1612,10 +1533,8 @@ def agent(
|
|||||||
await turn_done.wait()
|
await turn_done.wait()
|
||||||
|
|
||||||
if turn_response:
|
if turn_response:
|
||||||
response_msg = turn_response[0]
|
content, meta = turn_response[0]
|
||||||
content = response_msg.content
|
if content and not meta.get("_streamed"):
|
||||||
meta = response_msg.metadata
|
|
||||||
if content and not isinstance(response_msg.event, StreamedResponseEvent):
|
|
||||||
if renderer:
|
if renderer:
|
||||||
await renderer.close()
|
await renderer.close()
|
||||||
print_kwargs: dict[str, Any] = {}
|
print_kwargs: dict[str, Any] = {}
|
||||||
@@ -1825,11 +1744,6 @@ _PROVIDER_DISPLAY: dict[str, str] = {
|
|||||||
"github_copilot": "GitHub Copilot",
|
"github_copilot": "GitHub Copilot",
|
||||||
}
|
}
|
||||||
|
|
||||||
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
|
|
||||||
"openai_codex": "openai-codex/gpt-5.4-mini",
|
|
||||||
"github_copilot": "github-copilot/gpt-5.4-mini",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _register_login(name: str):
|
def _register_login(name: str):
|
||||||
"""Register an OAuth login handler."""
|
"""Register an OAuth login handler."""
|
||||||
@@ -1861,51 +1775,9 @@ def _resolve_oauth_provider(provider: str):
|
|||||||
return spec
|
return spec
|
||||||
|
|
||||||
|
|
||||||
def _set_oauth_provider_as_main(
|
|
||||||
provider_name: str,
|
|
||||||
*,
|
|
||||||
model: str | None = None,
|
|
||||||
config_path: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Persist an OAuth provider as the active agent provider."""
|
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
|
||||||
|
|
||||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
|
||||||
if resolved_config_path is not None:
|
|
||||||
set_config_path(resolved_config_path)
|
|
||||||
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
|
||||||
|
|
||||||
config = load_config(resolved_config_path)
|
|
||||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
|
||||||
config.agents.defaults.model_preset = None
|
|
||||||
config.agents.defaults.provider = provider_name
|
|
||||||
config.agents.defaults.model = selected_model
|
|
||||||
save_config(config, resolved_config_path)
|
|
||||||
|
|
||||||
saved_path = resolved_config_path or get_config_path()
|
|
||||||
console.print(
|
|
||||||
f"[green]✓ Set {provider_name.replace('_', '-')} as the main provider[/green] "
|
|
||||||
f"[dim]{selected_model}[/dim]"
|
|
||||||
)
|
|
||||||
console.print(f"[dim]Saved: {saved_path}[/dim]")
|
|
||||||
|
|
||||||
|
|
||||||
@provider_app.command("login")
|
@provider_app.command("login")
|
||||||
def provider_login(
|
def provider_login(
|
||||||
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
|
||||||
set_main: bool = typer.Option(
|
|
||||||
False,
|
|
||||||
"--set-main",
|
|
||||||
"--main",
|
|
||||||
help="Set this OAuth provider as the active agent provider after login",
|
|
||||||
),
|
|
||||||
model: str | None = typer.Option(
|
|
||||||
None,
|
|
||||||
"--model",
|
|
||||||
"-m",
|
|
||||||
help="Model to use when setting this provider as the active provider",
|
|
||||||
),
|
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
|
||||||
):
|
):
|
||||||
"""Authenticate with an OAuth provider."""
|
"""Authenticate with an OAuth provider."""
|
||||||
spec = _resolve_oauth_provider(provider)
|
spec = _resolve_oauth_provider(provider)
|
||||||
@@ -1917,8 +1789,6 @@ def provider_login(
|
|||||||
|
|
||||||
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
|
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
|
||||||
handler()
|
handler()
|
||||||
if set_main or model:
|
|
||||||
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
|
|
||||||
|
|
||||||
|
|
||||||
@provider_app.command("logout")
|
@provider_app.command("logout")
|
||||||
@@ -1942,23 +1812,14 @@ def _login_openai_codex() -> None:
|
|||||||
try:
|
try:
|
||||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
|
||||||
|
|
||||||
proxy = None
|
|
||||||
try:
|
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
|
||||||
except ValueError as e:
|
|
||||||
console.print(f"[red]{e}[/red]")
|
|
||||||
raise typer.Exit(1) from e
|
|
||||||
token = None
|
token = None
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
token = get_token(proxy=proxy)
|
token = get_token()
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
|
||||||
token = login_oauth_interactive(
|
token = login_oauth_interactive(
|
||||||
print_fn=lambda s: console.print(s),
|
print_fn=lambda s: console.print(s),
|
||||||
prompt_fn=lambda s: typer.prompt(s),
|
prompt_fn=lambda s: typer.prompt(s),
|
||||||
proxy=proxy,
|
|
||||||
)
|
)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
console.print("[red]✗ Authentication failed[/red]")
|
console.print("[red]✗ Authentication failed[/red]")
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
@@ -51,7 +50,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
|||||||
BuiltinCommandSpec(
|
BuiltinCommandSpec(
|
||||||
"/restart",
|
"/restart",
|
||||||
"Restart nanobot",
|
"Restart nanobot",
|
||||||
"Restart the bot process.",
|
"Restart the bot process in place.",
|
||||||
"rotate-cw",
|
"rotate-cw",
|
||||||
),
|
),
|
||||||
BuiltinCommandSpec(
|
BuiltinCommandSpec(
|
||||||
@@ -81,13 +80,6 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
|
|||||||
"activity",
|
"activity",
|
||||||
"<goal>",
|
"<goal>",
|
||||||
),
|
),
|
||||||
BuiltinCommandSpec(
|
|
||||||
"/trigger",
|
|
||||||
"Create named local trigger",
|
|
||||||
"Create a named CLI trigger bound to this chat session.",
|
|
||||||
"zap",
|
|
||||||
"<name>",
|
|
||||||
),
|
|
||||||
BuiltinCommandSpec(
|
BuiltinCommandSpec(
|
||||||
"/dream",
|
"/dream",
|
||||||
"Run Dream",
|
"Run Dream",
|
||||||
@@ -138,15 +130,6 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
total = await loop._cancel_active_tasks(ctx.key)
|
total = await loop._cancel_active_tasks(ctx.key)
|
||||||
# Also drain pending queue to prevent mid-turn injection deadlock
|
|
||||||
pending = loop._pending_queues.pop(ctx.key, None)
|
|
||||||
if pending is not None:
|
|
||||||
while not pending.empty():
|
|
||||||
try:
|
|
||||||
pending.get_nowait()
|
|
||||||
total += 1
|
|
||||||
except Exception:
|
|
||||||
break
|
|
||||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
@@ -155,7 +138,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
|
|
||||||
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Restart the process."""
|
"""Restart the process in-place via os.execv."""
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
set_restart_notice_to_env(
|
set_restart_notice_to_env(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
@@ -165,19 +148,7 @@ async def cmd_restart(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
async def _do_restart():
|
async def _do_restart():
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
|
os.execv(sys.executable, [sys.executable, "-m", "nanobot"] + sys.argv[1:])
|
||||||
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto"
|
|
||||||
if mode == "auto":
|
|
||||||
mode = "spawn" if sys.platform == "win32" else "exec"
|
|
||||||
if mode == "exec":
|
|
||||||
os.execv(sys.executable, argv)
|
|
||||||
return
|
|
||||||
if mode == "spawn":
|
|
||||||
kwargs = {}
|
|
||||||
if sys.platform == "win32":
|
|
||||||
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
||||||
subprocess.Popen(argv, **kwargs)
|
|
||||||
os._exit(0)
|
|
||||||
|
|
||||||
asyncio.create_task(_do_restart())
|
asyncio.create_task(_do_restart())
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -725,61 +696,6 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
|
|||||||
metadata=dict(ctx.msg.metadata or {}),
|
metadata=dict(ctx.msg.metadata or {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
|
|
||||||
"""Create a local trigger bound to the current session."""
|
|
||||||
name = ctx.args.strip()
|
|
||||||
if not name:
|
|
||||||
return OutboundMessage(
|
|
||||||
channel=ctx.msg.channel,
|
|
||||||
chat_id=ctx.msg.chat_id,
|
|
||||||
content=(
|
|
||||||
"Usage: /trigger <name>\n\n"
|
|
||||||
"Create a named local trigger bound to this chat session."
|
|
||||||
),
|
|
||||||
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
|
|
||||||
)
|
|
||||||
|
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
|
||||||
|
|
||||||
loop = ctx.loop
|
|
||||||
workspace = getattr(loop, "workspace", None)
|
|
||||||
if workspace is None:
|
|
||||||
workspace = getattr(getattr(loop, "context", None), "workspace", None)
|
|
||||||
if workspace is None:
|
|
||||||
raise RuntimeError("workspace unavailable for trigger creation")
|
|
||||||
|
|
||||||
store = getattr(loop, "local_trigger_store", None)
|
|
||||||
if store is None:
|
|
||||||
store = LocalTriggerStore(workspace)
|
|
||||||
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
||||||
|
|
||||||
session_key = (
|
|
||||||
ctx.msg.session_key
|
|
||||||
if ctx.key == UNIFIED_SESSION_KEY
|
|
||||||
else ctx.key
|
|
||||||
)
|
|
||||||
trigger = store.create(
|
|
||||||
name=name,
|
|
||||||
channel=ctx.msg.channel,
|
|
||||||
chat_id=ctx.msg.chat_id,
|
|
||||||
session_key=session_key,
|
|
||||||
sender_id="trigger",
|
|
||||||
origin_metadata=dict(ctx.msg.metadata or {}),
|
|
||||||
)
|
|
||||||
command = f'nanobot trigger {trigger.id} "message"'
|
|
||||||
return OutboundMessage(
|
|
||||||
channel=ctx.msg.channel,
|
|
||||||
chat_id=ctx.msg.chat_id,
|
|
||||||
content=(
|
|
||||||
f"Trigger created: {trigger.name}\n"
|
|
||||||
f"ID: {trigger.id}\n\n"
|
|
||||||
f"Command:\n{command}"
|
|
||||||
),
|
|
||||||
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_help(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Return available slash commands."""
|
"""Return available slash commands."""
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
@@ -814,8 +730,6 @@ def register_builtin_commands(router: CommandRouter) -> None:
|
|||||||
router.prefix("/history ", cmd_history)
|
router.prefix("/history ", cmd_history)
|
||||||
router.exact("/goal", cmd_goal)
|
router.exact("/goal", cmd_goal)
|
||||||
router.prefix("/goal ", cmd_goal)
|
router.prefix("/goal ", cmd_goal)
|
||||||
router.exact("/trigger", cmd_trigger)
|
|
||||||
router.prefix("/trigger ", cmd_trigger)
|
|
||||||
router.exact("/dream", cmd_dream)
|
router.exact("/dream", cmd_dream)
|
||||||
router.exact("/dream-log", cmd_dream_log)
|
router.exact("/dream-log", cmd_dream_log)
|
||||||
router.prefix("/dream-log ", cmd_dream_log)
|
router.prefix("/dream-log ", cmd_dream_log)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
@@ -11,26 +10,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.session.manager import Session
|
from nanobot.session.manager import Session
|
||||||
|
|
||||||
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
|
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
|
||||||
_BOT_SUFFIX_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_command_text(text: str) -> str:
|
|
||||||
"""Normalize slash-command transport variants before routing.
|
|
||||||
|
|
||||||
Telegram and Discord-style command dispatch can produce ``/cmd@bot args``.
|
|
||||||
The bot suffix belongs to the transport, not the command name, so strip it
|
|
||||||
once at the router boundary while preserving user arguments verbatim.
|
|
||||||
"""
|
|
||||||
stripped = text.strip()
|
|
||||||
if not stripped.startswith("/"):
|
|
||||||
return stripped
|
|
||||||
first, sep, rest = stripped.partition(" ")
|
|
||||||
if "@" not in first:
|
|
||||||
return stripped
|
|
||||||
command, suffix = first.rsplit("@", 1)
|
|
||||||
if command and suffix and _BOT_SUFFIX_RE.fullmatch(suffix):
|
|
||||||
return f"{command}{sep}{rest}" if sep else command
|
|
||||||
return stripped
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -71,7 +50,7 @@ class CommandRouter:
|
|||||||
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
||||||
|
|
||||||
def is_priority(self, text: str) -> bool:
|
def is_priority(self, text: str) -> bool:
|
||||||
return normalize_command_text(text).lower() in self._priority
|
return text.strip().lower() in self._priority
|
||||||
|
|
||||||
def is_dispatchable_command(self, text: str) -> bool:
|
def is_dispatchable_command(self, text: str) -> bool:
|
||||||
"""Check whether *text* matches any non-priority command tier (exact or prefix).
|
"""Check whether *text* matches any non-priority command tier (exact or prefix).
|
||||||
@@ -79,7 +58,7 @@ class CommandRouter:
|
|||||||
Does NOT check priority tier.
|
Does NOT check priority tier.
|
||||||
If this returns True, ``dispatch()`` is guaranteed to match a handler.
|
If this returns True, ``dispatch()`` is guaranteed to match a handler.
|
||||||
"""
|
"""
|
||||||
cmd = normalize_command_text(text).lower()
|
cmd = text.strip().lower()
|
||||||
if cmd in self._exact:
|
if cmd in self._exact:
|
||||||
return True
|
return True
|
||||||
for pfx, _ in self._prefix:
|
for pfx, _ in self._prefix:
|
||||||
@@ -89,7 +68,6 @@ class CommandRouter:
|
|||||||
|
|
||||||
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
|
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
|
||||||
"""Dispatch a priority command. Called from run() without the lock."""
|
"""Dispatch a priority command. Called from run() without the lock."""
|
||||||
ctx.raw = normalize_command_text(ctx.raw)
|
|
||||||
handler = self._priority.get(ctx.raw.lower())
|
handler = self._priority.get(ctx.raw.lower())
|
||||||
if handler:
|
if handler:
|
||||||
return await handler(ctx)
|
return await handler(ctx)
|
||||||
@@ -97,7 +75,6 @@ class CommandRouter:
|
|||||||
|
|
||||||
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
|
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
|
||||||
"""Try exact, then prefix handlers. Returns None if unhandled."""
|
"""Try exact, then prefix handlers. Returns None if unhandled."""
|
||||||
ctx.raw = normalize_command_text(ctx.raw)
|
|
||||||
cmd = ctx.raw.lower()
|
cmd = ctx.raw.lower()
|
||||||
|
|
||||||
if handler := self._exact.get(cmd):
|
if handler := self._exact.get(cmd):
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from loguru import logger
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
@@ -80,10 +79,6 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
|
|||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
data = config.model_dump(mode="json", by_alias=True)
|
data = config.model_dump(mode="json", by_alias=True)
|
||||||
if config.providers.openai_codex.proxy is not None:
|
|
||||||
data.setdefault("providers", {})["openaiCodex"] = {
|
|
||||||
"proxy": config.providers.openai_codex.proxy,
|
|
||||||
}
|
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
@@ -157,23 +152,6 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
def _migrate_config(data: dict) -> dict:
|
||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
agents = data.get("agents", {})
|
|
||||||
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
|
||||||
if isinstance(defaults, dict):
|
|
||||||
had_legacy_max_messages = (
|
|
||||||
"maxMessages" in defaults or "max_messages" in defaults
|
|
||||||
)
|
|
||||||
defaults.pop("maxMessages", None)
|
|
||||||
defaults.pop("max_messages", None)
|
|
||||||
if had_legacy_max_messages:
|
|
||||||
# TODO(next version): Remove this legacy cleanup branch; the schema
|
|
||||||
# will silently ignore this field once the warning grace period ends.
|
|
||||||
logger.warning(
|
|
||||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
|
||||||
"replay max messages is now an internal safety cap. Remove it from "
|
|
||||||
"config. This compatibility warning will be removed in the next version."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ class AgentDefaults(Base):
|
|||||||
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
|
||||||
serialization_alias="idleCompactAfterMinutes",
|
serialization_alias="idleCompactAfterMinutes",
|
||||||
) # Auto-compact idle threshold in minutes (0 = disabled)
|
) # Auto-compact idle threshold in minutes (0 = disabled)
|
||||||
|
max_messages: int = Field(
|
||||||
|
default=120,
|
||||||
|
ge=0,
|
||||||
|
) # Max messages to replay from session history (0 = use default 120, respects token budget)
|
||||||
consolidation_ratio: float = Field(
|
consolidation_ratio: float = Field(
|
||||||
default=0.5,
|
default=0.5,
|
||||||
ge=0.1,
|
ge=0.1,
|
||||||
@@ -179,7 +183,6 @@ class ProviderConfig(Base):
|
|||||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||||
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
extra_body: dict[str, Any] | None = None # Extra provider request fields; shape depends on provider/API surface
|
||||||
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
|
extra_query: dict[str, str] | None = None # Extra query params (e.g. api-version for Azure-style gateways)
|
||||||
proxy: str | None = None # OpenAI-compatible/Codex HTTP proxy URL
|
|
||||||
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
thinking_style: str | None = None # Thinking/reasoning style for custom providers
|
||||||
|
|
||||||
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
# Valid values mirror the keys of _THINKING_STYLE_MAP in
|
||||||
@@ -307,18 +310,6 @@ class ApiConfig(Base):
|
|||||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||||
port: int = 8900
|
port: int = 8900
|
||||||
timeout: float = 120.0 # Per-request timeout in seconds.
|
timeout: float = 120.0 # Per-request timeout in seconds.
|
||||||
api_key: str = Field(default="", repr=False)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def wildcard_host_requires_auth(self) -> "ApiConfig":
|
|
||||||
if self.host not in ("0.0.0.0", "::"):
|
|
||||||
return self
|
|
||||||
if self.api_key.strip():
|
|
||||||
return self
|
|
||||||
raise ValueError(
|
|
||||||
"host is 0.0.0.0 (all interfaces) but api_key is not set "
|
|
||||||
"- set api.api_key to prevent unauthenticated access"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GatewayConfig(Base):
|
class GatewayConfig(Base):
|
||||||
@@ -326,7 +317,6 @@ class GatewayConfig(Base):
|
|||||||
|
|
||||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||||
port: int = 18790
|
port: int = 18790
|
||||||
restart_mode: Literal["auto", "exec", "spawn", "exit"] = "auto"
|
|
||||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+21
-42
@@ -1,7 +1,6 @@
|
|||||||
"""Cron service for scheduling agent tasks."""
|
"""Cron service for scheduling agent tasks."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import errno
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -24,12 +23,6 @@ from nanobot.cron.types import (
|
|||||||
CronSchedule,
|
CronSchedule,
|
||||||
CronStore,
|
CronStore,
|
||||||
)
|
)
|
||||||
from nanobot.utils.run_records import (
|
|
||||||
safe_run_record_name,
|
|
||||||
)
|
|
||||||
from nanobot.utils.run_records import (
|
|
||||||
write_run_record as write_automation_run_record,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CronJobSkippedError(Exception):
|
class CronJobSkippedError(Exception):
|
||||||
@@ -364,25 +357,6 @@ class CronService:
|
|||||||
|
|
||||||
return self._store
|
return self._store
|
||||||
|
|
||||||
def _require_store(self) -> CronStore:
|
|
||||||
"""Return a usable store or raise a clear error.
|
|
||||||
|
|
||||||
``_load_store`` deliberately returns ``None`` when the first load sees
|
|
||||||
a corrupt on-disk store and no previous in-memory snapshot exists. The
|
|
||||||
public API requires a concrete store object before touching
|
|
||||||
``store.jobs``; raising here keeps callers from seeing an accidental
|
|
||||||
``AttributeError`` and, more importantly, prevents follow-up saves from
|
|
||||||
treating a corrupt store as an empty one.
|
|
||||||
"""
|
|
||||||
store = self._load_store()
|
|
||||||
if store is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"cron store at {self.store_path} could not be loaded and was preserved "
|
|
||||||
"as a .corrupt-<ts> backup; refusing to operate to avoid overwriting "
|
|
||||||
"scheduled jobs. Inspect the corrupt backup and restore jobs.json manually."
|
|
||||||
)
|
|
||||||
return store
|
|
||||||
|
|
||||||
def _save_store(self) -> None:
|
def _save_store(self) -> None:
|
||||||
"""Save jobs to disk."""
|
"""Save jobs to disk."""
|
||||||
if not self._store:
|
if not self._store:
|
||||||
@@ -463,15 +437,11 @@ class CronService:
|
|||||||
os.replace(tmp_path, path)
|
os.replace(tmp_path, path)
|
||||||
# fsync the parent directory so the rename itself is durable.
|
# fsync the parent directory so the rename itself is durable.
|
||||||
# Skip on Windows where opening a directory raises PermissionError;
|
# Skip on Windows where opening a directory raises PermissionError;
|
||||||
# some shared filesystems reject directory fsync with EINVAL.
|
# NTFS journals metadata synchronously so this is a no-op there.
|
||||||
with suppress(PermissionError):
|
with suppress(PermissionError):
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
fd = os.open(str(path.parent), os.O_RDONLY)
|
||||||
try:
|
|
||||||
try:
|
try:
|
||||||
os.fsync(fd)
|
os.fsync(fd)
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno != errno.EINVAL:
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
@@ -480,11 +450,20 @@ class CronService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_run_record_name(run_id: str) -> str:
|
def _safe_run_record_name(run_id: str) -> str:
|
||||||
return safe_run_record_name(run_id)
|
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
|
||||||
|
|
||||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||||
"""Write an internal audit record for one cron execution."""
|
"""Write an internal audit record for one cron execution."""
|
||||||
write_automation_run_record(self._run_records_dir, run_id, record)
|
name = self._safe_run_record_name(run_id)
|
||||||
|
if not name:
|
||||||
|
name = str(uuid.uuid4())
|
||||||
|
path = self._run_records_dir / f"{name}.json"
|
||||||
|
payload = {
|
||||||
|
**record,
|
||||||
|
"run_id": run_id,
|
||||||
|
"updated_at_ms": _now_ms(),
|
||||||
|
}
|
||||||
|
self._atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the cron service."""
|
"""Start the cron service."""
|
||||||
@@ -643,7 +622,7 @@ class CronService:
|
|||||||
|
|
||||||
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
def list_jobs(self, include_disabled: bool = False) -> list[CronJob]:
|
||||||
"""List all jobs."""
|
"""List all jobs."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
jobs = store.jobs if include_disabled else [j for j in store.jobs if j.enabled]
|
||||||
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
return sorted(jobs, key=lambda j: j.state.next_run_at_ms or float('inf'))
|
||||||
|
|
||||||
@@ -705,7 +684,7 @@ class CronService:
|
|||||||
_normalize_agent_turn_job(job)
|
_normalize_agent_turn_job(job)
|
||||||
self._enforce_agent_binding(job)
|
self._enforce_agent_binding(job)
|
||||||
if self._running:
|
if self._running:
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
store.jobs.append(job)
|
store.jobs.append(job)
|
||||||
self._save_store()
|
self._save_store()
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
@@ -717,7 +696,7 @@ class CronService:
|
|||||||
|
|
||||||
def register_system_job(self, job: CronJob) -> CronJob:
|
def register_system_job(self, job: CronJob) -> CronJob:
|
||||||
"""Register an internal system job (idempotent on restart)."""
|
"""Register an internal system job (idempotent on restart)."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
now = _now_ms()
|
now = _now_ms()
|
||||||
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
|
job.state = CronJobState(next_run_at_ms=_compute_next_run(job.schedule, now))
|
||||||
job.created_at_ms = now
|
job.created_at_ms = now
|
||||||
@@ -731,7 +710,7 @@ class CronService:
|
|||||||
|
|
||||||
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
|
def remove_job(self, job_id: str) -> Literal["removed", "protected", "not_found"]:
|
||||||
"""Remove a job by ID, unless it is a protected system job."""
|
"""Remove a job by ID, unless it is a protected system job."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||||
if job is None:
|
if job is None:
|
||||||
return "not_found"
|
return "not_found"
|
||||||
@@ -756,7 +735,7 @@ class CronService:
|
|||||||
|
|
||||||
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
|
def enable_job(self, job_id: str, enabled: bool = True) -> CronJob | None:
|
||||||
"""Enable or disable a job."""
|
"""Enable or disable a job."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
for job in store.jobs:
|
for job in store.jobs:
|
||||||
if job.id == job_id:
|
if job.id == job_id:
|
||||||
job.enabled = enabled
|
job.enabled = enabled
|
||||||
@@ -791,7 +770,7 @@ class CronService:
|
|||||||
For ``channel`` and ``to``, pass an explicit value (including ``None``)
|
For ``channel`` and ``to``, pass an explicit value (including ``None``)
|
||||||
to update; omit (sentinel ``...``) to leave unchanged.
|
to update; omit (sentinel ``...``) to leave unchanged.
|
||||||
"""
|
"""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
job = next((j for j in store.jobs if j.id == job_id), None)
|
job = next((j for j in store.jobs if j.id == job_id), None)
|
||||||
if job is None:
|
if job is None:
|
||||||
return "not_found"
|
return "not_found"
|
||||||
@@ -836,7 +815,7 @@ class CronService:
|
|||||||
was_running = self._running
|
was_running = self._running
|
||||||
self._running = True
|
self._running = True
|
||||||
try:
|
try:
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
for job in store.jobs:
|
for job in store.jobs:
|
||||||
if job.id == job_id:
|
if job.id == job_id:
|
||||||
if self._is_unbound_agent_job(job):
|
if self._is_unbound_agent_job(job):
|
||||||
@@ -856,12 +835,12 @@ class CronService:
|
|||||||
|
|
||||||
def get_job(self, job_id: str) -> CronJob | None:
|
def get_job(self, job_id: str) -> CronJob | None:
|
||||||
"""Get a job by ID."""
|
"""Get a job by ID."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
return next((j for j in store.jobs if j.id == job_id), None)
|
return next((j for j in store.jobs if j.id == job_id), None)
|
||||||
|
|
||||||
def status(self) -> dict:
|
def status(self) -> dict:
|
||||||
"""Get service status."""
|
"""Get service status."""
|
||||||
store = self._require_store()
|
store = self._load_store()
|
||||||
return {
|
return {
|
||||||
"enabled": self._running,
|
"enabled": self._running,
|
||||||
"jobs": len(store.jobs),
|
"jobs": len(store.jobs),
|
||||||
|
|||||||
@@ -5,43 +5,16 @@ from __future__ import annotations
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.session.automation_turns import (
|
|
||||||
AutomationTurnSpec,
|
|
||||||
automation_history_overrides_for_spec,
|
|
||||||
automation_trigger,
|
|
||||||
)
|
|
||||||
|
|
||||||
CRON_TRIGGER_META = "_cron_trigger"
|
CRON_TRIGGER_META = "_cron_trigger"
|
||||||
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
|
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
|
||||||
CRON_HISTORY_META = "_cron_turn"
|
CRON_HISTORY_META = "_cron_turn"
|
||||||
|
|
||||||
|
|
||||||
def _cron_history_text(trigger: Mapping[str, Any]) -> str | None:
|
|
||||||
persist_content = trigger.get("persist_content")
|
|
||||||
return (
|
|
||||||
persist_content
|
|
||||||
if isinstance(persist_content, str) and persist_content.strip()
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CRON_AUTOMATION_SPEC = AutomationTurnSpec(
|
|
||||||
kind="cron",
|
|
||||||
trigger_meta_key=CRON_TRIGGER_META,
|
|
||||||
legacy_history_meta_key=CRON_HISTORY_META,
|
|
||||||
history_fields={
|
|
||||||
"cron_job_id": "job_id",
|
|
||||||
"cron_job_name": "job_name",
|
|
||||||
"cron_run_id": "run_id",
|
|
||||||
"cron_prompt_ref": "prompt_ref",
|
|
||||||
},
|
|
||||||
text_builder=_cron_history_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||||
"""Return structured cron trigger metadata when present."""
|
"""Return structured cron trigger metadata when present."""
|
||||||
return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
|
raw = (metadata or {}).get(CRON_TRIGGER_META)
|
||||||
|
return raw if isinstance(raw, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
|
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
|
||||||
@@ -65,7 +38,22 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
|
|||||||
|
|
||||||
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
|
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
|
||||||
"""Return session-history text/metadata overrides for a cron turn."""
|
"""Return session-history text/metadata overrides for a cron turn."""
|
||||||
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
|
trigger = cron_trigger(metadata)
|
||||||
|
if not trigger:
|
||||||
|
return None, {}
|
||||||
|
persist_content = trigger.get("persist_content")
|
||||||
|
text = (
|
||||||
|
persist_content
|
||||||
|
if isinstance(persist_content, str) and persist_content.strip()
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return text, {
|
||||||
|
CRON_HISTORY_META: True,
|
||||||
|
"cron_job_id": trigger.get("job_id"),
|
||||||
|
"cron_job_name": trigger.get("job_name"),
|
||||||
|
"cron_run_id": trigger.get("run_id"),
|
||||||
|
"cron_prompt_ref": trigger.get("prompt_ref"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def is_bound_cron_job(job: CronJob) -> bool:
|
def is_bound_cron_job(job: CronJob) -> bool:
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@@ -276,19 +275,7 @@ class AnthropicProvider(LLMProvider):
|
|||||||
blocks.append({"type": "text", "text": content})
|
blocks.append({"type": "text", "text": content})
|
||||||
elif isinstance(content, list):
|
elif isinstance(content, list):
|
||||||
for item in content:
|
for item in content:
|
||||||
if isinstance(item, dict):
|
blocks.append(item if isinstance(item, dict) else {"type": "text", "text": str(item)})
|
||||||
if not item.get("type"):
|
|
||||||
# Anthropic requires every content block to declare a "type".
|
|
||||||
# A tool that returned a bare dict lands here; coerce it to
|
|
||||||
# a text block instead of emitting one that the API rejects.
|
|
||||||
blocks.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
blocks.append(item)
|
|
||||||
else:
|
|
||||||
blocks.append({"type": "text", "text": str(item)})
|
|
||||||
|
|
||||||
for tc in msg.get("tool_calls") or []:
|
for tc in msg.get("tool_calls") or []:
|
||||||
if not isinstance(tc, dict):
|
if not isinstance(tc, dict):
|
||||||
@@ -328,18 +315,11 @@ class AnthropicProvider(LLMProvider):
|
|||||||
# A tool that returned a bare dict (or a list of dicts) lands
|
# A tool that returned a bare dict (or a list of dicts) lands
|
||||||
# here; coerce it to a text block instead of emitting a block
|
# here; coerce it to a text block instead of emitting a block
|
||||||
# the API rejects with "content.0.type: Field required".
|
# the API rejects with "content.0.type: Field required".
|
||||||
result.append({
|
result.append({"type": "text", "text": str(item)})
|
||||||
"type": "text",
|
|
||||||
"text": AnthropicProvider._stringify_typeless_block(item),
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return result or "(empty)"
|
return result or "(empty)"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _stringify_typeless_block(block: dict[str, Any]) -> str:
|
|
||||||
return json.dumps(block, ensure_ascii=False, sort_keys=True, default=str)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
def _convert_image_block(block: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""Convert OpenAI image_url block to Anthropic image block."""
|
"""Convert OpenAI image_url block to Anthropic image block."""
|
||||||
|
|||||||
@@ -54,18 +54,6 @@ class ToolCallRequest:
|
|||||||
provider_specific_fields: dict[str, Any] | None = None
|
provider_specific_fields: dict[str, Any] | None = None
|
||||||
function_provider_specific_fields: dict[str, Any] | None = None
|
function_provider_specific_fields: dict[str, Any] | None = None
|
||||||
|
|
||||||
def has_valid_name(self) -> bool:
|
|
||||||
"""Whether this call carries a usable (non-empty string) tool name.
|
|
||||||
|
|
||||||
ToolCallRequest.name is typed ``str`` but not enforced at runtime: a
|
|
||||||
model/gateway can emit a degenerate call with ``name=None`` or ``""``.
|
|
||||||
Such a call cannot be executed and, if persisted and replayed, makes
|
|
||||||
upstream APIs reject the whole request (e.g. Anthropic-style
|
|
||||||
``messages.content.N.tool_use.name: Input should be a valid string``),
|
|
||||||
which permanently wedges the session.
|
|
||||||
"""
|
|
||||||
return isinstance(self.name, str) and bool(self.name)
|
|
||||||
|
|
||||||
def to_openai_tool_call(self) -> dict[str, Any]:
|
def to_openai_tool_call(self) -> dict[str, Any]:
|
||||||
"""Serialize to an OpenAI-style tool_call payload."""
|
"""Serialize to an OpenAI-style tool_call payload."""
|
||||||
arguments = (
|
arguments = (
|
||||||
|
|||||||
@@ -58,11 +58,6 @@ def _make_provider_core(
|
|||||||
if spec and spec.is_transcription_only:
|
if spec and spec.is_transcription_only:
|
||||||
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
raise ValueError(f"Provider '{provider_name}' only supports transcription.")
|
||||||
backend = spec.backend if spec else "openai_compat"
|
backend = spec.backend if spec else "openai_compat"
|
||||||
if p and p.proxy and backend not in {"openai_compat", "openai_codex"}:
|
|
||||||
raise ValueError(
|
|
||||||
f"providers.{provider_name}.proxy is only supported for "
|
|
||||||
"OpenAI-compatible providers and OpenAI Codex."
|
|
||||||
)
|
|
||||||
|
|
||||||
if backend == "azure_openai":
|
if backend == "azure_openai":
|
||||||
if not p or not p.api_base:
|
if not p or not p.api_base:
|
||||||
@@ -84,10 +79,7 @@ def _make_provider_core(
|
|||||||
if backend == "openai_codex":
|
if backend == "openai_codex":
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||||
|
|
||||||
provider = OpenAICodexProvider(
|
provider = OpenAICodexProvider(default_model=model)
|
||||||
default_model=model,
|
|
||||||
proxy=getattr(p, "proxy", None) if p else None,
|
|
||||||
)
|
|
||||||
elif backend == "azure_openai":
|
elif backend == "azure_openai":
|
||||||
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
|
||||||
|
|
||||||
@@ -132,7 +124,6 @@ def _make_provider_core(
|
|||||||
extra_body=p.extra_body if p else None,
|
extra_body=p.extra_body if p else None,
|
||||||
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
api_type=p.api_type if p and provider_name == "openai" else "auto",
|
||||||
extra_query=p.extra_query if p else None,
|
extra_query=p.extra_query if p else None,
|
||||||
proxy=p.proxy if p else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = resolved.to_generation_settings()
|
provider.generation = resolved.to_generation_settings()
|
||||||
@@ -227,7 +218,6 @@ def provider_signature(
|
|||||||
fallback.temperature,
|
fallback.temperature,
|
||||||
fallback.reasoning_effort,
|
fallback.reasoning_effort,
|
||||||
fallback.context_window_tokens,
|
fallback.context_window_tokens,
|
||||||
getattr(fp, "proxy", None) if fp else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
provider_name = config.get_provider_name(resolved.model, preset=resolved)
|
||||||
@@ -247,7 +237,6 @@ def provider_signature(
|
|||||||
resolved.temperature,
|
resolved.temperature,
|
||||||
resolved.reasoning_effort,
|
resolved.reasoning_effort,
|
||||||
resolved.context_window_tokens,
|
resolved.context_window_tokens,
|
||||||
getattr(p, "proxy", None) if p else None,
|
|
||||||
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
tuple(_fallback_signature(fallback) for fallback in fallback_presets),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
@@ -30,12 +29,6 @@ _EXPIRY_SKEW_SECONDS = 60
|
|||||||
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
_LONG_LIVED_TOKEN_SECONDS = 315360000
|
||||||
|
|
||||||
|
|
||||||
def _resolve(env_var: str, default: str) -> str:
|
|
||||||
"""Allow GitHub Enterprise / Copilot for Business deployments to override defaults via env."""
|
|
||||||
value = os.environ.get(env_var)
|
|
||||||
return value.strip() if value and value.strip() else default
|
|
||||||
|
|
||||||
|
|
||||||
def get_storage() -> FileTokenStorage:
|
def get_storage() -> FileTokenStorage:
|
||||||
return FileTokenStorage(
|
return FileTokenStorage(
|
||||||
token_filename=TOKEN_FILENAME,
|
token_filename=TOKEN_FILENAME,
|
||||||
@@ -75,16 +68,11 @@ def login_github_copilot(
|
|||||||
printer = print_fn or print
|
printer = print_fn or print
|
||||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||||
|
|
||||||
client_id = _resolve("NANOBOT_GITHUB_COPILOT_CLIENT_ID", GITHUB_COPILOT_CLIENT_ID)
|
|
||||||
device_code_url = _resolve("NANOBOT_GITHUB_DEVICE_CODE_URL", DEFAULT_GITHUB_DEVICE_CODE_URL)
|
|
||||||
access_token_url = _resolve("NANOBOT_GITHUB_ACCESS_TOKEN_URL", DEFAULT_GITHUB_ACCESS_TOKEN_URL)
|
|
||||||
user_url = _resolve("NANOBOT_GITHUB_USER_URL", DEFAULT_GITHUB_USER_URL)
|
|
||||||
|
|
||||||
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
with httpx.Client(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||||
response = client.post(
|
response = client.post(
|
||||||
device_code_url,
|
DEFAULT_GITHUB_DEVICE_CODE_URL,
|
||||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||||
data={"client_id": client_id, "scope": GITHUB_COPILOT_SCOPE},
|
data={"client_id": GITHUB_COPILOT_CLIENT_ID, "scope": GITHUB_COPILOT_SCOPE},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
@@ -108,10 +96,10 @@ def login_github_copilot(
|
|||||||
token_expires_in = _LONG_LIVED_TOKEN_SECONDS
|
token_expires_in = _LONG_LIVED_TOKEN_SECONDS
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
poll = client.post(
|
poll = client.post(
|
||||||
access_token_url,
|
DEFAULT_GITHUB_ACCESS_TOKEN_URL,
|
||||||
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
||||||
data={
|
data={
|
||||||
"client_id": client_id,
|
"client_id": GITHUB_COPILOT_CLIENT_ID,
|
||||||
"device_code": device_code,
|
"device_code": device_code,
|
||||||
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
||||||
},
|
},
|
||||||
@@ -144,7 +132,7 @@ def login_github_copilot(
|
|||||||
raise RuntimeError("GitHub device flow timed out.")
|
raise RuntimeError("GitHub device flow timed out.")
|
||||||
|
|
||||||
user = client.get(
|
user = client.get(
|
||||||
user_url,
|
DEFAULT_GITHUB_USER_URL,
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {access_token}",
|
"Authorization": f"Bearer {access_token}",
|
||||||
"Accept": "application/vnd.github+json",
|
"Accept": "application/vnd.github+json",
|
||||||
@@ -176,7 +164,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
self._copilot_expires_at: float = 0.0
|
self._copilot_expires_at: float = 0.0
|
||||||
super().__init__(
|
super().__init__(
|
||||||
api_key="no-key",
|
api_key="no-key",
|
||||||
api_base=_resolve("NANOBOT_COPILOT_BASE_URL", DEFAULT_COPILOT_BASE_URL),
|
api_base=DEFAULT_COPILOT_BASE_URL,
|
||||||
default_model=default_model,
|
default_model=default_model,
|
||||||
extra_headers={
|
extra_headers={
|
||||||
"Editor-Version": EDITOR_VERSION,
|
"Editor-Version": EDITOR_VERSION,
|
||||||
@@ -198,7 +186,7 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
timeout = httpx.Timeout(20.0, connect=20.0)
|
timeout = httpx.Timeout(20.0, connect=20.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
_resolve("NANOBOT_COPILOT_TOKEN_URL", DEFAULT_COPILOT_TOKEN_URL),
|
DEFAULT_COPILOT_TOKEN_URL,
|
||||||
headers=_copilot_headers(github_token.access),
|
headers=_copilot_headers(github_token.access),
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@@ -33,14 +33,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
|
|
||||||
supports_progress_deltas = True
|
supports_progress_deltas = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
|
||||||
self,
|
|
||||||
default_model: str = "openai-codex/gpt-5.1-codex",
|
|
||||||
proxy: str | None = None,
|
|
||||||
):
|
|
||||||
super().__init__(api_key=None, api_base=None)
|
super().__init__(api_key=None, api_base=None)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.proxy = proxy or None
|
|
||||||
|
|
||||||
async def _call_codex(
|
async def _call_codex(
|
||||||
self,
|
self,
|
||||||
@@ -57,6 +52,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
system_prompt, input_items = convert_messages(messages)
|
system_prompt, input_items = convert_messages(messages)
|
||||||
|
|
||||||
|
token = await asyncio.to_thread(get_codex_token)
|
||||||
|
headers = _build_headers(token.account_id, token.access)
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"model": _strip_model_prefix(model),
|
"model": _strip_model_prefix(model),
|
||||||
"store": False,
|
"store": False,
|
||||||
@@ -76,13 +74,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
|
||||||
headers = _build_headers(token.account_id, token.access)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=True,
|
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||||
proxy=self.proxy,
|
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
@@ -93,7 +87,6 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
DEFAULT_CODEX_URL, headers, body, verify=False,
|
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||||
proxy=self.proxy,
|
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
@@ -206,17 +199,12 @@ async def _request_codex(
|
|||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
verify: bool,
|
verify: bool,
|
||||||
proxy: str | None = None,
|
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||||
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
async with httpx.AsyncClient(timeout=idle_timeout_s, verify=verify) as client:
|
||||||
if proxy:
|
|
||||||
client_kwargs["proxy"] = proxy
|
|
||||||
client_kwargs["trust_env"] = False
|
|
||||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
||||||
async with client.stream("POST", url, headers=headers, json=body) as response:
|
async with client.stream("POST", url, headers=headers, json=body) as response:
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
text = await response.aread()
|
text = await response.aread()
|
||||||
|
|||||||
@@ -358,7 +358,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
extra_body: dict[str, Any] | None = None,
|
extra_body: dict[str, Any] | None = None,
|
||||||
api_type: str = "auto",
|
api_type: str = "auto",
|
||||||
extra_query: dict[str, str] | None = None,
|
extra_query: dict[str, str] | None = None,
|
||||||
proxy: str | None = None,
|
|
||||||
):
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
@@ -367,7 +366,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._extra_body = extra_body or {}
|
self._extra_body = extra_body or {}
|
||||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||||
self._extra_query = extra_query or {}
|
self._extra_query = extra_query or {}
|
||||||
self._proxy = proxy or None
|
|
||||||
|
|
||||||
if api_key and spec and spec.env_key:
|
if api_key and spec and spec.env_key:
|
||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
@@ -398,14 +396,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
timeout_s = _openai_compat_timeout_s()
|
timeout_s = _openai_compat_timeout_s()
|
||||||
http_client: httpx.AsyncClient | None = None
|
http_client: httpx.AsyncClient | None = None
|
||||||
if self._proxy:
|
if self._is_local:
|
||||||
http_client = httpx.AsyncClient(
|
|
||||||
timeout=timeout_s,
|
|
||||||
proxy=self._proxy,
|
|
||||||
trust_env=False,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
elif self._is_local:
|
|
||||||
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
|
||||||
# HTTP connections before the client-side keepalive expires. When
|
# HTTP connections before the client-side keepalive expires. When
|
||||||
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
|
||||||
@@ -1140,21 +1131,14 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if reasoning_content is None:
|
if reasoning_content is None:
|
||||||
reasoning_content = m.get("reasoning_content")
|
reasoning_content = m.get("reasoning_content")
|
||||||
|
|
||||||
# Deduplicate tool call IDs (same pattern as streaming path)
|
|
||||||
# Some providers reuse the same ID for parallel tool calls.
|
|
||||||
_seen_tc_ids: set[str] = set()
|
|
||||||
parsed_tool_calls = []
|
parsed_tool_calls = []
|
||||||
for tc in raw_tool_calls:
|
for tc in raw_tool_calls:
|
||||||
tc_map = self._maybe_mapping(tc) or {}
|
tc_map = self._maybe_mapping(tc) or {}
|
||||||
fn = self._maybe_mapping(tc_map.get("function")) or {}
|
fn = self._maybe_mapping(tc_map.get("function")) or {}
|
||||||
args = parse_tool_arguments(fn.get("arguments", {}))
|
args = parse_tool_arguments(fn.get("arguments", {}))
|
||||||
ec, prov, fn_prov = _extract_tc_extras(tc)
|
ec, prov, fn_prov = _extract_tc_extras(tc)
|
||||||
raw_id = str(tc_map.get("id") or _short_tool_id())
|
|
||||||
if not raw_id or raw_id in _seen_tc_ids:
|
|
||||||
raw_id = _short_tool_id()
|
|
||||||
_seen_tc_ids.add(raw_id)
|
|
||||||
parsed_tool_calls.append(ToolCallRequest(
|
parsed_tool_calls.append(ToolCallRequest(
|
||||||
id=raw_id,
|
id=str(tc_map.get("id") or _short_tool_id()),
|
||||||
name=str(fn.get("name") or ""),
|
name=str(fn.get("name") or ""),
|
||||||
arguments=args,
|
arguments=args,
|
||||||
extra_content=ec,
|
extra_content=ec,
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ class ProviderSpec:
|
|||||||
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
keywords: tuple[str, ...] # model-name keywords for matching (lowercase)
|
||||||
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
env_key: str # env var for API key, e.g. "DASHSCOPE_API_KEY"
|
||||||
display_name: str = "" # shown in `nanobot status`
|
display_name: str = "" # shown in `nanobot status`
|
||||||
model_catalog: str = "auto" # WebUI model-list source
|
|
||||||
|
|
||||||
# which provider implementation to use
|
# which provider implementation to use
|
||||||
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
|
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
|
||||||
@@ -222,7 +221,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
keywords=("skywork", "skyclaw", "apifree"),
|
keywords=("skywork", "skyclaw", "apifree"),
|
||||||
env_key="SKYWORK_API_KEY",
|
env_key="SKYWORK_API_KEY",
|
||||||
display_name="Skywork",
|
display_name="Skywork",
|
||||||
model_catalog="official",
|
|
||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
|
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
|
||||||
is_gateway=True,
|
is_gateway=True,
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
"""Shared handling for session-bound automation turns."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
AUTOMATION_HISTORY_META = "_automation_turn"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AutomationTurnSpec:
|
|
||||||
"""Source-specific wiring for one session-bound automation turn type."""
|
|
||||||
|
|
||||||
kind: str
|
|
||||||
trigger_meta_key: str
|
|
||||||
legacy_history_meta_key: str | None = None
|
|
||||||
history_fields: Mapping[str, str] = field(default_factory=dict)
|
|
||||||
text_builder: Callable[[Mapping[str, Any]], str | None] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def automation_trigger(
|
|
||||||
metadata: Mapping[str, Any] | None,
|
|
||||||
spec: AutomationTurnSpec,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Return source trigger metadata for *spec* when present."""
|
|
||||||
raw = (metadata or {}).get(spec.trigger_meta_key)
|
|
||||||
return raw if isinstance(raw, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def automation_history_overrides_for_spec(
|
|
||||||
metadata: Mapping[str, Any] | None,
|
|
||||||
spec: AutomationTurnSpec,
|
|
||||||
) -> tuple[str | None, dict[str, Any]]:
|
|
||||||
"""Return hidden session-history text/metadata overrides for *spec*."""
|
|
||||||
trigger = automation_trigger(metadata, spec)
|
|
||||||
if not trigger:
|
|
||||||
return None, {}
|
|
||||||
|
|
||||||
details: dict[str, Any] = {"kind": spec.kind}
|
|
||||||
extra: dict[str, Any] = {AUTOMATION_HISTORY_META: details}
|
|
||||||
if spec.legacy_history_meta_key:
|
|
||||||
extra[spec.legacy_history_meta_key] = True
|
|
||||||
for history_key, trigger_key in spec.history_fields.items():
|
|
||||||
value = trigger.get(trigger_key)
|
|
||||||
extra[history_key] = value
|
|
||||||
details[history_key] = value
|
|
||||||
|
|
||||||
text = spec.text_builder(trigger) if spec.text_builder else None
|
|
||||||
return text, extra
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
|
||||||
def _automation_specs() -> tuple[AutomationTurnSpec, ...]:
|
|
||||||
# Source modules import the generic helpers above, so keep spec loading lazy.
|
|
||||||
from nanobot.cron.session_turns import CRON_AUTOMATION_SPEC
|
|
||||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_AUTOMATION_SPEC
|
|
||||||
|
|
||||||
return (CRON_AUTOMATION_SPEC, LOCAL_TRIGGER_AUTOMATION_SPEC)
|
|
||||||
|
|
||||||
|
|
||||||
def automation_history_overrides(
|
|
||||||
metadata: Mapping[str, Any] | None,
|
|
||||||
) -> tuple[str | None, dict[str, Any]]:
|
|
||||||
"""Return session-history text/metadata overrides for supported automation turns."""
|
|
||||||
for spec in _automation_specs():
|
|
||||||
text, extra = automation_history_overrides_for_spec(metadata, spec)
|
|
||||||
if extra:
|
|
||||||
return text, extra
|
|
||||||
return None, {}
|
|
||||||
|
|
||||||
|
|
||||||
def is_automation_history_message(message: Mapping[str, Any] | None) -> bool:
|
|
||||||
"""True for hidden automation trigger records in session history."""
|
|
||||||
if not message:
|
|
||||||
return False
|
|
||||||
marker = message.get(AUTOMATION_HISTORY_META)
|
|
||||||
if marker is True or isinstance(marker, Mapping):
|
|
||||||
return True
|
|
||||||
return any(
|
|
||||||
spec.legacy_history_meta_key
|
|
||||||
and message.get(spec.legacy_history_meta_key) is True
|
|
||||||
for spec in _automation_specs()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_automation_kind(value: Any) -> bool:
|
|
||||||
return isinstance(value, str) and (
|
|
||||||
value == "trigger" or any(spec.kind == value for spec in _automation_specs())
|
|
||||||
)
|
|
||||||
+26
-111
@@ -1,6 +1,5 @@
|
|||||||
"""Session management for conversation history."""
|
"""Session management for conversation history."""
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -27,8 +26,6 @@ from nanobot.utils.helpers import (
|
|||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||||
|
|
||||||
FILE_MAX_MESSAGES = 2000
|
FILE_MAX_MESSAGES = 2000
|
||||||
MIN_REPLAY_MAX_MESSAGES = 120
|
|
||||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
|
||||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||||
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
_TOOL_CALL_ECHO_RE = re.compile(r'^\s*(?:generate_image|message)\([^)]*\)\s*$')
|
||||||
@@ -45,15 +42,6 @@ _FORK_VOLATILE_METADATA_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
|
|
||||||
if not context_window_tokens or context_window_tokens <= 0:
|
|
||||||
return FILE_MAX_MESSAGES
|
|
||||||
return min(
|
|
||||||
FILE_MAX_MESSAGES,
|
|
||||||
max(MIN_REPLAY_MAX_MESSAGES, context_window_tokens // REPLAY_TOKENS_PER_MESSAGE),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_assistant_replay_text(content: str) -> str:
|
def _sanitize_assistant_replay_text(content: str) -> str:
|
||||||
"""Remove internal replay artifacts that the model may have copied before.
|
"""Remove internal replay artifacts that the model may have copied before.
|
||||||
|
|
||||||
@@ -110,12 +98,6 @@ def _metadata_title(metadata: Any) -> str:
|
|||||||
return strip_think(title)
|
return strip_think(title)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RetentionResult:
|
|
||||||
dropped: list[dict]
|
|
||||||
already_consolidated_count: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@@ -149,7 +131,7 @@ class Session:
|
|||||||
|
|
||||||
def get_history(
|
def get_history(
|
||||||
self,
|
self,
|
||||||
max_messages: int = FILE_MAX_MESSAGES,
|
max_messages: int = 120,
|
||||||
*,
|
*,
|
||||||
max_tokens: int = 0,
|
max_tokens: int = 0,
|
||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
@@ -160,7 +142,7 @@ class Session:
|
|||||||
token budget from the tail (``max_tokens``) when provided.
|
token budget from the tail (``max_tokens``) when provided.
|
||||||
"""
|
"""
|
||||||
unconsolidated = self.messages[self.last_consolidated:]
|
unconsolidated = self.messages[self.last_consolidated:]
|
||||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
max_messages = max_messages if max_messages > 0 else 120
|
||||||
start_idx = recent_message_start_index(
|
start_idx = recent_message_start_index(
|
||||||
unconsolidated,
|
unconsolidated,
|
||||||
max_messages,
|
max_messages,
|
||||||
@@ -295,26 +277,22 @@ class Session:
|
|||||||
max_messages: int,
|
max_messages: int,
|
||||||
*,
|
*,
|
||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
) -> RetentionResult:
|
) -> tuple[list[dict], int]:
|
||||||
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
"""Keep a legal recent suffix, optionally extending it back to a user turn.
|
||||||
|
|
||||||
Returns a RetentionResult with dropped messages and how many of those
|
Returns ``(dropped, already_consolidated_count)`` where *dropped* is
|
||||||
were in the already-consolidated prefix. This method mutates
|
the list of removed messages (in original order) and
|
||||||
self.messages and self.last_consolidated in place.
|
*already_consolidated_count* is how many of those were inside the
|
||||||
|
pre-existing ``last_consolidated`` prefix and therefore do not need
|
||||||
|
raw archiving.
|
||||||
"""
|
"""
|
||||||
if max_messages <= 0:
|
if max_messages <= 0:
|
||||||
dropped = list(self.messages)
|
dropped = list(self.messages)
|
||||||
lc = self.last_consolidated
|
lc = self.last_consolidated
|
||||||
self.clear()
|
self.clear()
|
||||||
return RetentionResult(
|
return dropped, min(lc, len(dropped))
|
||||||
dropped=dropped,
|
|
||||||
already_consolidated_count=min(lc, len(dropped)),
|
|
||||||
)
|
|
||||||
if len(self.messages) <= max_messages:
|
if len(self.messages) <= max_messages:
|
||||||
return RetentionResult(
|
return [], 0
|
||||||
dropped=[],
|
|
||||||
already_consolidated_count=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
original = list(self.messages)
|
original = list(self.messages)
|
||||||
before_lc = self.last_consolidated
|
before_lc = self.last_consolidated
|
||||||
@@ -380,10 +358,7 @@ class Session:
|
|||||||
self.messages = retained
|
self.messages = retained
|
||||||
self.last_consolidated = new_lc
|
self.last_consolidated = new_lc
|
||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
return RetentionResult(
|
return dropped, already_consolidated
|
||||||
dropped=dropped,
|
|
||||||
already_consolidated_count=already_consolidated,
|
|
||||||
)
|
|
||||||
|
|
||||||
def enforce_file_cap(
|
def enforce_file_cap(
|
||||||
self,
|
self,
|
||||||
@@ -394,17 +369,17 @@ class Session:
|
|||||||
if limit <= 0 or len(self.messages) <= limit:
|
if limit <= 0 or len(self.messages) <= limit:
|
||||||
return
|
return
|
||||||
|
|
||||||
result = self.retain_recent_legal_suffix(limit)
|
dropped, already_consolidated = self.retain_recent_legal_suffix(limit)
|
||||||
if not result.dropped:
|
if not dropped:
|
||||||
return
|
return
|
||||||
|
|
||||||
archive_chunk = result.dropped[result.already_consolidated_count:]
|
archive_chunk = dropped[already_consolidated:]
|
||||||
if archive_chunk and on_archive:
|
if archive_chunk and on_archive:
|
||||||
on_archive(archive_chunk)
|
on_archive(archive_chunk)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||||
self.key,
|
self.key,
|
||||||
len(result.dropped),
|
len(dropped),
|
||||||
len(archive_chunk),
|
len(archive_chunk),
|
||||||
len(self.messages),
|
len(self.messages),
|
||||||
)
|
)
|
||||||
@@ -428,53 +403,14 @@ class SessionManager:
|
|||||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||||
return safe_filename(key.replace(":", "_"))
|
return safe_filename(key.replace(":", "_"))
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _storage_key(key: str) -> str:
|
|
||||||
"""Collision-resistant encoding for internal session storage filenames."""
|
|
||||||
return base64.urlsafe_b64encode(key.encode()).decode().rstrip("=")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _decode_storage_key(stem: str) -> str | None:
|
|
||||||
"""Reverse _storage_key(): decode a base64url (no-padding) stem back to the original key."""
|
|
||||||
try:
|
|
||||||
# Restore padding stripped by rstrip("=")
|
|
||||||
padding = 4 - len(stem) % 4
|
|
||||||
if padding != 4:
|
|
||||||
stem += "=" * padding
|
|
||||||
return base64.urlsafe_b64decode(stem).decode("utf-8")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the file path for a session."""
|
||||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
return self.sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
def _get_legacy_lossy_path(self, key: str) -> Path:
|
|
||||||
"""Previous workspace session path using lossy ':' to '_' replacement."""
|
|
||||||
return self.sessions_dir / f"{safe_filename(key.replace(':', '_'))}.jsonl"
|
|
||||||
|
|
||||||
def _get_legacy_session_path(self, key: str) -> Path:
|
def _get_legacy_session_path(self, key: str) -> Path:
|
||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _stored_key_for_path(path: Path) -> str | None:
|
|
||||||
"""Read the stored session key from a JSONL metadata row, if present."""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
data = json.loads(line)
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
stored_key = data.get("key")
|
|
||||||
return stored_key if isinstance(stored_key, str) else None
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
Get an existing session or create a new one.
|
Get an existing session or create a new one.
|
||||||
@@ -499,28 +435,13 @@ class SessionManager:
|
|||||||
"""Load a session from disk."""
|
"""Load a session from disk."""
|
||||||
path = self._get_session_path(key)
|
path = self._get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
fallback_paths = [
|
legacy_path = self._get_legacy_session_path(key)
|
||||||
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
if legacy_path.exists():
|
||||||
(self._get_legacy_session_path(key), "legacy path"),
|
|
||||||
]
|
|
||||||
for fallback_path, description in fallback_paths:
|
|
||||||
if not fallback_path.exists():
|
|
||||||
continue
|
|
||||||
stored_key = self._stored_key_for_path(fallback_path)
|
|
||||||
if stored_key and stored_key != key:
|
|
||||||
logger.info(
|
|
||||||
"Skipping migration for {} from {} because it belongs to {}",
|
|
||||||
key,
|
|
||||||
description,
|
|
||||||
stored_key,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
shutil.move(str(fallback_path), str(path))
|
shutil.move(str(legacy_path), str(path))
|
||||||
logger.info("Migrated session {} from {}", key, description)
|
logger.info("Migrated session {} from legacy path", key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to migrate session {}", key)
|
logger.exception("Failed to migrate session {}", key)
|
||||||
break
|
|
||||||
|
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -563,9 +484,8 @@ class SessionManager:
|
|||||||
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
|
logger.info("Recovered session {} from corrupt file ({} messages)", key, len(repaired.messages))
|
||||||
return repaired
|
return repaired
|
||||||
|
|
||||||
def _repair(self, key: str, *, path: Path | None = None) -> Session | None:
|
def _repair(self, key: str) -> Session | None:
|
||||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||||
if path is None:
|
|
||||||
path = self._get_session_path(key)
|
path = self._get_session_path(key)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
@@ -703,11 +623,7 @@ class SessionManager:
|
|||||||
|
|
||||||
Returns True if at least one JSONL file was found and unlinked.
|
Returns True if at least one JSONL file was found and unlinked.
|
||||||
"""
|
"""
|
||||||
paths = [
|
paths = [self._get_session_path(key), self._get_legacy_session_path(key)]
|
||||||
self._get_session_path(key),
|
|
||||||
self._get_legacy_lossy_path(key),
|
|
||||||
self._get_legacy_session_path(key),
|
|
||||||
]
|
|
||||||
self.invalidate(key)
|
self.invalidate(key)
|
||||||
deleted = False
|
deleted = False
|
||||||
for path in paths:
|
for path in paths:
|
||||||
@@ -868,8 +784,7 @@ class SessionManager:
|
|||||||
sessions = []
|
sessions = []
|
||||||
|
|
||||||
for path in self.sessions_dir.glob("*.jsonl"):
|
for path in self.sessions_dir.glob("*.jsonl"):
|
||||||
decoded = self._decode_storage_key(path.stem)
|
fallback_key = path.stem.replace("_", ":", 1)
|
||||||
fallback_key = decoded or path.stem.replace("_", ":", 1)
|
|
||||||
try:
|
try:
|
||||||
# Read the metadata line and a small preview for session lists.
|
# Read the metadata line and a small preview for session lists.
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -877,7 +792,7 @@ class SessionManager:
|
|||||||
if first_line:
|
if first_line:
|
||||||
data = json.loads(first_line)
|
data = json.loads(first_line)
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or fallback_key
|
key = data.get("key") or path.stem.replace("_", ":", 1)
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
title = _metadata_title(metadata)
|
title = _metadata_title(metadata)
|
||||||
preview = ""
|
preview = ""
|
||||||
@@ -918,7 +833,7 @@ class SessionManager:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
repaired = self._repair(fallback_key, path=path)
|
repaired = self._repair(fallback_key)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
sessions.append(
|
sessions.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ _GOAL_CONTINUATION_SENDER = "system:continuation"
|
|||||||
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
|
_GOAL_CONTINUATION_ROUNDS_KEY = "_sustained_goal_continuation_rounds"
|
||||||
_MAX_GOAL_CONTINUATION_ROUNDS = 12
|
_MAX_GOAL_CONTINUATION_ROUNDS = 12
|
||||||
_STRIPPED_INBOUND_META_KEYS = {
|
_STRIPPED_INBOUND_META_KEYS = {
|
||||||
|
"_stream_id",
|
||||||
|
"_stream_delta",
|
||||||
|
"_stream_end",
|
||||||
|
"_resuming",
|
||||||
INTERNAL_CONTINUATION_PENDING_META,
|
INTERNAL_CONTINUATION_PENDING_META,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus import progress as bus_progress
|
from nanobot.bus import progress as bus_progress
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
GoalStateSyncEvent,
|
|
||||||
GoalStatusEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
|
||||||
SessionUpdatedEvent,
|
|
||||||
TurnEndEvent,
|
|
||||||
outbound_message_for_event,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import (
|
from nanobot.bus.runtime_events import (
|
||||||
GoalStateChanged,
|
GoalStateChanged,
|
||||||
@@ -30,8 +22,8 @@ from nanobot.bus.runtime_events import (
|
|||||||
TurnCompleted,
|
TurnCompleted,
|
||||||
TurnRunStatusChanged,
|
TurnRunStatusChanged,
|
||||||
)
|
)
|
||||||
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.session.automation_turns import is_automation_history_message
|
|
||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.helpers import strip_think, truncate_text
|
from nanobot.utils.helpers import strip_think, truncate_text
|
||||||
@@ -77,7 +69,7 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
|||||||
for message in session.messages:
|
for message in session.messages:
|
||||||
if message.get("_command") is True:
|
if message.get("_command") is True:
|
||||||
continue
|
continue
|
||||||
if is_automation_history_message(message):
|
if message.get(CRON_HISTORY_META) is True:
|
||||||
continue
|
continue
|
||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
@@ -214,22 +206,26 @@ async def publish_turn_run_status(
|
|||||||
if msg.channel != "websocket":
|
if msg.channel != "websocket":
|
||||||
return
|
return
|
||||||
cid = str(msg.chat_id)
|
cid = str(msg.chat_id)
|
||||||
started_at_event: float | None = None
|
meta: dict[str, Any] = {
|
||||||
|
**dict(msg.metadata or {}),
|
||||||
|
"_goal_status": True,
|
||||||
|
"goal_status": status,
|
||||||
|
}
|
||||||
if status == "running":
|
if status == "running":
|
||||||
if isinstance(started_at, int | float) and started_at > 0:
|
if isinstance(started_at, int | float) and started_at > 0:
|
||||||
t0 = float(started_at)
|
t0 = float(started_at)
|
||||||
else:
|
else:
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
started_at_event = t0
|
meta["started_at"] = t0
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
_WEBSOCKET_TURN_WALL_STARTED_AT[cid] = t0
|
||||||
else:
|
else:
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(cid, None)
|
||||||
await bus.publish_outbound(
|
await bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
event=GoalStatusEvent(status=status, started_at=started_at_event),
|
content="",
|
||||||
metadata=msg.metadata,
|
metadata=meta,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -322,25 +318,28 @@ class WebuiTurnCoordinator:
|
|||||||
if not cid:
|
if not cid:
|
||||||
return
|
return
|
||||||
await self.bus.publish_outbound(
|
await self.bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
OutboundMessage(
|
||||||
channel=event.context.channel,
|
channel=event.context.channel,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
event=GoalStateSyncEvent(
|
content="",
|
||||||
goal_state=goal_state_ws_blob(event.session_metadata),
|
metadata={
|
||||||
),
|
"_goal_state_sync": True,
|
||||||
metadata=event.context.metadata,
|
"goal_state": goal_state_ws_blob(event.session_metadata),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
|
async def _handle_runtime_model_changed(self, event: RuntimeModelChanged) -> None:
|
||||||
await self.bus.publish_outbound(
|
await self.bus.publish_outbound(
|
||||||
outbound_message_for_event(
|
OutboundMessage(
|
||||||
channel="websocket",
|
channel="websocket",
|
||||||
chat_id="*",
|
chat_id="*",
|
||||||
event=RuntimeModelUpdatedEvent(
|
content="",
|
||||||
model=event.model,
|
metadata={
|
||||||
model_preset=event.model_preset,
|
"_runtime_model_updated": True,
|
||||||
),
|
"model": event.model,
|
||||||
|
"model_preset": event.model_preset,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -375,18 +374,17 @@ class WebuiTurnCoordinator:
|
|||||||
if msg.channel != "websocket":
|
if msg.channel != "websocket":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
|
||||||
|
if latency_ms is not None:
|
||||||
|
turn_metadata["latency_ms"] = int(latency_ms)
|
||||||
session = self.sessions.get_or_create(session_key)
|
session = self.sessions.get_or_create(session_key)
|
||||||
await self.bus.publish_outbound(
|
turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
|
||||||
outbound_message_for_event(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
event=TurnEndEvent(
|
content="",
|
||||||
latency_ms=latency_ms,
|
metadata=turn_metadata,
|
||||||
goal_state=goal_state_ws_blob(session.metadata),
|
))
|
||||||
),
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._schedule_title_update(msg, session_key=session_key)
|
self._schedule_title_update(msg, session_key=session_key)
|
||||||
|
|
||||||
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
||||||
@@ -406,11 +404,16 @@ class WebuiTurnCoordinator:
|
|||||||
model=title_llm.model,
|
model=title_llm.model,
|
||||||
)
|
)
|
||||||
if generated:
|
if generated:
|
||||||
await self._publish_session_metadata_updated(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
chat_id=msg.chat_id,
|
||||||
metadata=msg.metadata,
|
content="",
|
||||||
)
|
metadata={
|
||||||
|
**msg.metadata,
|
||||||
|
"_session_updated": True,
|
||||||
|
"_session_update_scope": "metadata",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
self.schedule_background(_generate_title_and_notify())
|
self.schedule_background(_generate_title_and_notify())
|
||||||
|
|
||||||
@@ -435,26 +438,15 @@ class WebuiTurnCoordinator:
|
|||||||
model=title_llm.model,
|
model=title_llm.model,
|
||||||
)
|
)
|
||||||
if generated:
|
if generated:
|
||||||
await self._publish_session_metadata_updated(
|
await self.bus.publish_outbound(OutboundMessage(
|
||||||
channel=event.context.channel,
|
channel=event.context.channel,
|
||||||
chat_id=event.context.chat_id,
|
chat_id=event.context.chat_id,
|
||||||
metadata=event.context.metadata,
|
content="",
|
||||||
)
|
metadata={
|
||||||
|
**event.context.metadata,
|
||||||
|
"_session_updated": True,
|
||||||
|
"_session_update_scope": "metadata",
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
self.schedule_background(_generate_title_and_notify())
|
self.schedule_background(_generate_title_and_notify())
|
||||||
|
|
||||||
async def _publish_session_metadata_updated(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
channel: str,
|
|
||||||
chat_id: str,
|
|
||||||
metadata: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
await self.bus.publish_outbound(
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
event=SessionUpdatedEvent(scope="metadata"),
|
|
||||||
metadata=metadata,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Local trigger support."""
|
|
||||||
|
|
||||||
from nanobot.triggers.local_store import (
|
|
||||||
LocalTriggerStore,
|
|
||||||
TriggerDisabledError,
|
|
||||||
TriggerNotFoundError,
|
|
||||||
TriggerStoreError,
|
|
||||||
)
|
|
||||||
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LocalTrigger",
|
|
||||||
"LocalTriggerStore",
|
|
||||||
"TriggerDelivery",
|
|
||||||
"TriggerDisabledError",
|
|
||||||
"TriggerNotFoundError",
|
|
||||||
"TriggerRunRecord",
|
|
||||||
"TriggerStoreError",
|
|
||||||
]
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
"""Gateway delivery loop for local triggers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import uuid
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.agent.automation_turns import AutomationTurnError
|
|
||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
|
||||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
|
||||||
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery
|
|
||||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
|
||||||
|
|
||||||
|
|
||||||
async def run_local_trigger_queue(
|
|
||||||
*,
|
|
||||||
store: LocalTriggerStore,
|
|
||||||
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]] | None = None,
|
|
||||||
poll_interval_s: float = 0.5,
|
|
||||||
batch_size: int = 20,
|
|
||||||
) -> None:
|
|
||||||
"""Poll local trigger deliveries and submit them as session turns."""
|
|
||||||
if submit_turn is None:
|
|
||||||
raise ValueError("run_local_trigger_queue requires submit_turn")
|
|
||||||
logger.info("Local trigger queue started")
|
|
||||||
recovered = store.recover_processing_deliveries()
|
|
||||||
if recovered:
|
|
||||||
logger.warning(
|
|
||||||
"Trigger: recovered {} interrupted delivery file(s) from processing",
|
|
||||||
recovered,
|
|
||||||
)
|
|
||||||
while True:
|
|
||||||
deliveries = store.claim_deliveries(limit=batch_size)
|
|
||||||
if not deliveries:
|
|
||||||
await asyncio.sleep(poll_interval_s)
|
|
||||||
continue
|
|
||||||
|
|
||||||
for delivery in deliveries:
|
|
||||||
try:
|
|
||||||
await _deliver_delivery(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
submit_turn=submit_turn,
|
|
||||||
)
|
|
||||||
store.complete_delivery(delivery)
|
|
||||||
except asyncio.CancelledError as exc:
|
|
||||||
store.retry_delivery(delivery, str(exc) or exc.__class__.__name__)
|
|
||||||
_write_delivery_run_record(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
status="interrupted",
|
|
||||||
error=str(exc) or exc.__class__.__name__,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
except _TerminalDeliveryError as exc:
|
|
||||||
store.record_delivery(
|
|
||||||
delivery.trigger_id,
|
|
||||||
status="error",
|
|
||||||
error=str(exc),
|
|
||||||
run_at_ms=delivery.created_at_ms,
|
|
||||||
)
|
|
||||||
_write_delivery_run_record(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
status="error",
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
store.complete_delivery(delivery)
|
|
||||||
logger.warning(
|
|
||||||
"Trigger: dropped delivery {} for {}: {}",
|
|
||||||
delivery.id,
|
|
||||||
delivery.trigger_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
except AutomationTurnError as exc:
|
|
||||||
error = str(exc) or exc.__class__.__name__
|
|
||||||
store.record_delivery(
|
|
||||||
delivery.trigger_id,
|
|
||||||
status="error",
|
|
||||||
error=error,
|
|
||||||
run_at_ms=delivery.created_at_ms,
|
|
||||||
)
|
|
||||||
_write_delivery_run_record(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
status="error",
|
|
||||||
error=error,
|
|
||||||
)
|
|
||||||
store.complete_delivery(delivery)
|
|
||||||
logger.warning(
|
|
||||||
"Trigger: delivery {} for {} reached the agent but failed: {}",
|
|
||||||
delivery.id,
|
|
||||||
delivery.trigger_id,
|
|
||||||
error,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
error = str(exc) or exc.__class__.__name__
|
|
||||||
retried = store.retry_delivery(delivery, error)
|
|
||||||
_write_delivery_run_record(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
status="retrying" if retried else "error",
|
|
||||||
error=error,
|
|
||||||
)
|
|
||||||
store.record_delivery(
|
|
||||||
delivery.trigger_id,
|
|
||||||
status="error",
|
|
||||||
error=error,
|
|
||||||
run_at_ms=delivery.created_at_ms,
|
|
||||||
)
|
|
||||||
logger.exception(
|
|
||||||
"Trigger: failed delivery {} for {}{}",
|
|
||||||
delivery.id,
|
|
||||||
delivery.trigger_id,
|
|
||||||
"; queued retry" if retried else "; moved to failed queue",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _TerminalDeliveryError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def _deliver_delivery(
|
|
||||||
store: LocalTriggerStore,
|
|
||||||
delivery: TriggerDelivery,
|
|
||||||
*,
|
|
||||||
submit_turn: Callable[[InboundMessage], Awaitable[OutboundMessage | None]],
|
|
||||||
) -> None:
|
|
||||||
trigger = store.get(delivery.trigger_id)
|
|
||||||
if trigger is None:
|
|
||||||
raise _TerminalDeliveryError("trigger not found")
|
|
||||||
if not trigger.enabled:
|
|
||||||
raise _TerminalDeliveryError("trigger is disabled")
|
|
||||||
|
|
||||||
store.write_delivery_run_record(delivery, trigger=trigger, status="processing")
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel=trigger.channel,
|
|
||||||
sender_id=trigger.sender_id,
|
|
||||||
chat_id=trigger.chat_id,
|
|
||||||
content=delivery.content,
|
|
||||||
metadata=_delivery_metadata(trigger, delivery),
|
|
||||||
session_key_override=trigger.session_key,
|
|
||||||
)
|
|
||||||
response = await submit_turn(msg)
|
|
||||||
store.record_delivery(
|
|
||||||
trigger.id,
|
|
||||||
status="ok",
|
|
||||||
run_at_ms=delivery.created_at_ms,
|
|
||||||
)
|
|
||||||
_write_delivery_run_record(
|
|
||||||
store,
|
|
||||||
delivery,
|
|
||||||
trigger=trigger,
|
|
||||||
status="ok",
|
|
||||||
response=response.content if response else "",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_delivery_run_record(
|
|
||||||
store: LocalTriggerStore,
|
|
||||||
delivery: TriggerDelivery,
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
trigger: LocalTrigger | None = None,
|
|
||||||
error: str | None = None,
|
|
||||||
response: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
store.write_delivery_run_record(
|
|
||||||
delivery,
|
|
||||||
trigger=trigger,
|
|
||||||
status=status,
|
|
||||||
error=error,
|
|
||||||
response=response,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception(
|
|
||||||
"Trigger: failed to write run record for delivery {}",
|
|
||||||
delivery.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _delivery_metadata(trigger: LocalTrigger, delivery: TriggerDelivery) -> dict[str, Any]:
|
|
||||||
metadata = dict(trigger.origin_metadata or {})
|
|
||||||
metadata[LOCAL_TRIGGER_META] = {
|
|
||||||
"trigger_id": trigger.id,
|
|
||||||
"trigger_name": trigger.name,
|
|
||||||
"delivery_id": delivery.id,
|
|
||||||
"created_at_ms": delivery.created_at_ms,
|
|
||||||
"persist_content": _history_content(trigger, delivery),
|
|
||||||
}
|
|
||||||
if trigger.channel == "websocket":
|
|
||||||
metadata.pop(WEBUI_TURN_METADATA_KEY, None)
|
|
||||||
metadata[WEBUI_TURN_METADATA_KEY] = f"trigger:{trigger.id}:{uuid.uuid4().hex}"
|
|
||||||
source: dict[str, str] = {"kind": "local_trigger"}
|
|
||||||
if trigger.name:
|
|
||||||
source["label"] = trigger.name
|
|
||||||
metadata[WEBUI_MESSAGE_SOURCE_METADATA_KEY] = source
|
|
||||||
return metadata
|
|
||||||
|
|
||||||
|
|
||||||
def _history_content(trigger: LocalTrigger, delivery: TriggerDelivery) -> str:
|
|
||||||
label = trigger.name.strip() if trigger.name else trigger.id
|
|
||||||
return f"Local trigger received: {label}\n\n{delivery.content}"
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"""Shared metadata helpers for local trigger session turns."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Mapping
|
|
||||||
|
|
||||||
from nanobot.session.automation_turns import (
|
|
||||||
AutomationTurnSpec,
|
|
||||||
automation_history_overrides_for_spec,
|
|
||||||
automation_trigger,
|
|
||||||
)
|
|
||||||
|
|
||||||
LOCAL_TRIGGER_META = "_local_trigger"
|
|
||||||
|
|
||||||
|
|
||||||
def _local_trigger_history_text(trigger: Mapping[str, Any]) -> str:
|
|
||||||
persist_content = trigger.get("persist_content")
|
|
||||||
if isinstance(persist_content, str) and persist_content.strip():
|
|
||||||
return persist_content
|
|
||||||
name = trigger.get("trigger_name")
|
|
||||||
trigger_id = trigger.get("trigger_id")
|
|
||||||
label = name if isinstance(name, str) and name.strip() else trigger_id
|
|
||||||
return (
|
|
||||||
f"Local trigger received: {label}"
|
|
||||||
if isinstance(label, str) and label.strip()
|
|
||||||
else "Local trigger received"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
LOCAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
|
|
||||||
kind="local_trigger",
|
|
||||||
trigger_meta_key=LOCAL_TRIGGER_META,
|
|
||||||
history_fields={
|
|
||||||
"trigger_id": "trigger_id",
|
|
||||||
"trigger_name": "trigger_name",
|
|
||||||
"trigger_delivery_id": "delivery_id",
|
|
||||||
},
|
|
||||||
text_builder=_local_trigger_history_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def local_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
||||||
"""Return structured local trigger metadata when present."""
|
|
||||||
return automation_trigger(metadata, LOCAL_TRIGGER_AUTOMATION_SPEC)
|
|
||||||
|
|
||||||
|
|
||||||
def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
|
|
||||||
trigger = local_trigger(metadata)
|
|
||||||
if not trigger:
|
|
||||||
return None
|
|
||||||
value = trigger.get("delivery_id")
|
|
||||||
return value if isinstance(value, str) and value else None
|
|
||||||
|
|
||||||
|
|
||||||
def local_trigger_history_overrides(
|
|
||||||
metadata: Mapping[str, Any] | None,
|
|
||||||
) -> tuple[str | None, dict[str, Any]]:
|
|
||||||
"""Return session-history text/metadata overrides for a local trigger turn."""
|
|
||||||
return automation_history_overrides_for_spec(
|
|
||||||
metadata,
|
|
||||||
LOCAL_TRIGGER_AUTOMATION_SPEC,
|
|
||||||
)
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
"""Workspace-scoped local trigger store and delivery queue."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import errno
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from filelock import FileLock
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.triggers.local_types import LocalTrigger, TriggerDelivery, TriggerRunRecord
|
|
||||||
from nanobot.utils.helpers import truncate_text
|
|
||||||
from nanobot.utils.run_records import write_run_record as write_automation_run_record
|
|
||||||
|
|
||||||
_TRIGGER_ID_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
||||||
_MAX_RUN_HISTORY = 20
|
|
||||||
_MAX_DELIVERY_ATTEMPTS = 10
|
|
||||||
_RUN_RECORD_TEXT_MAX_CHARS = 4000
|
|
||||||
_PROCESSING_RECOVERY_ERROR = "delivery was recovered from interrupted processing"
|
|
||||||
|
|
||||||
|
|
||||||
class TriggerStoreError(RuntimeError):
|
|
||||||
"""Base class for trigger store errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class TriggerNotFoundError(TriggerStoreError):
|
|
||||||
"""Raised when a trigger ID does not exist."""
|
|
||||||
|
|
||||||
|
|
||||||
class TriggerDisabledError(TriggerStoreError):
|
|
||||||
"""Raised when a trigger is disabled."""
|
|
||||||
|
|
||||||
|
|
||||||
class LocalTriggerStore:
|
|
||||||
"""Persistent local triggers for one workspace."""
|
|
||||||
|
|
||||||
def __init__(self, workspace_path: Path):
|
|
||||||
self.workspace_path = Path(workspace_path)
|
|
||||||
self.root = self.workspace_path / "triggers"
|
|
||||||
self.store_path = self.root / "triggers.json"
|
|
||||||
self.inbox_dir = self.root / "inbox"
|
|
||||||
self.processing_dir = self.root / "processing"
|
|
||||||
self.failed_dir = self.root / "failed"
|
|
||||||
self.runs_dir = self.root / "runs"
|
|
||||||
self._lock = FileLock(str(self.root / ".lock"))
|
|
||||||
|
|
||||||
def create(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
channel: str,
|
|
||||||
chat_id: str,
|
|
||||||
session_key: str,
|
|
||||||
sender_id: str = "trigger",
|
|
||||||
origin_metadata: dict[str, Any] | None = None,
|
|
||||||
) -> LocalTrigger:
|
|
||||||
"""Create a new session-bound local trigger."""
|
|
||||||
clean_name = _clean_name(name)
|
|
||||||
channel = channel.strip()
|
|
||||||
chat_id = chat_id.strip()
|
|
||||||
session_key = session_key.strip()
|
|
||||||
if not channel or not chat_id or not session_key:
|
|
||||||
raise ValueError("channel, chat_id, and session_key are required")
|
|
||||||
|
|
||||||
now = _now_ms()
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
existing_ids = {trigger.id for trigger in triggers}
|
|
||||||
trigger_id = _new_trigger_id(existing_ids)
|
|
||||||
trigger = LocalTrigger(
|
|
||||||
id=trigger_id,
|
|
||||||
name=clean_name,
|
|
||||||
enabled=True,
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
session_key=session_key,
|
|
||||||
sender_id=sender_id.strip() or "trigger",
|
|
||||||
origin_metadata=dict(origin_metadata or {}),
|
|
||||||
created_at_ms=now,
|
|
||||||
updated_at_ms=now,
|
|
||||||
)
|
|
||||||
triggers.append(trigger)
|
|
||||||
self._save_triggers_unlocked(triggers)
|
|
||||||
return trigger
|
|
||||||
|
|
||||||
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]:
|
|
||||||
"""List triggers in this workspace."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
if not include_disabled:
|
|
||||||
triggers = [trigger for trigger in triggers if trigger.enabled]
|
|
||||||
return sorted(triggers, key=lambda trigger: (trigger.updated_at_ms, trigger.id), reverse=True)
|
|
||||||
|
|
||||||
def list_for_session(
|
|
||||||
self,
|
|
||||||
session_key: str,
|
|
||||||
*,
|
|
||||||
include_disabled: bool = True,
|
|
||||||
) -> list[LocalTrigger]:
|
|
||||||
"""List triggers bound to one session key."""
|
|
||||||
return [
|
|
||||||
trigger
|
|
||||||
for trigger in self.list_triggers(include_disabled=include_disabled)
|
|
||||||
if trigger.session_key == session_key
|
|
||||||
]
|
|
||||||
|
|
||||||
def get(self, trigger_id: str) -> LocalTrigger | None:
|
|
||||||
"""Return one trigger by ID."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
return self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
|
|
||||||
|
|
||||||
def enable(self, trigger_id: str, *, enabled: bool) -> LocalTrigger | None:
|
|
||||||
"""Enable or disable a trigger."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
trigger = self._find_unlocked(triggers, trigger_id)
|
|
||||||
if trigger is None:
|
|
||||||
return None
|
|
||||||
trigger.enabled = enabled
|
|
||||||
trigger.updated_at_ms = _now_ms()
|
|
||||||
self._save_triggers_unlocked(triggers)
|
|
||||||
return trigger
|
|
||||||
|
|
||||||
def update(self, trigger_id: str, *, name: str | None = None) -> LocalTrigger | None:
|
|
||||||
"""Update mutable trigger fields."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
trigger = self._find_unlocked(triggers, trigger_id)
|
|
||||||
if trigger is None:
|
|
||||||
return None
|
|
||||||
if name is not None:
|
|
||||||
trigger.name = _clean_name(name)
|
|
||||||
trigger.updated_at_ms = _now_ms()
|
|
||||||
self._save_triggers_unlocked(triggers)
|
|
||||||
return trigger
|
|
||||||
|
|
||||||
def delete(self, trigger_id: str) -> bool:
|
|
||||||
"""Delete a trigger by ID."""
|
|
||||||
trigger_id = trigger_id.strip()
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
remaining = [trigger for trigger in triggers if trigger.id != trigger_id]
|
|
||||||
if len(remaining) == len(triggers):
|
|
||||||
return False
|
|
||||||
self._save_triggers_unlocked(remaining)
|
|
||||||
self._delete_delivery_files_for_trigger_unlocked(trigger_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def enqueue(self, trigger_id: str, content: str) -> TriggerDelivery:
|
|
||||||
"""Queue a delivery for the gateway process to consume."""
|
|
||||||
trigger_id = trigger_id.strip()
|
|
||||||
if not content.strip():
|
|
||||||
raise ValueError("trigger message is required")
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
trigger = self._find_unlocked(self._load_triggers_unlocked(), trigger_id)
|
|
||||||
if trigger is None:
|
|
||||||
raise TriggerNotFoundError(f"trigger not found: {trigger_id}")
|
|
||||||
if not trigger.enabled:
|
|
||||||
raise TriggerDisabledError(f"trigger is disabled: {trigger_id}")
|
|
||||||
delivery = TriggerDelivery(
|
|
||||||
id=f"tdl_{uuid.uuid4().hex[:12]}",
|
|
||||||
trigger_id=trigger_id,
|
|
||||||
content=content,
|
|
||||||
created_at_ms=_now_ms(),
|
|
||||||
)
|
|
||||||
path = self.inbox_dir / f"{delivery.created_at_ms}-{delivery.id}.json"
|
|
||||||
self._atomic_write(path, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
|
|
||||||
delivery.path = path
|
|
||||||
try:
|
|
||||||
self.write_delivery_run_record(delivery, trigger=trigger, status="queued")
|
|
||||||
except BaseException:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
delivery.path = None
|
|
||||||
raise
|
|
||||||
return delivery
|
|
||||||
|
|
||||||
def claim_deliveries(self, *, limit: int = 20) -> list[TriggerDelivery]:
|
|
||||||
"""Move pending deliveries into processing and return them."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
claimed: list[TriggerDelivery] = []
|
|
||||||
with self._lock:
|
|
||||||
for path in sorted(self.inbox_dir.glob("*.json"))[: max(0, limit)]:
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
delivery = TriggerDelivery.from_dict(
|
|
||||||
data.get("delivery", data),
|
|
||||||
path=self.processing_dir / path.name,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Trigger: failed to parse delivery {}", path)
|
|
||||||
self._move_bad_delivery_unlocked(path)
|
|
||||||
continue
|
|
||||||
os.replace(path, delivery.path)
|
|
||||||
claimed.append(delivery)
|
|
||||||
return claimed
|
|
||||||
|
|
||||||
def recover_processing_deliveries(self) -> int:
|
|
||||||
"""Requeue deliveries left in processing by an interrupted gateway."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
recovered = 0
|
|
||||||
with self._lock:
|
|
||||||
for path in sorted(self.processing_dir.glob("*.json")):
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
delivery = TriggerDelivery.from_dict(
|
|
||||||
data.get("delivery", data),
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Trigger: failed to parse processing delivery {}", path)
|
|
||||||
self._move_bad_delivery_unlocked(path)
|
|
||||||
continue
|
|
||||||
if self._retry_delivery_unlocked(delivery, _PROCESSING_RECOVERY_ERROR):
|
|
||||||
recovered += 1
|
|
||||||
return recovered
|
|
||||||
|
|
||||||
def complete_delivery(self, delivery: TriggerDelivery) -> None:
|
|
||||||
"""Delete a claimed delivery after it is handled."""
|
|
||||||
if delivery.path is None:
|
|
||||||
return
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
delivery.path.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def retry_delivery(self, delivery: TriggerDelivery, error: str) -> bool:
|
|
||||||
"""Retry a claimed delivery unless it exceeded the attempt limit."""
|
|
||||||
if delivery.path is None:
|
|
||||||
return False
|
|
||||||
self._ensure_dirs()
|
|
||||||
with self._lock:
|
|
||||||
return self._retry_delivery_unlocked(delivery, error)
|
|
||||||
|
|
||||||
def record_delivery(
|
|
||||||
self,
|
|
||||||
trigger_id: str,
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
error: str | None = None,
|
|
||||||
run_at_ms: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Record the latest delivery status on a trigger."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
run_at_ms = run_at_ms or _now_ms()
|
|
||||||
with self._lock:
|
|
||||||
triggers = self._load_triggers_unlocked()
|
|
||||||
trigger = self._find_unlocked(triggers, trigger_id)
|
|
||||||
if trigger is None:
|
|
||||||
return
|
|
||||||
trigger.last_run_at_ms = run_at_ms
|
|
||||||
trigger.last_status = "ok" if status == "ok" else "error"
|
|
||||||
trigger.last_error = None if status == "ok" else (error or "delivery failed")
|
|
||||||
trigger.updated_at_ms = _now_ms()
|
|
||||||
trigger.run_history.append(
|
|
||||||
TriggerRunRecord(
|
|
||||||
run_at_ms=run_at_ms,
|
|
||||||
status=trigger.last_status,
|
|
||||||
error=trigger.last_error,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
trigger.run_history = trigger.run_history[-_MAX_RUN_HISTORY:]
|
|
||||||
self._save_triggers_unlocked(triggers)
|
|
||||||
|
|
||||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> Path:
|
|
||||||
"""Write an internal audit record for one local trigger delivery."""
|
|
||||||
self._ensure_dirs()
|
|
||||||
return write_automation_run_record(self.runs_dir, run_id, record)
|
|
||||||
|
|
||||||
def write_delivery_run_record(
|
|
||||||
self,
|
|
||||||
delivery: TriggerDelivery,
|
|
||||||
*,
|
|
||||||
status: str,
|
|
||||||
trigger: LocalTrigger | None = None,
|
|
||||||
error: str | None = None,
|
|
||||||
response: str | None = None,
|
|
||||||
) -> Path:
|
|
||||||
"""Write the durable audit record for one local trigger delivery."""
|
|
||||||
if trigger is None:
|
|
||||||
trigger = self.get(delivery.trigger_id)
|
|
||||||
record = _delivery_run_record(delivery, trigger)
|
|
||||||
record["status"] = status
|
|
||||||
if error:
|
|
||||||
record["error"] = _run_record_text(error)
|
|
||||||
if response is not None:
|
|
||||||
record["response"] = _run_record_text(response)
|
|
||||||
return self.write_run_record(delivery.id, record)
|
|
||||||
|
|
||||||
def _ensure_dirs(self) -> None:
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.inbox_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.processing_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.failed_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.runs_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
def _load_triggers_unlocked(self) -> list[LocalTrigger]:
|
|
||||||
if not self.store_path.exists():
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
data = json.loads(self.store_path.read_text(encoding="utf-8"))
|
|
||||||
return [
|
|
||||||
LocalTrigger.from_dict(raw)
|
|
||||||
for raw in data.get("triggers", [])
|
|
||||||
if isinstance(raw, dict)
|
|
||||||
]
|
|
||||||
except Exception as exc:
|
|
||||||
backup = self.store_path.with_suffix(
|
|
||||||
self.store_path.suffix + f".corrupt-{int(time.time())}"
|
|
||||||
)
|
|
||||||
with suppress(OSError):
|
|
||||||
os.replace(self.store_path, backup)
|
|
||||||
raise TriggerStoreError(
|
|
||||||
f"trigger store at {self.store_path} could not be loaded and was preserved "
|
|
||||||
"as a .corrupt-<ts> backup"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
def _save_triggers_unlocked(self, triggers: list[LocalTrigger]) -> None:
|
|
||||||
payload = {
|
|
||||||
"version": 1,
|
|
||||||
"triggers": [trigger.to_dict() for trigger in triggers],
|
|
||||||
}
|
|
||||||
self._atomic_write(self.store_path, json.dumps(payload, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_unlocked(
|
|
||||||
triggers: list[LocalTrigger],
|
|
||||||
trigger_id: str,
|
|
||||||
) -> LocalTrigger | None:
|
|
||||||
return next((trigger for trigger in triggers if trigger.id == trigger_id), None)
|
|
||||||
|
|
||||||
def _move_bad_delivery_unlocked(self, path: Path) -> None:
|
|
||||||
target = self.failed_dir / f"{path.name}.bad"
|
|
||||||
with suppress(OSError):
|
|
||||||
os.replace(path, target)
|
|
||||||
|
|
||||||
def _retry_delivery_unlocked(self, delivery: TriggerDelivery, error: str) -> bool:
|
|
||||||
if delivery.path is None:
|
|
||||||
return False
|
|
||||||
if delivery.attempts + 1 >= _MAX_DELIVERY_ATTEMPTS:
|
|
||||||
delivery.attempts += 1
|
|
||||||
delivery.last_error = error
|
|
||||||
failed = self.failed_dir / delivery.path.name
|
|
||||||
self._atomic_write(failed, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
|
|
||||||
delivery.path.unlink(missing_ok=True)
|
|
||||||
return False
|
|
||||||
delivery.attempts += 1
|
|
||||||
delivery.last_error = error
|
|
||||||
target = self.inbox_dir / delivery.path.name
|
|
||||||
self._atomic_write(target, json.dumps(_delivery_payload(delivery), ensure_ascii=False))
|
|
||||||
delivery.path.unlink(missing_ok=True)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _delete_delivery_files_for_trigger_unlocked(self, trigger_id: str) -> None:
|
|
||||||
for directory in (self.inbox_dir, self.processing_dir, self.failed_dir):
|
|
||||||
for path in directory.iterdir():
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
if self._delivery_file_trigger_id(path) != trigger_id:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
path.unlink(missing_ok=True)
|
|
||||||
except OSError as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Trigger: failed to delete delivery file {} for deleted trigger {}: {}",
|
|
||||||
path,
|
|
||||||
trigger_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _delivery_file_trigger_id(path: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
raw = data.get("delivery", data) if isinstance(data, dict) else None
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
trigger_id = raw.get("triggerId", raw.get("trigger_id", ""))
|
|
||||||
return str(trigger_id) if trigger_id else None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _atomic_write(path: Path, content: str) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp_path, path)
|
|
||||||
with suppress(PermissionError):
|
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno != errno.EINVAL:
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
except BaseException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _new_trigger_id(existing_ids: set[str]) -> str:
|
|
||||||
for _ in range(100):
|
|
||||||
suffix = "".join(secrets.choice(_TRIGGER_ID_ALPHABET) for _ in range(8))
|
|
||||||
candidate = f"trg_{suffix}"
|
|
||||||
if candidate not in existing_ids:
|
|
||||||
return candidate
|
|
||||||
raise TriggerStoreError("could not allocate a unique trigger id")
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_name(name: str) -> str:
|
|
||||||
stripped = " ".join(name.strip().split())
|
|
||||||
return (stripped or "Local trigger")[:120]
|
|
||||||
|
|
||||||
|
|
||||||
def _now_ms() -> int:
|
|
||||||
return int(time.time() * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def _delivery_payload(delivery: TriggerDelivery) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"version": 1,
|
|
||||||
"delivery": delivery.to_dict(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _delivery_run_record(
|
|
||||||
delivery: TriggerDelivery,
|
|
||||||
trigger: LocalTrigger | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
record: dict[str, Any] = {
|
|
||||||
"kind": "local_trigger",
|
|
||||||
"trigger_id": delivery.trigger_id,
|
|
||||||
"delivery_id": delivery.id,
|
|
||||||
"content": _run_record_text(delivery.content),
|
|
||||||
"created_at_ms": delivery.created_at_ms,
|
|
||||||
"attempts": delivery.attempts,
|
|
||||||
}
|
|
||||||
if delivery.last_error:
|
|
||||||
record["last_error"] = _run_record_text(delivery.last_error)
|
|
||||||
if trigger is not None:
|
|
||||||
record.update(
|
|
||||||
{
|
|
||||||
"trigger_name": trigger.name,
|
|
||||||
"session_key": trigger.session_key,
|
|
||||||
"channel": trigger.channel,
|
|
||||||
"chat_id": trigger.chat_id,
|
|
||||||
"sender_id": trigger.sender_id,
|
|
||||||
"origin_metadata": trigger.origin_metadata,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
def _run_record_text(value: str) -> str:
|
|
||||||
return truncate_text(value, _RUN_RECORD_TEXT_MAX_CHARS)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Coordination for local trigger turns."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
|
||||||
|
|
||||||
from nanobot.agent.automation_turns import AutomationTurnCoordinator
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.triggers.local_session_turns import local_trigger, local_trigger_delivery_id
|
|
||||||
|
|
||||||
|
|
||||||
class LocalTriggerTurnCoordinator(AutomationTurnCoordinator):
|
|
||||||
"""Manage local trigger turns without mixing them into live injections."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
|
|
||||||
dispatch: Callable[[InboundMessage], Awaitable[object]],
|
|
||||||
is_running: Callable[[], bool],
|
|
||||||
deferred_queues: dict[str, list[InboundMessage]] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
publish_inbound=publish_inbound,
|
|
||||||
dispatch=dispatch,
|
|
||||||
is_running=is_running,
|
|
||||||
turn_id=lambda msg: local_trigger_delivery_id(msg.metadata),
|
|
||||||
pending_id=_local_trigger_id,
|
|
||||||
should_defer_turn=_should_defer_local_trigger_turn,
|
|
||||||
missing_id_error="local trigger turn metadata must include a delivery_id",
|
|
||||||
duplicate_id_error=lambda delivery_id: (
|
|
||||||
f"local trigger delivery {delivery_id!r} is already pending"
|
|
||||||
),
|
|
||||||
deferred_queues=deferred_queues,
|
|
||||||
)
|
|
||||||
|
|
||||||
def pending_trigger_ids_for_session(self, session_key: str) -> set[str]:
|
|
||||||
"""Return local triggers waiting for or running in *session_key*."""
|
|
||||||
return self.pending_ids_for_session(session_key)
|
|
||||||
|
|
||||||
|
|
||||||
def _should_defer_local_trigger_turn(
|
|
||||||
msg: InboundMessage,
|
|
||||||
session_key: str,
|
|
||||||
active_session_keys: Iterable[str],
|
|
||||||
) -> bool:
|
|
||||||
return local_trigger(msg.metadata) is not None and session_key in active_session_keys
|
|
||||||
|
|
||||||
|
|
||||||
def _local_trigger_id(msg: InboundMessage) -> str | None:
|
|
||||||
trigger = local_trigger(msg.metadata)
|
|
||||||
if not trigger:
|
|
||||||
return None
|
|
||||||
value = trigger.get("trigger_id")
|
|
||||||
return value if isinstance(value, str) and value else None
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""Persistent types for local triggers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Literal
|
|
||||||
|
|
||||||
TriggerStatus = Literal["ok", "error"]
|
|
||||||
|
|
||||||
|
|
||||||
def _get(data: dict[str, Any], camel: str, snake: str, default: Any = None) -> Any:
|
|
||||||
if camel in data:
|
|
||||||
return data[camel]
|
|
||||||
return data.get(snake, default)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TriggerRunRecord:
|
|
||||||
"""A single local trigger delivery record."""
|
|
||||||
|
|
||||||
run_at_ms: int
|
|
||||||
status: TriggerStatus
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "TriggerRunRecord":
|
|
||||||
return cls(
|
|
||||||
run_at_ms=int(_get(data, "runAtMs", "run_at_ms", 0)),
|
|
||||||
status=str(data.get("status") or "error"), # type: ignore[arg-type]
|
|
||||||
error=data.get("error"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"runAtMs": self.run_at_ms,
|
|
||||||
"status": self.status,
|
|
||||||
"error": self.error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LocalTrigger:
|
|
||||||
"""A session-bound local trigger."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
enabled: bool
|
|
||||||
channel: str
|
|
||||||
chat_id: str
|
|
||||||
session_key: str
|
|
||||||
sender_id: str = "trigger"
|
|
||||||
origin_metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
created_at_ms: int = 0
|
|
||||||
updated_at_ms: int = 0
|
|
||||||
last_run_at_ms: int | None = None
|
|
||||||
last_status: TriggerStatus | None = None
|
|
||||||
last_error: str | None = None
|
|
||||||
run_history: list[TriggerRunRecord] = field(default_factory=list)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: dict[str, Any]) -> "LocalTrigger":
|
|
||||||
history = [
|
|
||||||
record if isinstance(record, TriggerRunRecord) else TriggerRunRecord.from_dict(record)
|
|
||||||
for record in data.get("runHistory", data.get("run_history", []))
|
|
||||||
if isinstance(record, (dict, TriggerRunRecord))
|
|
||||||
]
|
|
||||||
return cls(
|
|
||||||
id=str(data["id"]),
|
|
||||||
name=str(data.get("name") or data["id"]),
|
|
||||||
enabled=bool(data.get("enabled", True)),
|
|
||||||
channel=str(data.get("channel") or ""),
|
|
||||||
chat_id=str(_get(data, "chatId", "chat_id", "")),
|
|
||||||
session_key=str(_get(data, "sessionKey", "session_key", "")),
|
|
||||||
sender_id=str(_get(data, "senderId", "sender_id", "trigger") or "trigger"),
|
|
||||||
origin_metadata=dict(_get(data, "originMetadata", "origin_metadata", {}) or {}),
|
|
||||||
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
|
|
||||||
updated_at_ms=int(_get(data, "updatedAtMs", "updated_at_ms", 0)),
|
|
||||||
last_run_at_ms=_get(data, "lastRunAtMs", "last_run_at_ms"),
|
|
||||||
last_status=_get(data, "lastStatus", "last_status"), # type: ignore[arg-type]
|
|
||||||
last_error=_get(data, "lastError", "last_error"),
|
|
||||||
run_history=history,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"name": self.name,
|
|
||||||
"enabled": self.enabled,
|
|
||||||
"channel": self.channel,
|
|
||||||
"chatId": self.chat_id,
|
|
||||||
"sessionKey": self.session_key,
|
|
||||||
"senderId": self.sender_id,
|
|
||||||
"originMetadata": self.origin_metadata,
|
|
||||||
"createdAtMs": self.created_at_ms,
|
|
||||||
"updatedAtMs": self.updated_at_ms,
|
|
||||||
"lastRunAtMs": self.last_run_at_ms,
|
|
||||||
"lastStatus": self.last_status,
|
|
||||||
"lastError": self.last_error,
|
|
||||||
"runHistory": [record.to_dict() for record in self.run_history],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TriggerDelivery:
|
|
||||||
"""One pending local trigger delivery written by the CLI."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
trigger_id: str
|
|
||||||
content: str
|
|
||||||
created_at_ms: int
|
|
||||||
attempts: int = 0
|
|
||||||
last_error: str | None = None
|
|
||||||
path: Path | None = field(default=None, compare=False, repr=False)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(
|
|
||||||
cls,
|
|
||||||
data: dict[str, Any],
|
|
||||||
*,
|
|
||||||
path: Path | None = None,
|
|
||||||
) -> "TriggerDelivery":
|
|
||||||
return cls(
|
|
||||||
id=str(data["id"]),
|
|
||||||
trigger_id=str(_get(data, "triggerId", "trigger_id", "")),
|
|
||||||
content=str(data.get("content") or ""),
|
|
||||||
created_at_ms=int(_get(data, "createdAtMs", "created_at_ms", 0)),
|
|
||||||
attempts=int(data.get("attempts", 0)),
|
|
||||||
last_error=data.get("lastError") or data.get("last_error"),
|
|
||||||
path=path,
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": self.id,
|
|
||||||
"triggerId": self.trigger_id,
|
|
||||||
"content": self.content,
|
|
||||||
"createdAtMs": self.created_at_ms,
|
|
||||||
"attempts": self.attempts,
|
|
||||||
"lastError": self.last_error,
|
|
||||||
}
|
|
||||||
@@ -529,9 +529,6 @@ class StreamingFileEditTracker:
|
|||||||
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
"""Keep final start/end events keyed to any earlier streamed placeholder."""
|
||||||
used_canonicals: set[str] = set()
|
used_canonicals: set[str] = set()
|
||||||
for tool_call in final_tool_calls:
|
for tool_call in final_tool_calls:
|
||||||
name = getattr(tool_call, "name", None)
|
|
||||||
if not is_file_edit_tool(name):
|
|
||||||
continue
|
|
||||||
canonical = self.canonical_call_id_for(tool_call)
|
canonical = self.canonical_call_id_for(tool_call)
|
||||||
if canonical and canonical not in used_canonicals:
|
if canonical and canonical not in used_canonicals:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Durable JSON run records for automation executions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import errno
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
def safe_run_record_name(run_id: str) -> str:
|
|
||||||
"""Return a filesystem-safe filename stem for a run ID."""
|
|
||||||
return "".join(c if c.isalnum() or c in "._-" else "_" for c in run_id)
|
|
||||||
|
|
||||||
|
|
||||||
def write_run_record(runs_dir: Path, run_id: str, record: dict[str, Any]) -> Path:
|
|
||||||
"""Write or replace one durable automation run audit record."""
|
|
||||||
name = safe_run_record_name(run_id) or str(uuid.uuid4())
|
|
||||||
path = runs_dir / f"{name}.json"
|
|
||||||
payload = {
|
|
||||||
**record,
|
|
||||||
"run_id": run_id,
|
|
||||||
"updated_at_ms": _now_ms(),
|
|
||||||
}
|
|
||||||
_atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=False))
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def _now_ms() -> int:
|
|
||||||
return int(time.time() * 1000)
|
|
||||||
|
|
||||||
|
|
||||||
def _atomic_write(path: Path, content: str) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp_path = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
||||||
try:
|
|
||||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp_path, path)
|
|
||||||
with suppress(PermissionError):
|
|
||||||
fd = os.open(str(path.parent), os.O_RDONLY)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
os.fsync(fd)
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno != errno.EINVAL:
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
os.close(fd)
|
|
||||||
except BaseException:
|
|
||||||
tmp_path.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -11,7 +10,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.utils.helpers import stringify_text_blocks
|
from nanobot.utils.helpers import stringify_text_blocks
|
||||||
|
|
||||||
_MAX_REPEAT_ATTEMPTS = 2
|
_MAX_REPEAT_EXTERNAL_LOOKUPS = 2
|
||||||
|
|
||||||
# Third same-target workspace violation in a turn escalates to "stop retrying".
|
# Third same-target workspace violation in a turn escalates to "stop retrying".
|
||||||
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
|
_MAX_REPEAT_WORKSPACE_VIOLATIONS = 2
|
||||||
@@ -104,14 +103,6 @@ def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _over_repeat_budget(signature: str | None, seen_counts: dict[str, int]) -> int | None:
|
|
||||||
if signature is None:
|
|
||||||
return None
|
|
||||||
count = seen_counts.get(signature, 0) + 1
|
|
||||||
seen_counts[signature] = count
|
|
||||||
return count if count > _MAX_REPEAT_ATTEMPTS else None
|
|
||||||
|
|
||||||
|
|
||||||
def repeated_external_lookup_error(
|
def repeated_external_lookup_error(
|
||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: Any,
|
arguments: Any,
|
||||||
@@ -119,8 +110,11 @@ def repeated_external_lookup_error(
|
|||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Block repeated external lookups after a small retry budget."""
|
"""Block repeated external lookups after a small retry budget."""
|
||||||
signature = external_lookup_signature(tool_name, arguments)
|
signature = external_lookup_signature(tool_name, arguments)
|
||||||
count = _over_repeat_budget(signature, seen_counts)
|
if signature is None:
|
||||||
if count is None:
|
return None
|
||||||
|
count = seen_counts.get(signature, 0) + 1
|
||||||
|
seen_counts[signature] = count
|
||||||
|
if count <= _MAX_REPEAT_EXTERNAL_LOOKUPS:
|
||||||
return None
|
return None
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Blocking repeated external lookup {} on attempt {}",
|
"Blocking repeated external lookup {} on attempt {}",
|
||||||
@@ -133,33 +127,6 @@ def repeated_external_lookup_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def repeated_tool_result_hint(
|
|
||||||
tool_name: str,
|
|
||||||
result: Any,
|
|
||||||
seen_counts: dict[str, int],
|
|
||||||
) -> str | None:
|
|
||||||
"""Hint when a successful tool keeps returning the exact same text in one turn."""
|
|
||||||
if isinstance(result, str):
|
|
||||||
text = result
|
|
||||||
elif isinstance(result, list):
|
|
||||||
text = stringify_text_blocks(result)
|
|
||||||
else:
|
|
||||||
text = None
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
digest = hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
|
||||||
signature = f"tool_result:{tool_name}:{len(text)}:{digest}"
|
|
||||||
count = _over_repeat_budget(signature, seen_counts)
|
|
||||||
if count is None:
|
|
||||||
return None
|
|
||||||
logger.warning("Hinting repeated {} result on attempt {}", tool_name, count)
|
|
||||||
return (
|
|
||||||
f"\n\n[Repeated {tool_name} result: this exact output has already been "
|
|
||||||
"returned in this turn. Use the existing evidence, or change the tool input "
|
|
||||||
"if you need new information.]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Workspace-boundary violations are soft errors, with per-target throttling.
|
# Workspace-boundary violations are soft errors, with per-target throttling.
|
||||||
|
|
||||||
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
|
_OUTSIDE_PATH_PATTERN = re.compile(r"(?:^|[\s|>'\"])((?:/[^\s\"'>;|<]+)|(?:~[^\s\"'>;|<]+))")
|
||||||
|
|||||||
@@ -35,15 +35,10 @@ def format_tool_hints(tool_calls: list, max_length: int = 40) -> str:
|
|||||||
|
|
||||||
formatted = []
|
formatted = []
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
name = getattr(tc, "name", None)
|
fmt = _TOOL_FORMATS.get(tc.name)
|
||||||
if not isinstance(name, str) or not name:
|
|
||||||
# Degenerate/malformed tool call (e.g. a model emits name=None);
|
|
||||||
# skip it instead of raising AttributeError on the whole turn.
|
|
||||||
continue
|
|
||||||
fmt = _TOOL_FORMATS.get(name)
|
|
||||||
if fmt:
|
if fmt:
|
||||||
formatted.append(_fmt_known(tc, fmt, max_length))
|
formatted.append(_fmt_known(tc, fmt, max_length))
|
||||||
elif name.startswith("mcp_"):
|
elif tc.name.startswith("mcp_"):
|
||||||
formatted.append(_fmt_mcp(tc, max_length))
|
formatted.append(_fmt_mcp(tc, max_length))
|
||||||
else:
|
else:
|
||||||
formatted.append(_fmt_fallback(tc, max_length))
|
formatted.append(_fmt_fallback(tc, max_length))
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ class GatewayServices:
|
|||||||
workspaces: WebUIWorkspaceController
|
workspaces: WebUIWorkspaceController
|
||||||
session_manager: Any | None
|
session_manager: Any | None
|
||||||
cron_service: Any | None
|
cron_service: Any | None
|
||||||
local_trigger_store: Any | None
|
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None
|
cron_pending_job_ids: Callable[[str], set[str]] | None
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
|
||||||
|
|
||||||
|
|
||||||
def build_gateway_services(
|
def build_gateway_services(
|
||||||
@@ -44,9 +42,7 @@ def build_gateway_services(
|
|||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: Any | None = None,
|
cron_service: Any | None = None,
|
||||||
local_trigger_store: Any | None = None,
|
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
@@ -74,9 +70,7 @@ def build_gateway_services(
|
|||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
local_trigger_store=local_trigger_store,
|
|
||||||
cron_pending_job_ids=cron_pending_job_ids,
|
cron_pending_job_ids=cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
@@ -87,7 +81,5 @@ def build_gateway_services(
|
|||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
local_trigger_store=local_trigger_store,
|
|
||||||
cron_pending_job_ids=cron_pending_job_ids,
|
cron_pending_job_ids=cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ from __future__ import annotations
|
|||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.session.automation_turns import is_automation_history_message
|
|
||||||
from nanobot.session.manager import _message_preview_text
|
from nanobot.session.manager import _message_preview_text
|
||||||
from nanobot.triggers.local_types import LocalTrigger
|
|
||||||
|
|
||||||
AutomationJob = CronJob | LocalTrigger
|
|
||||||
|
|
||||||
|
|
||||||
class _CronServiceLike(Protocol):
|
class _CronServiceLike(Protocol):
|
||||||
@@ -24,17 +21,6 @@ class _CronServiceLike(Protocol):
|
|||||||
) -> list[CronJob]: ...
|
) -> list[CronJob]: ...
|
||||||
|
|
||||||
|
|
||||||
class _LocalTriggerStoreLike(Protocol):
|
|
||||||
def list_triggers(self, *, include_disabled: bool = False) -> list[LocalTrigger]: ...
|
|
||||||
|
|
||||||
def list_for_session(
|
|
||||||
self,
|
|
||||||
session_key: str,
|
|
||||||
*,
|
|
||||||
include_disabled: bool = True,
|
|
||||||
) -> list[LocalTrigger]: ...
|
|
||||||
|
|
||||||
|
|
||||||
class _SessionManagerLike(Protocol):
|
class _SessionManagerLike(Protocol):
|
||||||
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
|
def read_session_file(self, key: str) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
@@ -42,43 +28,26 @@ class _SessionManagerLike(Protocol):
|
|||||||
def session_automation_jobs(
|
def session_automation_jobs(
|
||||||
cron_service: _CronServiceLike | None,
|
cron_service: _CronServiceLike | None,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
) -> list[CronJob]:
|
||||||
local_trigger_store: _LocalTriggerStoreLike | None = None,
|
|
||||||
) -> list[AutomationJob]:
|
|
||||||
"""Return user automations attached to the WebUI session."""
|
"""Return user automations attached to the WebUI session."""
|
||||||
jobs: list[AutomationJob] = []
|
if cron_service is None:
|
||||||
if cron_service is not None:
|
return []
|
||||||
jobs.extend(
|
return cron_service.list_bound_cron_jobs_for_session(
|
||||||
cron_service.list_bound_cron_jobs_for_session(
|
|
||||||
session_key,
|
session_key,
|
||||||
include_disabled=True,
|
include_disabled=True,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
if local_trigger_store is not None:
|
|
||||||
jobs.extend(
|
|
||||||
local_trigger_store.list_for_session(
|
|
||||||
session_key,
|
|
||||||
include_disabled=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return jobs
|
|
||||||
|
|
||||||
|
|
||||||
def session_automations_payload(
|
def session_automations_payload(
|
||||||
cron_service: _CronServiceLike | None,
|
cron_service: _CronServiceLike | None,
|
||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
*,
|
||||||
local_trigger_store: _LocalTriggerStoreLike | None = None,
|
|
||||||
pending_job_ids: Collection[str] | None = None,
|
pending_job_ids: Collection[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return user-created automation jobs attached to a WebUI session."""
|
"""Return user-created automation jobs attached to a WebUI session."""
|
||||||
return {
|
return {
|
||||||
"jobs": serialize_automation_jobs(
|
"jobs": serialize_automation_jobs(
|
||||||
session_automation_jobs(
|
session_automation_jobs(cron_service, session_key),
|
||||||
cron_service,
|
|
||||||
session_key,
|
|
||||||
local_trigger_store=local_trigger_store,
|
|
||||||
),
|
|
||||||
pending_job_ids=pending_job_ids,
|
pending_job_ids=pending_job_ids,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,16 +56,11 @@ def session_automations_payload(
|
|||||||
def all_automations_payload(
|
def all_automations_payload(
|
||||||
cron_service: _CronServiceLike | None,
|
cron_service: _CronServiceLike | None,
|
||||||
*,
|
*,
|
||||||
local_trigger_store: _LocalTriggerStoreLike | None = None,
|
|
||||||
session_manager: _SessionManagerLike | None = None,
|
session_manager: _SessionManagerLike | None = None,
|
||||||
pending_job_ids: Collection[str] | None = None,
|
pending_job_ids: Collection[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return all cron jobs visible to the WebUI automation manager."""
|
"""Return all cron jobs visible to the WebUI automation manager."""
|
||||||
jobs: list[AutomationJob] = []
|
jobs = cron_service.list_jobs(include_disabled=True) if cron_service is not None else []
|
||||||
if cron_service is not None:
|
|
||||||
jobs.extend(cron_service.list_jobs(include_disabled=True))
|
|
||||||
if local_trigger_store is not None:
|
|
||||||
jobs.extend(local_trigger_store.list_triggers(include_disabled=True))
|
|
||||||
return {
|
return {
|
||||||
"jobs": serialize_automation_jobs(
|
"jobs": serialize_automation_jobs(
|
||||||
jobs,
|
jobs,
|
||||||
@@ -108,7 +72,7 @@ def all_automations_payload(
|
|||||||
|
|
||||||
|
|
||||||
def serialize_automation_jobs(
|
def serialize_automation_jobs(
|
||||||
jobs: list[AutomationJob],
|
jobs: list[CronJob],
|
||||||
*,
|
*,
|
||||||
pending_job_ids: Collection[str] | None = None,
|
pending_job_ids: Collection[str] | None = None,
|
||||||
include_details: bool = False,
|
include_details: bool = False,
|
||||||
@@ -126,20 +90,12 @@ def serialize_automation_jobs(
|
|||||||
|
|
||||||
|
|
||||||
def _serialize_job(
|
def _serialize_job(
|
||||||
job: AutomationJob,
|
job: CronJob,
|
||||||
*,
|
*,
|
||||||
pending: bool = False,
|
pending: bool = False,
|
||||||
include_details: bool = False,
|
include_details: bool = False,
|
||||||
session_manager: _SessionManagerLike | None = None,
|
session_manager: _SessionManagerLike | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if isinstance(job, LocalTrigger):
|
|
||||||
return _serialize_trigger(
|
|
||||||
job,
|
|
||||||
pending=pending,
|
|
||||||
include_details=include_details,
|
|
||||||
session_manager=session_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"id": job.id,
|
"id": job.id,
|
||||||
"name": job.name,
|
"name": job.name,
|
||||||
@@ -187,67 +143,6 @@ def _serialize_job(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _serialize_trigger(
|
|
||||||
trigger: LocalTrigger,
|
|
||||||
*,
|
|
||||||
pending: bool = False,
|
|
||||||
include_details: bool = False,
|
|
||||||
session_manager: _SessionManagerLike | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
command = f'nanobot trigger {trigger.id} "message"'
|
|
||||||
payload = {
|
|
||||||
"id": trigger.id,
|
|
||||||
"name": trigger.name,
|
|
||||||
"enabled": trigger.enabled,
|
|
||||||
"kind": "local_trigger",
|
|
||||||
"schedule": {
|
|
||||||
"kind": "local",
|
|
||||||
"at_ms": None,
|
|
||||||
"every_ms": None,
|
|
||||||
"expr": None,
|
|
||||||
"tz": None,
|
|
||||||
},
|
|
||||||
"payload": {
|
|
||||||
"kind": "local_trigger",
|
|
||||||
"message": command,
|
|
||||||
"command": command,
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"next_run_at_ms": None,
|
|
||||||
"last_status": trigger.last_status,
|
|
||||||
"pending": pending,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if not include_details:
|
|
||||||
return payload
|
|
||||||
|
|
||||||
payload["protected"] = False
|
|
||||||
payload["delete_after_run"] = False
|
|
||||||
payload["created_at_ms"] = trigger.created_at_ms
|
|
||||||
payload["updated_at_ms"] = trigger.updated_at_ms
|
|
||||||
payload["state"].update(
|
|
||||||
{
|
|
||||||
"last_run_at_ms": trigger.last_run_at_ms,
|
|
||||||
"last_error": trigger.last_error,
|
|
||||||
"run_history": [
|
|
||||||
{
|
|
||||||
"run_at_ms": record.run_at_ms,
|
|
||||||
"status": record.status,
|
|
||||||
"duration_ms": 0,
|
|
||||||
"error": record.error,
|
|
||||||
}
|
|
||||||
for record in trigger.run_history[-5:]
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
payload["origin"] = _trigger_origin_payload(trigger, session_manager)
|
|
||||||
payload["trigger"] = {
|
|
||||||
"id": trigger.id,
|
|
||||||
"command": command,
|
|
||||||
}
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def _origin_payload(
|
def _origin_payload(
|
||||||
job: CronJob,
|
job: CronJob,
|
||||||
session_manager: _SessionManagerLike | None,
|
session_manager: _SessionManagerLike | None,
|
||||||
@@ -266,46 +161,6 @@ def _origin_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
session_key = f"{channel}:{chat_id}"
|
session_key = f"{channel}:{chat_id}"
|
||||||
return _websocket_origin_payload(
|
|
||||||
session_key=session_key,
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
session_manager=session_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _trigger_origin_payload(
|
|
||||||
trigger: LocalTrigger,
|
|
||||||
session_manager: _SessionManagerLike | None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
channel = trigger.channel
|
|
||||||
chat_id = trigger.chat_id
|
|
||||||
if not channel or not chat_id:
|
|
||||||
return None
|
|
||||||
if channel != "websocket":
|
|
||||||
return {
|
|
||||||
"channel": channel,
|
|
||||||
"title": "",
|
|
||||||
"preview": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
return _websocket_origin_payload(
|
|
||||||
session_key=trigger.session_key or f"{channel}:{chat_id}",
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
session_manager=session_manager,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _websocket_origin_payload(
|
|
||||||
*,
|
|
||||||
session_key: str,
|
|
||||||
channel: str,
|
|
||||||
chat_id: str,
|
|
||||||
session_manager: _SessionManagerLike | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
title = ""
|
|
||||||
preview = ""
|
|
||||||
if session_manager is not None:
|
if session_manager is not None:
|
||||||
data = session_manager.read_session_file(session_key)
|
data = session_manager.read_session_file(session_key)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
@@ -328,7 +183,7 @@ def _session_preview(messages: Any) -> str:
|
|||||||
for message in messages:
|
for message in messages:
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
continue
|
||||||
if is_automation_history_message(message):
|
if message.get(CRON_HISTORY_META) is True:
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(message)
|
text = _message_preview_text(message)
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
from nanobot.config.paths import get_webui_dir
|
||||||
from nanobot.session.automation_turns import is_automation_history_message
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.session.manager import (
|
from nanobot.session.manager import (
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS,
|
_SESSION_LIST_PREVIEW_MAX_RECORDS,
|
||||||
@@ -26,11 +26,10 @@ from nanobot.session.manager import (
|
|||||||
_metadata_title,
|
_metadata_title,
|
||||||
)
|
)
|
||||||
|
|
||||||
_INDEX_VERSION = 2
|
_INDEX_VERSION = 1
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
|
||||||
|
|
||||||
|
|
||||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||||
@@ -154,7 +153,7 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
|||||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
if is_automation_history_message(item):
|
if item.get(CRON_HISTORY_META) is True:
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
@@ -215,45 +214,14 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
|||||||
return stored
|
return stored
|
||||||
|
|
||||||
|
|
||||||
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
|
||||||
if is_automation_history_message(item):
|
|
||||||
return None
|
|
||||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
|
||||||
return None
|
|
||||||
timestamp = item.get("timestamp")
|
|
||||||
return timestamp if isinstance(timestamp, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _last_visible_message_at(messages: list[dict[str, Any]]) -> str | None:
|
|
||||||
latest: str | None = None
|
|
||||||
for item in messages:
|
|
||||||
timestamp = _visible_message_timestamp(item)
|
|
||||||
if timestamp is not None:
|
|
||||||
latest = _latest_updated_at(latest, timestamp)
|
|
||||||
return latest
|
|
||||||
|
|
||||||
|
|
||||||
def _visible_activity_updated_at(
|
|
||||||
stored: str | None,
|
|
||||||
visible_message_at: str | None,
|
|
||||||
webui_activity: str | None,
|
|
||||||
) -> str | None:
|
|
||||||
return _latest_updated_at(visible_message_at, webui_activity) or stored
|
|
||||||
|
|
||||||
|
|
||||||
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
activity_signature = _webui_activity_signature(session.key)
|
activity_signature = _webui_activity_signature(session.key)
|
||||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||||
visible_message_at = _last_visible_message_at(session.messages)
|
|
||||||
return {
|
return {
|
||||||
"key": session.key,
|
"key": session.key,
|
||||||
"created_at": session.created_at.isoformat(),
|
"created_at": session.created_at.isoformat(),
|
||||||
"updated_at": _visible_activity_updated_at(
|
"updated_at": _latest_updated_at(session.updated_at.isoformat(), activity_updated_at),
|
||||||
session.updated_at.isoformat(),
|
|
||||||
visible_message_at,
|
|
||||||
activity_updated_at,
|
|
||||||
),
|
|
||||||
"title": _metadata_title(session.metadata),
|
"title": _metadata_title(session.metadata),
|
||||||
"preview": _preview_from_messages(session.messages),
|
"preview": _preview_from_messages(session.messages),
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
@@ -264,8 +232,7 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||||
storage_key = SessionManager._decode_storage_key(path.stem)
|
fallback_key = path.stem.replace("_", ":", 1)
|
||||||
fallback_key = storage_key or path.stem.replace("_", ":", 1)
|
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
@@ -276,37 +243,29 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
return None
|
return None
|
||||||
preview = ""
|
preview = ""
|
||||||
fallback_preview = ""
|
fallback_preview = ""
|
||||||
visible_message_at = None
|
|
||||||
preview_done = False
|
|
||||||
scanned_records = 0
|
scanned_records = 0
|
||||||
scanned_chars = 0
|
scanned_chars = 0
|
||||||
for line in f:
|
for line in f:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
item = json.loads(line)
|
|
||||||
timestamp = _visible_message_timestamp(item)
|
|
||||||
if timestamp is not None:
|
|
||||||
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
|
||||||
if not preview_done:
|
|
||||||
scanned_records += 1
|
scanned_records += 1
|
||||||
scanned_chars += len(line)
|
scanned_chars += len(line)
|
||||||
if (
|
if (
|
||||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
):
|
):
|
||||||
preview_done = True
|
break
|
||||||
continue
|
item = json.loads(line)
|
||||||
if item.get("_type") == "metadata":
|
if item.get("_type") == "metadata":
|
||||||
continue
|
continue
|
||||||
if is_automation_history_message(item):
|
if item.get(CRON_HISTORY_META) is True:
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
if item.get("role") == "user":
|
if item.get("role") == "user":
|
||||||
preview = text
|
preview = text
|
||||||
preview_done = True
|
break
|
||||||
continue
|
|
||||||
if not fallback_preview and item.get("role") == "assistant":
|
if not fallback_preview and item.get("role") == "assistant":
|
||||||
fallback_preview = text
|
fallback_preview = text
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
@@ -322,11 +281,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
return {
|
return {
|
||||||
"key": key,
|
"key": key,
|
||||||
"created_at": created_at_s,
|
"created_at": created_at_s,
|
||||||
"updated_at": _visible_activity_updated_at(
|
"updated_at": _latest_updated_at(updated_at_s, activity_updated_at),
|
||||||
updated_at_s,
|
|
||||||
visible_message_at,
|
|
||||||
activity_updated_at,
|
|
||||||
),
|
|
||||||
"title": _metadata_title(data.get("metadata", {})),
|
"title": _metadata_title(data.get("metadata", {})),
|
||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from nanobot.audio.transcription_registry import (
|
|||||||
resolve_transcription_provider,
|
resolve_transcription_provider,
|
||||||
transcription_provider_names,
|
transcription_provider_names,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
@@ -99,6 +99,47 @@ _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144}
|
|||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
_MODEL_LIST_UNSUPPORTED_BACKENDS = {
|
||||||
|
"anthropic",
|
||||||
|
"azure_openai",
|
||||||
|
"bedrock",
|
||||||
|
"github_copilot",
|
||||||
|
"openai_codex",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODEL_LIST_CATALOG_PROVIDERS = {
|
||||||
|
"aihubmix",
|
||||||
|
"byteplus",
|
||||||
|
"byteplus_coding_plan",
|
||||||
|
"huggingface",
|
||||||
|
"novita",
|
||||||
|
"openrouter",
|
||||||
|
"siliconflow",
|
||||||
|
"volcengine",
|
||||||
|
"volcengine_coding_plan",
|
||||||
|
}
|
||||||
|
|
||||||
|
_MODEL_LIST_OFFICIAL_PROVIDERS = {
|
||||||
|
"ant_ling",
|
||||||
|
"dashscope",
|
||||||
|
"deepseek",
|
||||||
|
"gemini",
|
||||||
|
"groq",
|
||||||
|
"longcat",
|
||||||
|
"minimax",
|
||||||
|
"minimax_anthropic",
|
||||||
|
"mistral",
|
||||||
|
"moonshot",
|
||||||
|
"nvidia",
|
||||||
|
"openai",
|
||||||
|
"qianfan",
|
||||||
|
"skywork",
|
||||||
|
"stepfun",
|
||||||
|
"xiaomi_mimo",
|
||||||
|
"zhipu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsError(ValueError):
|
class WebUISettingsError(ValueError):
|
||||||
"""User-facing settings validation failure."""
|
"""User-facing settings validation failure."""
|
||||||
|
|
||||||
@@ -353,13 +394,10 @@ def _provider_settings_row(
|
|||||||
|
|
||||||
|
|
||||||
def _model_catalog_kind(spec: Any) -> str:
|
def _model_catalog_kind(spec: Any) -> str:
|
||||||
catalog = getattr(spec, "model_catalog", "auto")
|
if spec.name in _MODEL_LIST_CATALOG_PROVIDERS:
|
||||||
if catalog != "auto":
|
return "catalog"
|
||||||
return catalog
|
if spec.name in _MODEL_LIST_OFFICIAL_PROVIDERS:
|
||||||
if spec.is_transcription_only or spec.is_oauth:
|
return "official"
|
||||||
return "unsupported"
|
|
||||||
if spec.backend != "openai_compat" and spec.name != "minimax_anthropic":
|
|
||||||
return "unsupported"
|
|
||||||
if spec.is_local:
|
if spec.is_local:
|
||||||
return "local"
|
return "local"
|
||||||
if spec.is_direct:
|
if spec.is_direct:
|
||||||
@@ -452,20 +490,27 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
spec, provider_key, provider_config = resolved_provider
|
spec, provider_key, provider_config = resolved_provider
|
||||||
|
|
||||||
catalog_kind = _model_catalog_kind(spec)
|
|
||||||
base_payload: dict[str, Any] = {
|
base_payload: dict[str, Any] = {
|
||||||
"provider": provider_key,
|
"provider": provider_key,
|
||||||
"label": spec.label,
|
"label": spec.label,
|
||||||
"catalog_kind": catalog_kind,
|
"catalog_kind": _model_catalog_kind(spec),
|
||||||
"models": [],
|
"models": [],
|
||||||
"model_count": 0,
|
"model_count": 0,
|
||||||
"message": None,
|
"message": None,
|
||||||
"fetched_at": time.time(),
|
"fetched_at": time.time(),
|
||||||
}
|
}
|
||||||
if catalog_kind == "unsupported":
|
if (
|
||||||
|
spec.is_transcription_only
|
||||||
|
or (
|
||||||
|
spec.backend in _MODEL_LIST_UNSUPPORTED_BACKENDS
|
||||||
|
and spec.name != "minimax_anthropic"
|
||||||
|
)
|
||||||
|
or spec.is_oauth
|
||||||
|
):
|
||||||
return {
|
return {
|
||||||
**base_payload,
|
**base_payload,
|
||||||
"status": "unsupported",
|
"status": "unsupported",
|
||||||
|
"catalog_kind": "unsupported",
|
||||||
"message": "Model list is not available for this provider. Type a model ID manually.",
|
"message": "Model list is not available for this provider. Type a model ID manually.",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,19 +1166,14 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
raise WebUISettingsError("oauth_cli_kit is not installed", status=500) from None
|
||||||
|
|
||||||
try:
|
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
|
||||||
except ValueError as e:
|
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
|
||||||
token = None
|
token = None
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
token = get_token(proxy=proxy)
|
token = get_token()
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
token = login_oauth_interactive(
|
token = login_oauth_interactive(
|
||||||
print_fn=lambda message: messages.append(str(message)),
|
print_fn=lambda message: messages.append(str(message)),
|
||||||
prompt_fn=lambda _prompt: "",
|
prompt_fn=lambda _prompt: "",
|
||||||
proxy=proxy,
|
|
||||||
)
|
)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from urllib.parse import unquote, urlparse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
from nanobot.config.paths import get_webui_dir
|
||||||
from nanobot.session.automation_turns import is_automation_history_message, is_automation_kind
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
||||||
|
|
||||||
@@ -598,12 +598,9 @@ def normalize_webui_turn_id(value: Any) -> str:
|
|||||||
|
|
||||||
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
|
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
|
||||||
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
|
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict) or raw.get("kind") != "cron":
|
||||||
return None
|
return None
|
||||||
kind = raw.get("kind")
|
source: dict[str, str] = {"kind": "cron"}
|
||||||
if not is_automation_kind(kind):
|
|
||||||
return None
|
|
||||||
source: dict[str, str] = {"kind": kind}
|
|
||||||
label = raw.get("label")
|
label = raw.get("label")
|
||||||
if isinstance(label, str) and label.strip():
|
if isinstance(label, str) and label.strip():
|
||||||
source["label"] = label.strip()
|
source["label"] = label.strip()
|
||||||
@@ -782,8 +779,6 @@ def write_session_messages_as_transcript(
|
|||||||
target_chat_id = _chat_id_from_session_key(target_key)
|
target_chat_id = _chat_id_from_session_key(target_key)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if is_automation_history_message(msg):
|
|
||||||
continue
|
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
text = content if isinstance(content, str) else ""
|
text = content if isinstance(content, str) else ""
|
||||||
@@ -860,7 +855,7 @@ def _session_user_event(
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if message.get("role") != "user":
|
if message.get("role") != "user":
|
||||||
return None
|
return None
|
||||||
if is_automation_history_message(message):
|
if message.get(CRON_HISTORY_META) is True:
|
||||||
return None
|
return None
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
text = content if isinstance(content, str) else ""
|
text = content if isinstance(content, str) else ""
|
||||||
@@ -1276,12 +1271,9 @@ def replay_transcript_to_ui_messages(
|
|||||||
|
|
||||||
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
|
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
|
||||||
source = rec.get("source")
|
source = rec.get("source")
|
||||||
if not isinstance(source, dict):
|
if not isinstance(source, dict) or source.get("kind") != "cron":
|
||||||
return {}
|
return {}
|
||||||
kind = source.get("kind")
|
out: dict[str, Any] = {"source": {"kind": "cron"}}
|
||||||
if not is_automation_kind(kind):
|
|
||||||
return {}
|
|
||||||
out: dict[str, Any] = {"source": {"kind": kind}}
|
|
||||||
label = source.get("label")
|
label = source.get("label")
|
||||||
if isinstance(label, str) and label.strip():
|
if isinstance(label, str) and label.strip():
|
||||||
out["source"]["label"] = label.strip()
|
out["source"]["label"] = label.strip()
|
||||||
|
|||||||
+8
-100
@@ -26,7 +26,6 @@ from websockets.http11 import Response
|
|||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
from nanobot.triggers.local_types import LocalTrigger
|
|
||||||
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
from nanobot.webui.file_preview import WebUIFilePreviewError, file_preview_payload
|
||||||
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
|
from nanobot.webui.gateway_tokens import GatewayTokenStore, token_response_payload
|
||||||
@@ -90,7 +89,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
@@ -155,9 +153,7 @@ class GatewayHTTPHandler:
|
|||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
local_trigger_store: LocalTriggerStore | None = None,
|
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -171,9 +167,7 @@ class GatewayHTTPHandler:
|
|||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills = disabled_skills or set()
|
self.disabled_skills = disabled_skills or set()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
|
||||||
self.cron_pending_job_ids = cron_pending_job_ids
|
self.cron_pending_job_ids = cron_pending_job_ids
|
||||||
self.local_trigger_pending_ids = local_trigger_pending_ids
|
|
||||||
self._log = log
|
self._log = log
|
||||||
self._runtime_surface = runtime_surface
|
self._runtime_surface = runtime_surface
|
||||||
|
|
||||||
@@ -489,12 +483,13 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
pending_job_ids = self._pending_automation_ids_for_session(decoded_key)
|
pending_job_ids: set[str] = set()
|
||||||
|
if self.cron_pending_job_ids is not None:
|
||||||
|
pending_job_ids = self.cron_pending_job_ids(decoded_key)
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
session_automations_payload(
|
session_automations_payload(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
decoded_key,
|
decoded_key,
|
||||||
local_trigger_store=self.local_trigger_store,
|
|
||||||
pending_job_ids=pending_job_ids,
|
pending_job_ids=pending_job_ids,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -511,11 +506,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
query = _parse_query(request.path)
|
query = _parse_query(request.path)
|
||||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||||
automation_jobs = session_automation_jobs(
|
automation_jobs = session_automation_jobs(self.cron_service, decoded_key)
|
||||||
self.cron_service,
|
|
||||||
decoded_key,
|
|
||||||
local_trigger_store=self.local_trigger_store,
|
|
||||||
)
|
|
||||||
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
|
if automation_jobs and delete_automations not in {"1", "true", "yes"}:
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
{
|
{
|
||||||
@@ -524,12 +515,8 @@ class GatewayHTTPHandler:
|
|||||||
"automations": serialize_automation_jobs(automation_jobs),
|
"automations": serialize_automation_jobs(automation_jobs),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if automation_jobs:
|
if automation_jobs and self.cron_service is not None:
|
||||||
for job in automation_jobs:
|
for job in automation_jobs:
|
||||||
if isinstance(job, LocalTrigger):
|
|
||||||
if self.local_trigger_store is not None:
|
|
||||||
self.local_trigger_store.delete(job.id)
|
|
||||||
elif self.cron_service is not None:
|
|
||||||
self.cron_service.remove_job(job.id)
|
self.cron_service.remove_job(job.id)
|
||||||
deleted = self.session_manager.delete_session(decoded_key)
|
deleted = self.session_manager.delete_session(decoded_key)
|
||||||
delete_webui_thread(decoded_key)
|
delete_webui_thread(decoded_key)
|
||||||
@@ -561,37 +548,14 @@ class GatewayHTTPHandler:
|
|||||||
pending.update(self.cron_pending_job_ids(session_key))
|
pending.update(self.cron_pending_job_ids(session_key))
|
||||||
return pending
|
return pending
|
||||||
|
|
||||||
def _pending_local_trigger_ids_for_all(self) -> set[str]:
|
|
||||||
if self.local_trigger_store is None or self.local_trigger_pending_ids is None:
|
|
||||||
return set()
|
|
||||||
pending: set[str] = set()
|
|
||||||
for trigger in self.local_trigger_store.list_triggers(include_disabled=True):
|
|
||||||
session_key = trigger.session_key
|
|
||||||
if not session_key and trigger.channel and trigger.chat_id:
|
|
||||||
session_key = f"{trigger.channel}:{trigger.chat_id}"
|
|
||||||
if session_key:
|
|
||||||
pending.update(self.local_trigger_pending_ids(session_key))
|
|
||||||
return pending
|
|
||||||
|
|
||||||
def _pending_automation_ids_for_session(self, session_key: str) -> set[str]:
|
|
||||||
pending: set[str] = set()
|
|
||||||
if self.cron_pending_job_ids is not None:
|
|
||||||
pending.update(self.cron_pending_job_ids(session_key))
|
|
||||||
if self.local_trigger_pending_ids is not None:
|
|
||||||
pending.update(self.local_trigger_pending_ids(session_key))
|
|
||||||
return pending
|
|
||||||
|
|
||||||
def _handle_webui_automations(self, request: WsRequest) -> Response:
|
def _handle_webui_automations(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
pending_job_ids = self._pending_cron_job_ids_for_all()
|
|
||||||
pending_job_ids.update(self._pending_local_trigger_ids_for_all())
|
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
all_automations_payload(
|
all_automations_payload(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
local_trigger_store=self.local_trigger_store,
|
|
||||||
session_manager=self.session_manager,
|
session_manager=self.session_manager,
|
||||||
pending_job_ids=pending_job_ids,
|
pending_job_ids=self._pending_cron_job_ids_for_all(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -602,19 +566,13 @@ class GatewayHTTPHandler:
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if self.cron_service is None and self.local_trigger_store is None:
|
if self.cron_service is None:
|
||||||
return _http_error(503, "automation service unavailable")
|
return _http_error(503, "cron service unavailable")
|
||||||
|
|
||||||
query = _parse_query(request.path)
|
query = _parse_query(request.path)
|
||||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return _http_error(400, "missing automation id")
|
return _http_error(400, "missing automation id")
|
||||||
trigger = self.local_trigger_store.get(job_id) if self.local_trigger_store else None
|
|
||||||
if trigger is not None:
|
|
||||||
return self._handle_local_trigger_action(request, action, trigger)
|
|
||||||
|
|
||||||
if self.cron_service is None:
|
|
||||||
return _http_error(404, "automation not found")
|
|
||||||
job = self.cron_service.get_job(job_id)
|
job = self.cron_service.get_job(job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
return _http_error(404, "automation not found")
|
return _http_error(404, "automation not found")
|
||||||
@@ -660,40 +618,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
return self._handle_webui_automations(request)
|
return self._handle_webui_automations(request)
|
||||||
|
|
||||||
def _handle_local_trigger_action(
|
|
||||||
self,
|
|
||||||
request: WsRequest,
|
|
||||||
action: str,
|
|
||||||
trigger: LocalTrigger,
|
|
||||||
) -> Response:
|
|
||||||
if self.local_trigger_store is None:
|
|
||||||
return _http_error(503, "trigger service unavailable")
|
|
||||||
if action == "enable":
|
|
||||||
if self.local_trigger_store.enable(trigger.id, enabled=True) is None:
|
|
||||||
return _http_error(404, "automation not found")
|
|
||||||
elif action == "disable":
|
|
||||||
if self.local_trigger_store.enable(trigger.id, enabled=False) is None:
|
|
||||||
return _http_error(404, "automation not found")
|
|
||||||
elif action == "delete":
|
|
||||||
if not self.local_trigger_store.delete(trigger.id):
|
|
||||||
return _http_error(404, "automation not found")
|
|
||||||
elif action == "run":
|
|
||||||
return _http_error(409, "local trigger requires a CLI message")
|
|
||||||
elif action == "update":
|
|
||||||
values = _automation_values_from_request(request)
|
|
||||||
if values is None:
|
|
||||||
return _http_error(400, "invalid automation update payload")
|
|
||||||
parsed = _parse_local_trigger_update(values)
|
|
||||||
if isinstance(parsed, str):
|
|
||||||
return _http_error(400, parsed)
|
|
||||||
if parsed:
|
|
||||||
if self.local_trigger_store.update(trigger.id, **parsed) is None:
|
|
||||||
return _http_error(404, "automation not found")
|
|
||||||
else:
|
|
||||||
return _http_error(404, "unknown automation action")
|
|
||||||
|
|
||||||
return self._handle_webui_automations(request)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
|
def _log_automation_run_result(task: asyncio.Task[bool]) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -906,22 +830,6 @@ def _parse_automation_update(
|
|||||||
return update
|
return update
|
||||||
|
|
||||||
|
|
||||||
def _parse_local_trigger_update(values: dict[str, Any]) -> dict[str, Any] | str:
|
|
||||||
update: dict[str, Any] = {}
|
|
||||||
if "name" in values:
|
|
||||||
raw_name = values.get("name")
|
|
||||||
if not isinstance(raw_name, str):
|
|
||||||
return "name must be a string"
|
|
||||||
name = raw_name.strip()
|
|
||||||
if not name:
|
|
||||||
return "name cannot be empty"
|
|
||||||
update["name"] = name
|
|
||||||
forbidden = [key for key in ("message", "schedule") if key in values]
|
|
||||||
if forbidden:
|
|
||||||
return "local trigger updates only support name"
|
|
||||||
return update
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
|
def _parse_automation_schedule(values: dict[str, Any]) -> CronSchedule | str:
|
||||||
raw_kind = values.get("kind")
|
raw_kind = values.get("kind")
|
||||||
if not isinstance(raw_kind, str):
|
if not isinstance(raw_kind, str):
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ dependencies = [
|
|||||||
"websocket-client>=1.9.0,<2.0.0",
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
"ddgs>=9.5.5,<10.0.0",
|
"ddgs>=9.5.5,<10.0.0",
|
||||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
"oauth-cli-kit>=0.1.3,<1.0.0",
|
||||||
"loguru>=0.7.3,<1.0.0",
|
"loguru>=0.7.3,<1.0.0",
|
||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
|
|||||||
+2
-10
@@ -269,15 +269,7 @@ if [ "${NANOBOT_SKIP_WIZARD:-}" = "1" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -t 0 ]; then
|
info "Starting setup wizard..."
|
||||||
info "Starting setup wizard..."
|
run_nanobot onboard --wizard
|
||||||
run_nanobot onboard --wizard
|
|
||||||
elif : 2>/dev/null < /dev/tty; then
|
|
||||||
info "Starting setup wizard..."
|
|
||||||
run_nanobot onboard --wizard < /dev/tty
|
|
||||||
else
|
|
||||||
info "Skipping setup wizard because no interactive terminal is available."
|
|
||||||
info "Run this later: $(nanobot_try_command) onboard --wizard"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
|
info "Done. Try: $(nanobot_try_command) agent -m \"Hello!\""
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ def make_loop(
|
|||||||
model: str = "test-model",
|
model: str = "test-model",
|
||||||
context_window_tokens: int = 128_000,
|
context_window_tokens: int = 128_000,
|
||||||
session_ttl_minutes: int = 0,
|
session_ttl_minutes: int = 0,
|
||||||
|
max_messages: int = 120,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
mcp_servers: dict | None = None,
|
mcp_servers: dict | None = None,
|
||||||
tools_config=None,
|
tools_config=None,
|
||||||
@@ -63,6 +64,7 @@ def make_loop(
|
|||||||
model=model,
|
model=model,
|
||||||
context_window_tokens=context_window_tokens,
|
context_window_tokens=context_window_tokens,
|
||||||
session_ttl_minutes=session_ttl_minutes,
|
session_ttl_minutes=session_ttl_minutes,
|
||||||
|
max_messages=max_messages,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
)
|
)
|
||||||
if mcp_servers is not None:
|
if mcp_servers is not None:
|
||||||
@@ -77,8 +79,8 @@ def make_loop(
|
|||||||
if patch_deps:
|
if patch_deps:
|
||||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||||
patch("nanobot.agent.loop.SessionManager"), \
|
patch("nanobot.agent.loop.SessionManager"), \
|
||||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||||
return AgentLoop(**kwargs)
|
return AgentLoop(**kwargs)
|
||||||
return AgentLoop(**kwargs)
|
return AgentLoop(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ def _make_fake_compact(
|
|||||||
|
|
||||||
tail = list(session.messages[session.last_consolidated:])
|
tail = list(session.messages[session.last_consolidated:])
|
||||||
if not tail:
|
if not tail:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -102,14 +103,15 @@ def _make_fake_compact(
|
|||||||
metadata={},
|
metadata={},
|
||||||
last_consolidated=0,
|
last_consolidated=0,
|
||||||
)
|
)
|
||||||
result = probe.retain_recent_legal_suffix(
|
dropped, already_consolidated = probe.retain_recent_legal_suffix(
|
||||||
max_suffix,
|
max_suffix,
|
||||||
extend_to_user=True,
|
extend_to_user=True,
|
||||||
)
|
)
|
||||||
kept = probe.messages
|
kept = probe.messages
|
||||||
archive_msgs = result.dropped[result.already_consolidated_count:]
|
archive_msgs = dropped[already_consolidated:]
|
||||||
|
|
||||||
if not archive_msgs and not kept:
|
if not archive_msgs and not kept:
|
||||||
|
session.updated_at = datetime.now()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ def _make_fake_compact(
|
|||||||
|
|
||||||
session.messages = kept
|
session.messages = kept
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
|
session.updated_at = datetime.now()
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -1018,28 +1021,27 @@ class TestProactiveAutoCompact:
|
|||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 1
|
assert _fake_compact.state["count"] == 1
|
||||||
|
|
||||||
# Second tick: should NOT re-schedule because the session has no removable tail.
|
# Second tick: should NOT re-schedule (updated_at is fresh after clear)
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
assert _fake_compact.state["count"] == 1 # Still 1, not re-scheduled
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_session_does_not_schedule_idle_compact(self, tmp_path):
|
async def test_empty_skip_refreshes_updated_at_prevents_reschedule(self, tmp_path):
|
||||||
"""Empty expired sessions have no removable tail and should not schedule."""
|
"""Empty session skip refreshes updated_at, preventing immediate re-scheduling."""
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
_fake_compact = _make_fake_compact(loop)
|
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||||
loop.consolidator.compact_idle_session = _fake_compact
|
|
||||||
|
|
||||||
|
# First tick: skips (no messages), refreshes updated_at
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 0
|
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
|
|
||||||
|
# Second tick: should NOT re-schedule because updated_at is fresh
|
||||||
await self._run_check_expired(loop)
|
await self._run_check_expired(loop)
|
||||||
assert _fake_compact.state["count"] == 0
|
|
||||||
assert "cli:test" not in loop.auto_compact._summaries
|
assert "cli:test" not in loop.auto_compact._summaries
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|||||||
@@ -200,11 +200,8 @@ class TestCheckExpired:
|
|||||||
"""Expired session should trigger schedule_background."""
|
"""Expired session should trigger schedule_background."""
|
||||||
ac = _make_autocompact(ttl=15)
|
ac = _make_autocompact(ttl=15)
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
mock_sm = MagicMock(spec=SessionManager)
|
||||||
old_dt = datetime.now() - timedelta(minutes=20)
|
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||||
session = _make_session("cli:old", updated_at=old_dt)
|
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||||
_add_turns(session, 5)
|
|
||||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_dt.isoformat()}]
|
|
||||||
mock_sm.get_or_create.return_value = session
|
|
||||||
ac.sessions = mock_sm
|
ac.sessions = mock_sm
|
||||||
|
|
||||||
scheduled = []
|
scheduled = []
|
||||||
@@ -276,24 +273,6 @@ class TestCheckExpired:
|
|||||||
scheduler.assert_not_called()
|
scheduler.assert_not_called()
|
||||||
assert "dream:20260602-155256" not in ac._archiving
|
assert "dream:20260602-155256" not in ac._archiving
|
||||||
|
|
||||||
def test_already_trimmed_session_skips(self):
|
|
||||||
"""Expired session with no removable tail should not be re-scheduled."""
|
|
||||||
ac = _make_autocompact(ttl=15)
|
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
|
||||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
|
||||||
session = _make_session("cli:done", updated_at=last_active)
|
|
||||||
_add_turns(session, 2)
|
|
||||||
mock_sm.list_sessions.return_value = [
|
|
||||||
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
|
||||||
]
|
|
||||||
mock_sm.get_or_create.return_value = session
|
|
||||||
ac.sessions = mock_sm
|
|
||||||
|
|
||||||
scheduler = MagicMock()
|
|
||||||
ac.check_expired(scheduler)
|
|
||||||
|
|
||||||
scheduler.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _archive
|
# _archive
|
||||||
|
|||||||
@@ -430,11 +430,9 @@ class TestCompactIdleSession:
|
|||||||
)
|
)
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
session = sessions.get_or_create("cli:test")
|
session = sessions.get_or_create("cli:test")
|
||||||
old_ts = session.updated_at
|
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
session.add_message("user", f"user msg {i}")
|
session.add_message("user", f"user msg {i}")
|
||||||
session.add_message("assistant", f"assistant msg {i}")
|
session.add_message("assistant", f"assistant msg {i}")
|
||||||
session.updated_at = old_ts
|
|
||||||
sessions.save(session)
|
sessions.save(session)
|
||||||
|
|
||||||
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
|
result = await real_consolidator.compact_idle_session("cli:test", max_suffix=8)
|
||||||
@@ -447,7 +445,6 @@ class TestCompactIdleSession:
|
|||||||
assert meta is not None
|
assert meta is not None
|
||||||
assert meta["text"] == "Summary of old conversation."
|
assert meta["text"] == "Summary of old conversation."
|
||||||
assert "last_active" in meta
|
assert "last_active" in meta
|
||||||
assert reloaded.updated_at == old_ts
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
||||||
@@ -521,10 +518,8 @@ class TestCompactIdleSession:
|
|||||||
assert entries[0]["session_key"] == "cli:test"
|
assert entries[0]["session_key"] == "cli:test"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_session_does_not_refresh_timestamp(
|
async def test_empty_session_refreshes_timestamp(self, real_consolidator):
|
||||||
self, real_consolidator
|
"""Empty session with old updated_at → refreshed after call, returns ''."""
|
||||||
):
|
|
||||||
"""Empty session with old updated_at does not look active after compaction."""
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
@@ -537,8 +532,7 @@ class TestCompactIdleSession:
|
|||||||
assert result == ""
|
assert result == ""
|
||||||
|
|
||||||
reloaded = sessions.get_or_create("cli:empty")
|
reloaded = sessions.get_or_create("cli:empty")
|
||||||
assert reloaded.updated_at == old_ts
|
assert reloaded.updated_at > old_ts
|
||||||
assert reloaded.metadata == {}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
|
async def test_nothing_summary_not_stored(self, real_consolidator, mock_provider):
|
||||||
|
|||||||
@@ -24,14 +24,9 @@ class TestDreamSessionKey:
|
|||||||
|
|
||||||
class TestPruneDreamSessions:
|
class TestPruneDreamSessions:
|
||||||
def test_keeps_n_most_recent(self, tmp_path):
|
def test_keeps_n_most_recent(self, tmp_path):
|
||||||
import os
|
|
||||||
import time
|
|
||||||
|
|
||||||
sessions_dir = tmp_path / "sessions"
|
sessions_dir = tmp_path / "sessions"
|
||||||
sessions_dir.mkdir()
|
sessions_dir.mkdir()
|
||||||
|
|
||||||
base_time = time.time() - 100
|
|
||||||
|
|
||||||
for i in range(15):
|
for i in range(15):
|
||||||
key = f"dream:20260528-{100000 + i:06d}"
|
key = f"dream:20260528-{100000 + i:06d}"
|
||||||
safe_key = key.replace(":", "_")
|
safe_key = key.replace(":", "_")
|
||||||
@@ -42,7 +37,6 @@ class TestPruneDreamSessions:
|
|||||||
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
|
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
os.utime(path, (base_time + i, base_time + i))
|
|
||||||
|
|
||||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from unittest.mock import patch
|
|||||||
from nanobot.providers.base import ToolCallRequest
|
from nanobot.providers.base import ToolCallRequest
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
|
||||||
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
GEMINI_EXTRA = {"google": {"thought_signature": "sig-abc-123"}}
|
||||||
|
|
||||||
|
|
||||||
@@ -124,47 +125,6 @@ def test_parse_dict_preserves_extra_content() -> None:
|
|||||||
assert payload["extra_content"] == GEMINI_EXTRA
|
assert payload["extra_content"] == GEMINI_EXTRA
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dict_deduplicates_duplicate_tool_call_ids() -> None:
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
|
|
||||||
provider = OpenAICompatProvider()
|
|
||||||
|
|
||||||
response_dict = {
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_same",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
"finish_reason": "tool_calls",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"message": {
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [{
|
|
||||||
"id": "call_same",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
"finish_reason": "tool_calls",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
result = provider._parse(response_dict)
|
|
||||||
|
|
||||||
ids = [tc.id for tc in result.tool_calls]
|
|
||||||
assert len(ids) == 2
|
|
||||||
assert ids[0] == "call_same"
|
|
||||||
assert ids[1] != "call_same"
|
|
||||||
assert len(set(ids)) == 2
|
|
||||||
assert [tc.arguments for tc in result.tool_calls] == [{"path": "a.txt"}, {"path": "b.txt"}]
|
|
||||||
|
|
||||||
|
|
||||||
# ── _parse_chunks: streaming round-trip ───────────────────────────────
|
# ── _parse_chunks: streaming round-trip ───────────────────────────────
|
||||||
|
|
||||||
def test_parse_chunks_sdk_preserves_extra_content() -> None:
|
def test_parse_chunks_sdk_preserves_extra_content() -> None:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import GoalStatusEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||||
@@ -55,13 +54,13 @@ async def test_process_direct_websocket_clears_run_status(tmp_path) -> None:
|
|||||||
events.append(await loop.bus.consume_outbound())
|
events.append(await loop.bus.consume_outbound())
|
||||||
|
|
||||||
statuses = [
|
statuses = [
|
||||||
event.event
|
event.metadata
|
||||||
for event in events
|
for event in events
|
||||||
if isinstance(event.event, GoalStatusEvent)
|
if event.metadata.get("_goal_status") is True
|
||||||
]
|
]
|
||||||
assert [status.status for status in statuses] == ["running", "idle"]
|
assert [status["goal_status"] for status in statuses] == ["running", "idle"]
|
||||||
assert isinstance(statuses[0].started_at, float)
|
assert isinstance(statuses[0].get("started_at"), float)
|
||||||
assert statuses[1].started_at is None
|
assert "started_at" not in statuses[1]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -9,15 +9,6 @@ import pytest
|
|||||||
import nanobot.agent.runner as runner_module
|
import nanobot.agent.runner as runner_module
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
GoalStatusEvent,
|
|
||||||
ProgressEvent,
|
|
||||||
SessionUpdatedEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
TurnEndEvent,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
from nanobot.session.webui_turns import WebuiTurnCoordinator
|
||||||
@@ -269,45 +260,25 @@ class TestToolEventProgress:
|
|||||||
)
|
)
|
||||||
await loop._dispatch(msg)
|
await loop._dispatch(msg)
|
||||||
|
|
||||||
# Drain all outbound messages and find the one carrying tool events.
|
# Drain all outbound messages and find the one carrying _tool_events
|
||||||
outbound = []
|
outbound = []
|
||||||
while bus.outbound_size > 0:
|
while bus.outbound_size > 0:
|
||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
tool_event_msgs = [
|
tool_event_msgs = [m for m in outbound if m.metadata and m.metadata.get("_tool_events")]
|
||||||
m
|
assert tool_event_msgs, "expected at least one outbound message with _tool_events"
|
||||||
for m in outbound
|
|
||||||
if isinstance(m.event, ProgressEvent) and m.event.tool_events
|
|
||||||
]
|
|
||||||
assert tool_event_msgs, "expected at least one outbound message with tool events"
|
|
||||||
|
|
||||||
start_msgs = [
|
start_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] == "start"]
|
||||||
m
|
finish_msgs = [m for m in tool_event_msgs if m.metadata["_tool_events"][0]["phase"] in ("end", "error")]
|
||||||
for m in tool_event_msgs
|
|
||||||
if isinstance(m.event, ProgressEvent)
|
|
||||||
and m.event.tool_events
|
|
||||||
and m.event.tool_events[0]["phase"] == "start"
|
|
||||||
]
|
|
||||||
finish_msgs = [
|
|
||||||
m
|
|
||||||
for m in tool_event_msgs
|
|
||||||
if isinstance(m.event, ProgressEvent)
|
|
||||||
and m.event.tool_events
|
|
||||||
and m.event.tool_events[0]["phase"] in ("end", "error")
|
|
||||||
]
|
|
||||||
assert start_msgs, "expected a start-phase tool event"
|
assert start_msgs, "expected a start-phase tool event"
|
||||||
assert finish_msgs, "expected a finish-phase tool event"
|
assert finish_msgs, "expected a finish-phase tool event"
|
||||||
|
|
||||||
assert isinstance(start_msgs[0].event, ProgressEvent)
|
start = start_msgs[0].metadata["_tool_events"][0]
|
||||||
assert start_msgs[0].event.tool_events is not None
|
|
||||||
start = start_msgs[0].event.tool_events[0]
|
|
||||||
assert start["name"] == "exec"
|
assert start["name"] == "exec"
|
||||||
assert start["call_id"] == "tc1"
|
assert start["call_id"] == "tc1"
|
||||||
assert start["result"] is None
|
assert start["result"] is None
|
||||||
|
|
||||||
assert isinstance(finish_msgs[0].event, ProgressEvent)
|
finish = finish_msgs[0].metadata["_tool_events"][0]
|
||||||
assert finish_msgs[0].event.tool_events is not None
|
|
||||||
finish = finish_msgs[0].event.tool_events[0]
|
|
||||||
assert finish["phase"] == "end"
|
assert finish["phase"] == "end"
|
||||||
assert finish["result"] == "file.txt"
|
assert finish["result"] == "file.txt"
|
||||||
|
|
||||||
@@ -338,8 +309,7 @@ class TestToolEventProgress:
|
|||||||
await invoke_file_edit_progress(progress, edit_events)
|
await invoke_file_edit_progress(progress, edit_events)
|
||||||
outbound = await bus.consume_outbound()
|
outbound = await bus.consume_outbound()
|
||||||
assert outbound.channel == "telegram"
|
assert outbound.channel == "telegram"
|
||||||
assert isinstance(outbound.event, ProgressEvent)
|
assert outbound.metadata["_file_edit_events"] == edit_events
|
||||||
assert outbound.event.file_edit_events == edit_events
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
|
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
|
||||||
@@ -419,8 +389,7 @@ class TestToolEventProgress:
|
|||||||
edit_events = [
|
edit_events = [
|
||||||
event
|
event
|
||||||
for msg in outbound
|
for msg in outbound
|
||||||
if isinstance(msg.event, ProgressEvent)
|
for event in msg.metadata.get("_file_edit_events", [])
|
||||||
for event in msg.event.file_edit_events or []
|
|
||||||
]
|
]
|
||||||
assert any(
|
assert any(
|
||||||
event["status"] == "editing"
|
event["status"] == "editing"
|
||||||
@@ -464,8 +433,8 @@ class TestToolEventProgress:
|
|||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
assert [m.content for m in outbound] == ["Hello"]
|
assert [m.content for m in outbound] == ["Hello"]
|
||||||
assert not any(isinstance(m.event, ProgressEvent) for m in outbound)
|
assert not any(m.metadata.get("_progress") for m in outbound)
|
||||||
assert not any(isinstance(m.event, StreamedResponseEvent) for m in outbound)
|
assert not any(m.metadata.get("_streamed") for m in outbound)
|
||||||
provider.chat_stream_with_retry.assert_not_awaited()
|
provider.chat_stream_with_retry.assert_not_awaited()
|
||||||
provider.chat_with_retry.assert_awaited_once()
|
provider.chat_with_retry.assert_awaited_once()
|
||||||
|
|
||||||
@@ -474,7 +443,7 @@ class TestToolEventProgress:
|
|||||||
self,
|
self,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Streaming channels still receive provider deltas through stream events."""
|
"""Streaming channels still receive provider deltas through _stream_delta messages."""
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.supports_progress_deltas = True
|
provider.supports_progress_deltas = True
|
||||||
@@ -504,19 +473,21 @@ class TestToolEventProgress:
|
|||||||
while bus.outbound_size > 0:
|
while bus.outbound_size > 0:
|
||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
|
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
|
||||||
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
|
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
|
||||||
final = [
|
final = [
|
||||||
m for m in outbound
|
m for m in outbound
|
||||||
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
|
if not m.metadata.get("_stream_delta")
|
||||||
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
|
and not m.metadata.get("_stream_end")
|
||||||
|
and not m.metadata.get("_turn_end")
|
||||||
|
and not m.metadata.get("_goal_status")
|
||||||
]
|
]
|
||||||
|
|
||||||
assert [m.content for m in deltas] == ["Hel", "lo"]
|
assert [m.content for m in deltas] == ["Hel", "lo"]
|
||||||
assert len(stream_end) == 1
|
assert len(stream_end) == 1
|
||||||
assert final[-1].content == "Hello"
|
assert final[-1].content == "Hello"
|
||||||
assert isinstance(final[-1].event, StreamedResponseEvent)
|
assert final[-1].metadata.get("_streamed") is True
|
||||||
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
|
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
|
||||||
assert len(turn_end_msgs) == 1
|
assert len(turn_end_msgs) == 1
|
||||||
assert turn_end_msgs[0].content == ""
|
assert turn_end_msgs[0].content == ""
|
||||||
provider.chat_with_retry.assert_not_awaited()
|
provider.chat_with_retry.assert_not_awaited()
|
||||||
@@ -557,28 +528,23 @@ class TestToolEventProgress:
|
|||||||
while bus.outbound_size > 0:
|
while bus.outbound_size > 0:
|
||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
|
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
|
||||||
stream_end = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
|
stream_end = [m for m in outbound if m.metadata.get("_stream_end")]
|
||||||
final = [
|
final = [
|
||||||
m for m in outbound
|
m for m in outbound
|
||||||
if not isinstance(m.event, StreamDeltaEvent | StreamEndEvent)
|
if not m.metadata.get("_stream_delta")
|
||||||
and not isinstance(m.event, TurnEndEvent | GoalStatusEvent)
|
and not m.metadata.get("_stream_end")
|
||||||
|
and not m.metadata.get("_turn_end")
|
||||||
|
and not m.metadata.get("_goal_status")
|
||||||
]
|
]
|
||||||
|
|
||||||
assert [m.content for m in deltas] == ["partial", "full retry response"]
|
assert [m.content for m in deltas] == ["partial", "full retry response"]
|
||||||
assert [m.event.resuming for m in stream_end if isinstance(m.event, StreamEndEvent)] == [
|
assert [m.metadata.get("_resuming") for m in stream_end] == [True, False]
|
||||||
True,
|
assert deltas[0].metadata.get("_stream_id") == stream_end[0].metadata.get("_stream_id")
|
||||||
False,
|
assert deltas[1].metadata.get("_stream_id") == stream_end[1].metadata.get("_stream_id")
|
||||||
]
|
assert deltas[0].metadata.get("_stream_id") != deltas[1].metadata.get("_stream_id")
|
||||||
assert isinstance(deltas[0].event, StreamDeltaEvent)
|
|
||||||
assert isinstance(deltas[1].event, StreamDeltaEvent)
|
|
||||||
assert isinstance(stream_end[0].event, StreamEndEvent)
|
|
||||||
assert isinstance(stream_end[1].event, StreamEndEvent)
|
|
||||||
assert deltas[0].event.stream_id == stream_end[0].event.stream_id
|
|
||||||
assert deltas[1].event.stream_id == stream_end[1].event.stream_id
|
|
||||||
assert deltas[0].event.stream_id != deltas[1].event.stream_id
|
|
||||||
assert final[-1].content == "full retry response"
|
assert final[-1].content == "full retry response"
|
||||||
assert isinstance(final[-1].event, StreamedResponseEvent)
|
assert final[-1].metadata.get("_streamed") is True
|
||||||
provider.chat_with_retry.assert_not_awaited()
|
provider.chat_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -657,9 +623,9 @@ class TestToolEventProgress:
|
|||||||
|
|
||||||
done_msgs = [m for m in outbound if m.content == "Done"]
|
done_msgs = [m for m in outbound if m.content == "Done"]
|
||||||
assert len(done_msgs) == 1
|
assert len(done_msgs) == 1
|
||||||
assert not isinstance(done_msgs[0].event, TurnEndEvent)
|
assert not done_msgs[0].metadata.get("_turn_end")
|
||||||
|
|
||||||
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
|
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
|
||||||
assert len(turn_end_msgs) == 1
|
assert len(turn_end_msgs) == 1
|
||||||
assert turn_end_msgs[0].content == ""
|
assert turn_end_msgs[0].content == ""
|
||||||
assert turn_end_msgs[0].chat_id == "chat1"
|
assert turn_end_msgs[0].chat_id == "chat1"
|
||||||
@@ -693,14 +659,14 @@ class TestToolEventProgress:
|
|||||||
outbound.append(await bus.consume_outbound())
|
outbound.append(await bus.consume_outbound())
|
||||||
|
|
||||||
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
|
error_msgs = [m for m in outbound if m.content == "Sorry, I encountered an error."]
|
||||||
turn_end_msgs = [m for m in outbound if isinstance(m.event, TurnEndEvent)]
|
turn_end_msgs = [m for m in outbound if m.metadata.get("_turn_end")]
|
||||||
statuses = [m for m in outbound if isinstance(m.event, GoalStatusEvent)]
|
statuses = [m for m in outbound if m.metadata.get("_goal_status")]
|
||||||
|
|
||||||
assert len(error_msgs) == 1
|
assert len(error_msgs) == 1
|
||||||
assert len(turn_end_msgs) == 1
|
assert len(turn_end_msgs) == 1
|
||||||
assert turn_end_msgs[0].content == ""
|
assert turn_end_msgs[0].content == ""
|
||||||
assert turn_end_msgs[0].chat_id == "chat1"
|
assert turn_end_msgs[0].chat_id == "chat1"
|
||||||
assert [m.event.status for m in statuses if isinstance(m.event, GoalStatusEvent)] == ["idle"]
|
assert [m.metadata["goal_status"] for m in statuses] == ["idle"]
|
||||||
assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
|
assert outbound.index(error_msgs[0]) < outbound.index(turn_end_msgs[0])
|
||||||
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
|
assert outbound.index(turn_end_msgs[0]) < outbound.index(statuses[-1])
|
||||||
|
|
||||||
@@ -739,27 +705,27 @@ class TestToolEventProgress:
|
|||||||
outbound: list = []
|
outbound: list = []
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
|
outbound.append(await asyncio.wait_for(bus.consume_outbound(), timeout=0.5))
|
||||||
if isinstance(outbound[-1].event, TurnEndEvent):
|
if outbound[-1].metadata.get("_turn_end"):
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise AssertionError("turn-end event not found")
|
raise AssertionError("_turn_end message not found")
|
||||||
|
|
||||||
done_with_body = [m for m in outbound if m.content == "Done"]
|
done_with_body = [m for m in outbound if m.content == "Done"]
|
||||||
assert len(done_with_body) == 1
|
assert len(done_with_body) == 1
|
||||||
assert isinstance(outbound[-1].event, TurnEndEvent)
|
assert outbound[-1].metadata.get("_turn_end") is True
|
||||||
|
|
||||||
await asyncio.wait_for(title_started.wait(), timeout=0.5)
|
await asyncio.wait_for(title_started.wait(), timeout=0.5)
|
||||||
release_title.set()
|
release_title.set()
|
||||||
session_updated = None
|
session_updated = None
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
|
candidate = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
|
||||||
if isinstance(candidate.event, SessionUpdatedEvent):
|
if (candidate.metadata or {}).get("_session_updated"):
|
||||||
session_updated = candidate
|
session_updated = candidate
|
||||||
break
|
break
|
||||||
assert session_updated is not None
|
assert session_updated is not None
|
||||||
|
|
||||||
assert isinstance(session_updated.event, SessionUpdatedEvent)
|
assert (session_updated.metadata or {}).get("_session_updated") is True
|
||||||
assert session_updated.event.scope == "metadata"
|
assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
|
||||||
assert provider.chat_with_retry.await_count == 2
|
assert provider.chat_with_retry.await_count == 2
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -871,4 +837,4 @@ class TestToolEventProgress:
|
|||||||
|
|
||||||
assert len(outbound) == 1
|
assert len(outbound) == 1
|
||||||
assert outbound[0].content == "Done"
|
assert outbound[0].content == "Done"
|
||||||
assert not isinstance(outbound[0].event, TurnEndEvent)
|
assert (outbound[0].metadata or {}).get("_turn_end") is not True
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
@@ -24,8 +23,8 @@ def _make_loop(tmp_path):
|
|||||||
|
|
||||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||||
patch("nanobot.agent.loop.SessionManager"), \
|
patch("nanobot.agent.loop.SessionManager"), \
|
||||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@@ -194,9 +193,8 @@ async def test_streamed_flag_not_set_on_llm_error(tmp_path):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert "503" in result.content
|
assert "503" in result.content
|
||||||
assert not isinstance(result.event, StreamedResponseEvent), (
|
assert not result.metadata.get("_streamed"), \
|
||||||
"streamed response event must not be set when stop_reason is error"
|
"_streamed must not be set when stop_reason is error"
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -241,7 +239,7 @@ async def test_ssrf_soft_block_can_finalize_after_streamed_tool_call(tmp_path):
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.content == "I cannot access private URLs. Please share the local file."
|
assert result.content == "I cannot access private URLs. Please share the local file."
|
||||||
assert isinstance(result.event, StreamedResponseEvent)
|
assert result.metadata.get("_streamed") is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -8,17 +8,9 @@ import pytest
|
|||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
GoalStatusEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
TurnEndEvent,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
@@ -34,7 +26,6 @@ from nanobot.session.webui_turns import (
|
|||||||
clean_generated_title,
|
clean_generated_title,
|
||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
@@ -103,13 +94,6 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
|
|||||||
assert persisted is True
|
assert persisted is True
|
||||||
message = session.messages[-1]
|
message = session.messages[-1]
|
||||||
assert message["content"] == "Scheduled cron job triggered: Daily check"
|
assert message["content"] == "Scheduled cron job triggered: Daily check"
|
||||||
assert message[AUTOMATION_HISTORY_META] == {
|
|
||||||
"kind": "cron",
|
|
||||||
"cron_job_id": "job-1",
|
|
||||||
"cron_job_name": "Daily check",
|
|
||||||
"cron_run_id": "job-1:1",
|
|
||||||
"cron_prompt_ref": prompt_ref,
|
|
||||||
}
|
|
||||||
assert message[CRON_HISTORY_META] is True
|
assert message[CRON_HISTORY_META] is True
|
||||||
assert CRON_TRIGGER_META not in message
|
assert CRON_TRIGGER_META not in message
|
||||||
assert message["cron_job_id"] == "job-1"
|
assert message["cron_job_id"] == "job-1"
|
||||||
@@ -118,63 +102,6 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
|
|||||||
assert message["cron_prompt_ref"] == prompt_ref
|
assert message["cron_prompt_ref"] == prompt_ref
|
||||||
|
|
||||||
|
|
||||||
def test_persist_local_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
session = loop.sessions.get_or_create("websocket:auto")
|
|
||||||
|
|
||||||
persisted = loop._persist_user_message_early(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="trigger",
|
|
||||||
chat_id="auto",
|
|
||||||
content="Review PR #4502",
|
|
||||||
metadata={
|
|
||||||
LOCAL_TRIGGER_META: {
|
|
||||||
"trigger_id": "trg_123",
|
|
||||||
"trigger_name": "PR review",
|
|
||||||
"delivery_id": "tdel_456",
|
|
||||||
"created_at_ms": 1_700_000_000_000,
|
|
||||||
"persist_content": "Local trigger received: PR review\n\nReview PR #4502",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
session,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert persisted is True
|
|
||||||
message = session.messages[-1]
|
|
||||||
assert message["content"] == "Local trigger received: PR review\n\nReview PR #4502"
|
|
||||||
assert message[AUTOMATION_HISTORY_META] == {
|
|
||||||
"kind": "local_trigger",
|
|
||||||
"trigger_id": "trg_123",
|
|
||||||
"trigger_name": "PR review",
|
|
||||||
"trigger_delivery_id": "tdel_456",
|
|
||||||
}
|
|
||||||
assert LOCAL_TRIGGER_META not in message
|
|
||||||
assert message["trigger_id"] == "trg_123"
|
|
||||||
assert message["trigger_name"] == "PR review"
|
|
||||||
assert message["trigger_delivery_id"] == "tdel_456"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_with_bot_suffix_does_not_persist_command(tmp_path: Path) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
|
|
||||||
response = await loop._process_message(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="/new@nanobot_bot",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response is not None
|
|
||||||
assert response.content == "New session started."
|
|
||||||
session = loop.sessions.get_or_create("websocket:chat-1")
|
|
||||||
assert session.messages == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
||||||
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
||||||
assert clean_generated_title("Title: <think> The user said hello") == ""
|
assert clean_generated_title("Title: <think> The user said hello") == ""
|
||||||
@@ -838,6 +765,7 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
|
|||||||
"_wants_stream": True,
|
"_wants_stream": True,
|
||||||
"message_id": "om_001",
|
"message_id": "om_001",
|
||||||
"origin_message_id": "root_001",
|
"origin_message_id": "root_001",
|
||||||
|
"_stream_id": "old-stream",
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -847,23 +775,23 @@ async def test_internal_continuation_preserves_streaming_route_metadata(
|
|||||||
assert queued.metadata["_wants_stream"] is True
|
assert queued.metadata["_wants_stream"] is True
|
||||||
assert queued.metadata["message_id"] == "om_001"
|
assert queued.metadata["message_id"] == "om_001"
|
||||||
assert queued.metadata["origin_message_id"] == "root_001"
|
assert queued.metadata["origin_message_id"] == "root_001"
|
||||||
|
assert "_stream_id" not in queued.metadata
|
||||||
|
|
||||||
await loop._dispatch(queued)
|
await loop._dispatch(queued)
|
||||||
|
|
||||||
outbound = []
|
outbound = []
|
||||||
while loop.bus.outbound_size:
|
while loop.bus.outbound_size:
|
||||||
outbound.append(await loop.bus.consume_outbound())
|
outbound.append(await loop.bus.consume_outbound())
|
||||||
deltas = [m for m in outbound if isinstance(m.event, StreamDeltaEvent)]
|
deltas = [m for m in outbound if m.metadata.get("_stream_delta")]
|
||||||
ends = [m for m in outbound if isinstance(m.event, StreamEndEvent)]
|
ends = [m for m in outbound if m.metadata.get("_stream_end")]
|
||||||
streamed_markers = [m for m in outbound if isinstance(m.event, StreamedResponseEvent)]
|
streamed_markers = [m for m in outbound if m.metadata.get("_streamed")]
|
||||||
|
|
||||||
assert [m.content for m in deltas] == ["done"]
|
assert [m.content for m in deltas] == ["done"]
|
||||||
assert len(ends) == 1
|
assert len(ends) == 1
|
||||||
assert isinstance(ends[0].event, StreamEndEvent)
|
assert ends[0].metadata["_resuming"] is False
|
||||||
assert ends[0].event.resuming is False
|
|
||||||
assert ends[0].metadata["message_id"] == "om_001"
|
assert ends[0].metadata["message_id"] == "om_001"
|
||||||
assert ends[0].metadata["origin_message_id"] == "root_001"
|
assert ends[0].metadata["origin_message_id"] == "root_001"
|
||||||
assert isinstance(ends[0].event.stream_id, str)
|
assert isinstance(ends[0].metadata.get("_stream_id"), str)
|
||||||
assert streamed_markers and streamed_markers[-1].content == "done"
|
assert streamed_markers and streamed_markers[-1].content == "done"
|
||||||
|
|
||||||
|
|
||||||
@@ -914,10 +842,10 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
|||||||
first_outbound = []
|
first_outbound = []
|
||||||
while loop.bus.outbound_size:
|
while loop.bus.outbound_size:
|
||||||
first_outbound.append(await loop.bus.consume_outbound())
|
first_outbound.append(await loop.bus.consume_outbound())
|
||||||
first_statuses = [m.event for m in first_outbound if isinstance(m.event, GoalStatusEvent)]
|
first_statuses = [m.metadata for m in first_outbound if m.metadata.get("_goal_status")]
|
||||||
assert [m.status for m in first_statuses] == ["running"]
|
assert [m["goal_status"] for m in first_statuses] == ["running"]
|
||||||
assert not [m for m in first_outbound if isinstance(m.event, TurnEndEvent)]
|
assert not [m for m in first_outbound if m.metadata.get("_turn_end")]
|
||||||
started_at = first_statuses[0].started_at
|
started_at = first_statuses[0]["started_at"]
|
||||||
|
|
||||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||||
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
|
assert queued.metadata[INTERNAL_CONTINUATION_META] is True
|
||||||
@@ -928,13 +856,12 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
|||||||
second_outbound = []
|
second_outbound = []
|
||||||
while loop.bus.outbound_size:
|
while loop.bus.outbound_size:
|
||||||
second_outbound.append(await loop.bus.consume_outbound())
|
second_outbound.append(await loop.bus.consume_outbound())
|
||||||
second_statuses = [m.event for m in second_outbound if isinstance(m.event, GoalStatusEvent)]
|
second_statuses = [m.metadata for m in second_outbound if m.metadata.get("_goal_status")]
|
||||||
assert [m.status for m in second_statuses] == ["running", "idle"]
|
assert [m["goal_status"] for m in second_statuses] == ["running", "idle"]
|
||||||
assert second_statuses[0].started_at == started_at
|
assert second_statuses[0]["started_at"] == started_at
|
||||||
turn_end = [m for m in second_outbound if isinstance(m.event, TurnEndEvent)]
|
turn_end = [m for m in second_outbound if m.metadata.get("_turn_end")]
|
||||||
assert len(turn_end) == 1
|
assert len(turn_end) == 1
|
||||||
assert isinstance(turn_end[0].event, TurnEndEvent)
|
assert isinstance(turn_end[0].metadata.get("latency_ms"), int)
|
||||||
assert isinstance(turn_end[0].event.latency_ms, int)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for the internal max_messages replay cap."""
|
"""Tests for max_messages config wiring into session history replay."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,27 +11,20 @@ from nanobot.agent.loop import AgentLoop
|
|||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.base import LLMResponse
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.session.manager import Session
|
||||||
from nanobot.session.manager import (
|
|
||||||
FILE_MAX_MESSAGES,
|
DEFAULT_MAX_MESSAGES = 120
|
||||||
Session,
|
|
||||||
replay_max_messages_for_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(
|
def _make_loop(tmp_path: Path, max_messages: int = DEFAULT_MAX_MESSAGES) -> AgentLoop:
|
||||||
tmp_path: Path,
|
|
||||||
context_window_tokens: int = 200_000,
|
|
||||||
) -> AgentLoop:
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.get_default_model.return_value = "test-model"
|
provider.get_default_model.return_value = "test-model"
|
||||||
provider.generation.max_tokens = 4096
|
|
||||||
return AgentLoop(
|
return AgentLoop(
|
||||||
bus=MessageBus(),
|
bus=MessageBus(),
|
||||||
provider=provider,
|
provider=provider,
|
||||||
workspace=tmp_path,
|
workspace=tmp_path,
|
||||||
model="test-model",
|
model="test-model",
|
||||||
context_window_tokens=context_window_tokens,
|
max_messages=max_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -58,44 +51,24 @@ def _tool_round(call_id: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
class TestMaxMessagesInit:
|
class TestMaxMessagesInit:
|
||||||
"""Verify AgentLoop derives the internal replay cap correctly."""
|
"""Verify AgentLoop stores the config value correctly."""
|
||||||
|
|
||||||
def test_context_formula(self) -> None:
|
def test_default_is_builtin_limit(self, tmp_path: Path) -> None:
|
||||||
assert replay_max_messages_for_context(8_000) == 120
|
|
||||||
assert replay_max_messages_for_context(32_768) == 327
|
|
||||||
assert replay_max_messages_for_context(200_000) == FILE_MAX_MESSAGES
|
|
||||||
|
|
||||||
def test_default_for_200k_context_reaches_file_cap(self, tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
assert loop._max_messages == FILE_MAX_MESSAGES
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||||
|
|
||||||
def test_default_scales_with_context_window(self, tmp_path: Path) -> None:
|
def test_positive_value_stored(self, tmp_path: Path) -> None:
|
||||||
loop = _make_loop(tmp_path, context_window_tokens=32_768)
|
loop = _make_loop(tmp_path, max_messages=25)
|
||||||
assert loop._max_messages == 327
|
assert loop._max_messages == 25
|
||||||
|
|
||||||
def test_provider_refresh_resyncs_context_derived_limit(self, tmp_path: Path) -> None:
|
def test_zero_uses_builtin_limit(self, tmp_path: Path) -> None:
|
||||||
old_provider = MagicMock()
|
loop = _make_loop(tmp_path, max_messages=0)
|
||||||
old_provider.get_default_model.return_value = "old-model"
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||||
old_provider.generation.max_tokens = 4096
|
|
||||||
new_provider = MagicMock()
|
|
||||||
new_provider.generation.max_tokens = 4096
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=old_provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="old-model",
|
|
||||||
context_window_tokens=32_768,
|
|
||||||
provider_snapshot_loader=lambda: ProviderSnapshot(
|
|
||||||
provider=new_provider,
|
|
||||||
model="new-model",
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
signature=("new-model",),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert loop._max_messages == 327
|
def test_negative_treated_as_builtin_limit(self, tmp_path: Path) -> None:
|
||||||
loop._refresh_provider_snapshot()
|
"""Negative values should not produce negative slicing."""
|
||||||
assert loop._max_messages == FILE_MAX_MESSAGES
|
loop = _make_loop(tmp_path, max_messages=-5)
|
||||||
|
assert loop._max_messages == DEFAULT_MAX_MESSAGES
|
||||||
|
|
||||||
|
|
||||||
class TestGetHistoryWithMaxMessages:
|
class TestGetHistoryWithMaxMessages:
|
||||||
@@ -104,7 +77,7 @@ class TestGetHistoryWithMaxMessages:
|
|||||||
def test_default_uses_builtin_limit(self) -> None:
|
def test_default_uses_builtin_limit(self) -> None:
|
||||||
session = _populated_session(80)
|
session = _populated_session(80)
|
||||||
history = session.get_history()
|
history = session.get_history()
|
||||||
assert len(history) <= FILE_MAX_MESSAGES
|
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||||
|
|
||||||
def test_explicit_max_messages_limits_output(self) -> None:
|
def test_explicit_max_messages_limits_output(self) -> None:
|
||||||
session = _populated_session(40) # 80 messages total
|
session = _populated_session(40) # 80 messages total
|
||||||
@@ -120,7 +93,7 @@ class TestGetHistoryWithMaxMessages:
|
|||||||
def test_max_messages_zero_uses_builtin_limit(self) -> None:
|
def test_max_messages_zero_uses_builtin_limit(self) -> None:
|
||||||
session = _populated_session(80) # 160 messages total
|
session = _populated_session(80) # 160 messages total
|
||||||
history = session.get_history(max_messages=0)
|
history = session.get_history(max_messages=0)
|
||||||
assert len(history) <= FILE_MAX_MESSAGES
|
assert len(history) <= DEFAULT_MAX_MESSAGES
|
||||||
|
|
||||||
def test_small_session_unaffected(self) -> None:
|
def test_small_session_unaffected(self) -> None:
|
||||||
"""When session has fewer messages than max_messages, all are returned."""
|
"""When session has fewer messages than max_messages, all are returned."""
|
||||||
@@ -130,13 +103,12 @@ class TestGetHistoryWithMaxMessages:
|
|||||||
|
|
||||||
|
|
||||||
class TestMaxMessagesIntegration:
|
class TestMaxMessagesIntegration:
|
||||||
"""Verify AgentLoop passes the replay cap into get_history calls."""
|
"""Verify the config flows from AgentLoop into get_history calls."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_passes_limit_to_history_call(self, tmp_path: Path) -> None:
|
async def test_process_message_passes_config_to_history_call(self, tmp_path: Path) -> None:
|
||||||
"""The real message path should pass max_messages into session history replay."""
|
"""The real message path should pass max_messages into session history replay."""
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path, max_messages=25)
|
||||||
loop._max_messages = 25
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||||
)
|
)
|
||||||
@@ -155,11 +127,8 @@ class TestMaxMessagesIntegration:
|
|||||||
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_default_limit_passes_context_derived_limit_to_history_call(
|
async def test_zero_config_passes_builtin_limit_to_history_call(self, tmp_path: Path) -> None:
|
||||||
self,
|
loop = _make_loop(tmp_path, max_messages=0)
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||||
)
|
)
|
||||||
@@ -173,7 +142,7 @@ class TestMaxMessagesIntegration:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert mock_hist.call_args.kwargs["max_messages"] == FILE_MAX_MESSAGES
|
assert mock_hist.call_args.kwargs["max_messages"] == DEFAULT_MAX_MESSAGES
|
||||||
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
assert mock_hist.call_args.kwargs["extend_to_user"] is False
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -182,8 +151,7 @@ class TestMaxMessagesIntegration:
|
|||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A live user turn should not extend history to an older long tool turn."""
|
"""A live user turn should not extend history to an older long tool turn."""
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path, max_messages=6)
|
||||||
loop._max_messages = 6
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
return_value=LLMResponse(content="ok", tool_calls=[], usage={})
|
||||||
)
|
)
|
||||||
@@ -214,3 +182,31 @@ class TestMaxMessagesIntegration:
|
|||||||
sent_text = "\n".join(str(message.get("content")) for message in sent_messages)
|
sent_text = "\n".join(str(message.get("content")) for message in sent_messages)
|
||||||
assert "new question" in sent_text
|
assert "new question" in sent_text
|
||||||
assert "long older turn" not in sent_text
|
assert "long older turn" not in sent_text
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaConfig:
|
||||||
|
"""Verify the config schema accepts max_messages."""
|
||||||
|
|
||||||
|
def test_schema_default(self) -> None:
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
defaults = AgentDefaults()
|
||||||
|
assert defaults.max_messages == DEFAULT_MAX_MESSAGES
|
||||||
|
|
||||||
|
def test_schema_accepts_zero_as_builtin_limit(self) -> None:
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
defaults = AgentDefaults(max_messages=0)
|
||||||
|
assert defaults.max_messages == 0
|
||||||
|
|
||||||
|
def test_schema_accepts_positive(self) -> None:
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
defaults = AgentDefaults(max_messages=25)
|
||||||
|
assert defaults.max_messages == 25
|
||||||
|
|
||||||
|
def test_schema_rejects_negative(self) -> None:
|
||||||
|
from nanobot.config.schema import AgentDefaults
|
||||||
|
|
||||||
|
with pytest.raises(Exception): # Pydantic validation error
|
||||||
|
AgentDefaults(max_messages=-1)
|
||||||
|
|||||||
@@ -853,11 +853,10 @@ class TestApiServerRegistration:
|
|||||||
config = Config()
|
config = Config()
|
||||||
from nanobot.config.schema import ApiConfig
|
from nanobot.config.schema import ApiConfig
|
||||||
|
|
||||||
new_api = ApiConfig(host="0.0.0.0", port=9999, api_key="secret")
|
new_api = ApiConfig(host="0.0.0.0", port=9999)
|
||||||
_SETTINGS_SETTER["API Server"](config, new_api)
|
_SETTINGS_SETTER["API Server"](config, new_api)
|
||||||
assert config.api.host == "0.0.0.0"
|
assert config.api.host == "0.0.0.0"
|
||||||
assert config.api.port == 9999
|
assert config.api.port == 9999
|
||||||
assert config.api.api_key == "secret"
|
|
||||||
|
|
||||||
|
|
||||||
class TestMainMenuUpdate:
|
class TestMainMenuUpdate:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user