mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9c5f03ae | ||
|
|
1cd3431639 | ||
|
|
c62d0d5fa7 | ||
|
|
9474498e3e | ||
|
|
abf930a381 | ||
|
|
c2b1453b2e | ||
|
|
3fccd9ab9a | ||
|
|
12610138af | ||
|
|
5b9eba4318 |
@@ -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:**
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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]],
|
||||||
|
|||||||
+27
-48
@@ -31,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 (
|
||||||
@@ -64,11 +57,7 @@ from nanobot.session.goal_state import (
|
|||||||
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.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,7 +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",
|
|
||||||
):
|
):
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
|
||||||
@@ -235,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
|
||||||
@@ -304,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] = {}
|
||||||
@@ -402,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,
|
||||||
@@ -433,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(
|
||||||
@@ -447,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
|
||||||
@@ -574,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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1004,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(
|
||||||
@@ -1381,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)
|
||||||
|
|
||||||
@@ -1392,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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+59
-99
@@ -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,
|
||||||
@@ -50,6 +50,7 @@ from nanobot.utils.runtime import (
|
|||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
build_goal_continue_message,
|
build_goal_continue_message,
|
||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
|
build_runtime_budget_notice_message,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
repeated_workspace_violation_error,
|
repeated_workspace_violation_error,
|
||||||
@@ -67,6 +68,7 @@ _MAX_EMPTY_RETRIES = 2
|
|||||||
_MAX_LENGTH_RECOVERIES = 3
|
_MAX_LENGTH_RECOVERIES = 3
|
||||||
_MAX_INJECTIONS_PER_TURN = 3
|
_MAX_INJECTIONS_PER_TURN = 3
|
||||||
_MAX_INJECTION_CYCLES = 5
|
_MAX_INJECTION_CYCLES = 5
|
||||||
|
_BUDGET_NOTICE_MIN_ITERATIONS = 20
|
||||||
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
# Backward-compatible module attribute for tests/extensions that monkeypatch
|
||||||
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
|
||||||
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
prepare_file_edit_tracker = _prepare_file_edit_tracker
|
||||||
@@ -357,6 +359,7 @@ class AgentRunner:
|
|||||||
length_recovery_count = 0
|
length_recovery_count = 0
|
||||||
had_injections = False
|
had_injections = False
|
||||||
injection_cycles = 0
|
injection_cycles = 0
|
||||||
|
budget_notice_level_sent = 0
|
||||||
compacted_tool_call_ids: set[str] = set()
|
compacted_tool_call_ids: set[str] = set()
|
||||||
governance_config = ContextGovernanceConfig(
|
governance_config = ContextGovernanceConfig(
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
@@ -389,15 +392,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
|
||||||
)
|
)
|
||||||
@@ -519,6 +514,12 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
if _drained:
|
if _drained:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
|
budget_notice_level_sent = self._append_runtime_budget_notice_if_needed(
|
||||||
|
spec,
|
||||||
|
messages,
|
||||||
|
completed_iterations=iteration + 1,
|
||||||
|
sent_level=budget_notice_level_sent,
|
||||||
|
)
|
||||||
await hook.after_iteration(context)
|
await hook.after_iteration(context)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -733,8 +734,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:
|
||||||
@@ -877,94 +876,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,
|
||||||
@@ -1036,6 +949,53 @@ class AgentRunner:
|
|||||||
retry_messages.append(build_budget_exhausted_finalization_message())
|
retry_messages.append(build_budget_exhausted_finalization_message())
|
||||||
return retry_messages
|
return retry_messages
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _append_runtime_budget_notice_if_needed(
|
||||||
|
cls,
|
||||||
|
spec: AgentRunSpec,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
completed_iterations: int,
|
||||||
|
sent_level: int,
|
||||||
|
) -> int:
|
||||||
|
level = cls._runtime_budget_notice_level(
|
||||||
|
max_iterations=spec.max_iterations,
|
||||||
|
completed_iterations=completed_iterations,
|
||||||
|
)
|
||||||
|
if level <= sent_level:
|
||||||
|
return sent_level
|
||||||
|
|
||||||
|
remaining_iterations = max(0, spec.max_iterations - completed_iterations)
|
||||||
|
messages.append(build_runtime_budget_notice_message(
|
||||||
|
level=level,
|
||||||
|
max_iterations=spec.max_iterations,
|
||||||
|
used_iterations=completed_iterations,
|
||||||
|
remaining_iterations=remaining_iterations,
|
||||||
|
))
|
||||||
|
return level
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _runtime_budget_notice_level(
|
||||||
|
*,
|
||||||
|
max_iterations: int,
|
||||||
|
completed_iterations: int,
|
||||||
|
) -> int:
|
||||||
|
"""Return the convergence-warning level for a long tool loop."""
|
||||||
|
if max_iterations < _BUDGET_NOTICE_MIN_ITERATIONS:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
remaining_iterations = max_iterations - completed_iterations
|
||||||
|
if remaining_iterations <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
convergence_threshold = max(5, (max_iterations + 9) // 10)
|
||||||
|
final_threshold = max(3, (max_iterations + 32) // 33)
|
||||||
|
if remaining_iterations <= final_threshold:
|
||||||
|
return 2
|
||||||
|
if remaining_iterations <= convergence_threshold:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
|
def _max_iterations_fallback(spec: AgentRunSpec) -> str:
|
||||||
if spec.max_iterations_message:
|
if spec.max_iterations_message:
|
||||||
@@ -1266,7 +1226,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,
|
||||||
@@ -17,6 +17,13 @@ from nanobot.agent.tools.schema import (
|
|||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
VerificationAnalysis,
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
|
from nanobot.utils.helpers import build_structured_output_summary
|
||||||
|
|
||||||
DEFAULT_YIELD_MS = 1000
|
DEFAULT_YIELD_MS = 1000
|
||||||
MAX_YIELD_MS = 30_000
|
MAX_YIELD_MS = 30_000
|
||||||
@@ -37,6 +44,7 @@ class _SessionPoll:
|
|||||||
terminated: bool = False
|
terminated: bool = False
|
||||||
stdin_closed: bool = False
|
stdin_closed: bool = False
|
||||||
truncated_chars: int = 0
|
truncated_chars: int = 0
|
||||||
|
analysis: VerificationAnalysis | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -147,7 +155,19 @@ class _ExecSession:
|
|||||||
output = "".join(self._chunks)
|
output = "".join(self._chunks)
|
||||||
self._chunks.clear()
|
self._chunks.clear()
|
||||||
|
|
||||||
output, truncated = _truncate_output(output, max_output_chars)
|
analysis = analyze_verification_result(
|
||||||
|
command=self.command,
|
||||||
|
output=output,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
timed_out=self._timed_out,
|
||||||
|
)
|
||||||
|
output, truncated = _truncate_output(
|
||||||
|
output,
|
||||||
|
max_output_chars,
|
||||||
|
analysis=analysis,
|
||||||
|
exit_code=self.process.returncode,
|
||||||
|
elapsed_s=max(0.0, time.monotonic() - self.started_at),
|
||||||
|
)
|
||||||
return _SessionPoll(
|
return _SessionPoll(
|
||||||
output=output,
|
output=output,
|
||||||
done=self.process.returncode is not None,
|
done=self.process.returncode is not None,
|
||||||
@@ -157,6 +177,7 @@ class _ExecSession:
|
|||||||
terminated=terminated,
|
terminated=terminated,
|
||||||
stdin_closed=stdin_closed,
|
stdin_closed=stdin_closed,
|
||||||
truncated_chars=truncated,
|
truncated_chars=truncated,
|
||||||
|
analysis=analysis,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
@@ -320,15 +341,33 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
|
|||||||
return min(max(value, minimum), maximum)
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
def _truncate_output(
|
||||||
|
output: str,
|
||||||
|
max_output_chars: int,
|
||||||
|
*,
|
||||||
|
analysis: VerificationAnalysis | None = None,
|
||||||
|
exit_code: int | None = None,
|
||||||
|
elapsed_s: float | None = None,
|
||||||
|
) -> tuple[str, int]:
|
||||||
if len(output) <= max_output_chars:
|
if len(output) <= max_output_chars:
|
||||||
return output, 0
|
return output, 0
|
||||||
half = max_output_chars // 2
|
|
||||||
omitted = len(output) - max_output_chars
|
omitted = len(output) - max_output_chars
|
||||||
return (
|
return (
|
||||||
output[:half]
|
build_structured_output_summary(
|
||||||
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
"[tool output truncated]",
|
||||||
+ output[-half:],
|
output,
|
||||||
|
max_chars=max_output_chars,
|
||||||
|
metadata=[
|
||||||
|
("original_size_chars", len(output)),
|
||||||
|
("exit_code", exit_code if exit_code is not None else "running"),
|
||||||
|
("elapsed_s", f"{elapsed_s:.1f}" if elapsed_s is not None else "unknown"),
|
||||||
|
],
|
||||||
|
analysis=analysis,
|
||||||
|
guidance=(
|
||||||
|
"Use the structured summary first. Poll again for new output "
|
||||||
|
"or rerun a narrower command instead of reading broad logs."
|
||||||
|
),
|
||||||
|
),
|
||||||
omitted,
|
omitted,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -351,6 +390,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
return "\n".join(parts) if parts else "(no output yet)"
|
return "\n".join(parts) if parts else "(no output yet)"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_poll_with_verification(session_id: str, poll: _SessionPoll) -> str:
|
||||||
|
result = format_session_poll(session_id, poll)
|
||||||
|
if not poll.done:
|
||||||
|
return result
|
||||||
|
analysis = poll.analysis or analyze_verification_result(
|
||||||
|
command="",
|
||||||
|
output=result,
|
||||||
|
exit_code=poll.exit_code,
|
||||||
|
timed_out=poll.timed_out,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
|
||||||
@@ -492,12 +545,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_poll_with_verification(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 +585,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_poll_with_verification(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_poll_with_verification(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 +659,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,9 +20,14 @@ 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.agent.verification_state import (
|
||||||
|
clear_verification_observation,
|
||||||
|
format_completion_gate_message,
|
||||||
|
latest_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
GOAL_STATE_KEY,
|
GOAL_STATE_KEY,
|
||||||
@@ -150,12 +155,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."
|
||||||
)
|
)
|
||||||
@@ -187,6 +192,29 @@ class LongTaskTool(Tool, _GoalToolsMixin):
|
|||||||
max_length=8000,
|
max_length=8000,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
|
verification_summary=StringSchema(
|
||||||
|
"For coding or file-producing tasks, summarize how the work was verified. "
|
||||||
|
"Mention the most relevant test/check command and whether it passed. "
|
||||||
|
"If no verification was possible, say why.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
commands_run=StringSchema(
|
||||||
|
"Optional concise list of verification/build commands run before completion.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
artifacts_created=StringSchema(
|
||||||
|
"Optional concise list of files, outputs, or artifacts created.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
remaining_failures=StringSchema(
|
||||||
|
"Known unresolved failures, if intentionally stopping before success. "
|
||||||
|
"Leave empty when verification passes.",
|
||||||
|
max_length=4000,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
required=[],
|
required=[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -222,30 +250,67 @@ class CompleteGoalTool(Tool, _GoalToolsMixin):
|
|||||||
return (
|
return (
|
||||||
"End bookkeeping for the active sustained goal. "
|
"End bookkeeping for the active sustained goal. "
|
||||||
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
"Use when the objective is fully achieved and verified—recap what was delivered. "
|
||||||
|
"For coding/file-producing tasks, run the smallest reliable verification first and include "
|
||||||
|
"verification_summary / commands_run / artifacts_created. "
|
||||||
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
"Also call when the user cancels, redirects, or replaces the goal: recap must reflect "
|
||||||
"what actually happened (not necessarily success). "
|
"what actually happened (not necessarily success). "
|
||||||
|
"If recent verification failed and no later verification passed, this tool will ask you to "
|
||||||
|
"continue fixing unless remaining_failures describes an intentional incomplete stop. "
|
||||||
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
"If no goal is active, the tool reports that and leaves metadata unchanged."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def execute(self, recap: str | None = None, **kwargs: Any) -> str:
|
async def execute(
|
||||||
|
self,
|
||||||
|
recap: str | None = None,
|
||||||
|
verification_summary: str | None = None,
|
||||||
|
commands_run: str | None = None,
|
||||||
|
artifacts_created: str | None = None,
|
||||||
|
remaining_failures: 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."
|
||||||
|
|
||||||
|
session_key = self._request_ctx.get().session_key if self._request_ctx.get() else None
|
||||||
|
observation = latest_verification_observation(session_key)
|
||||||
|
if (
|
||||||
|
observation is not None
|
||||||
|
and observation.analysis.status == "failed"
|
||||||
|
and not _has_meaningful_remaining_failures(remaining_failures)
|
||||||
|
):
|
||||||
|
return format_completion_gate_message(observation)
|
||||||
|
|
||||||
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."
|
||||||
|
|
||||||
ended = _iso_now()
|
ended = _iso_now()
|
||||||
sess.metadata[GOAL_STATE_KEY] = {
|
completed = {
|
||||||
**prior,
|
**prior,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"completed_at": ended,
|
"completed_at": ended,
|
||||||
"recap": (recap or "").strip(),
|
"recap": (recap or "").strip(),
|
||||||
}
|
}
|
||||||
|
if verification_summary:
|
||||||
|
completed["verification_summary"] = verification_summary.strip()
|
||||||
|
if commands_run:
|
||||||
|
completed["commands_run"] = commands_run.strip()
|
||||||
|
if artifacts_created:
|
||||||
|
completed["artifacts_created"] = artifacts_created.strip()
|
||||||
|
if remaining_failures:
|
||||||
|
completed["remaining_failures"] = remaining_failures.strip()
|
||||||
|
sess.metadata[GOAL_STATE_KEY] = completed
|
||||||
discard_legacy_goal_state_key(sess.metadata)
|
discard_legacy_goal_state_key(sess.metadata)
|
||||||
self._sessions.save(sess)
|
self._sessions.save(sess)
|
||||||
|
clear_verification_observation(session_key)
|
||||||
await self._publish_goal_state_changed(sess.metadata)
|
await self._publish_goal_state_changed(sess.metadata)
|
||||||
tail = (recap or "").strip()
|
tail = (recap or "").strip()
|
||||||
if tail:
|
if tail:
|
||||||
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
return f"Goal marked complete ({ended}). Recap:\n{tail}"
|
||||||
return f"Goal marked complete ({ended})."
|
return f"Goal marked complete ({ended})."
|
||||||
|
|
||||||
|
|
||||||
|
def _has_meaningful_remaining_failures(value: str | None) -> bool:
|
||||||
|
text = (value or "").strip().lower()
|
||||||
|
return bool(text and text not in {"none", "no", "n/a", "na", "no remaining failures"})
|
||||||
|
|||||||
+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}")
|
||||||
|
|||||||
+214
-102
@@ -6,17 +6,19 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from pydantic import Field
|
from pydantic import AliasChoices, Field
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Schema, 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,
|
||||||
@@ -34,12 +36,19 @@ from nanobot.agent.tools.schema import (
|
|||||||
StringSchema,
|
StringSchema,
|
||||||
tool_parameters_schema,
|
tool_parameters_schema,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
from nanobot.security.workspace_access import current_scope_allows_loopback, current_tool_workspace
|
||||||
from nanobot.security.workspace_policy import is_path_within
|
from nanobot.security.workspace_policy import is_path_within
|
||||||
|
from nanobot.utils.helpers import build_structured_output_summary
|
||||||
|
|
||||||
_IS_WINDOWS = sys.platform == "win32"
|
_IS_WINDOWS = sys.platform == "win32"
|
||||||
|
_DETACHED_EXIT_GRACE_S = 1.0 if _IS_WINDOWS else 0.2
|
||||||
|
|
||||||
|
|
||||||
# Policy note appended to recoverable workspace-boundary guard errors.
|
# Policy note appended to recoverable workspace-boundary guard errors.
|
||||||
@@ -56,6 +65,13 @@ class ExecToolConfig(Base):
|
|||||||
"""Shell exec tool configuration."""
|
"""Shell exec tool configuration."""
|
||||||
enable: bool = True
|
enable: bool = True
|
||||||
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
|
||||||
|
allow_local_service_access: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias=AliasChoices(
|
||||||
|
"allowLocalServiceAccess",
|
||||||
|
"allow_local_service_access",
|
||||||
|
),
|
||||||
|
) # allow shell commands to reach literal localhost/loopback services
|
||||||
path_prepend: str = ""
|
path_prepend: str = ""
|
||||||
path_append: str = ""
|
path_append: str = ""
|
||||||
sandbox: str = ""
|
sandbox: str = ""
|
||||||
@@ -74,55 +90,51 @@ class _PreparedCommand:
|
|||||||
login: bool
|
login: bool
|
||||||
|
|
||||||
|
|
||||||
_EXEC_TOOL_PARAMETERS = tool_parameters_schema(
|
@tool_parameters(
|
||||||
command=StringSchema("The shell command to execute"),
|
tool_parameters_schema(
|
||||||
working_dir=StringSchema("Optional working directory for the command"),
|
command=StringSchema("The shell command to execute"),
|
||||||
timeout=IntegerSchema(
|
cmd=StringSchema("Compatibility alias for command"),
|
||||||
60,
|
working_dir=StringSchema("Optional working directory for the command"),
|
||||||
description=(
|
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||||
"Timeout in seconds. Increase for long-running commands "
|
timeout=IntegerSchema(
|
||||||
"like compilation or installation (default 60, max 600)."
|
60,
|
||||||
|
description=(
|
||||||
|
"Timeout in seconds. Increase for long-running commands "
|
||||||
|
"like compilation or installation (default 60, max 600)."
|
||||||
|
),
|
||||||
|
minimum=1,
|
||||||
|
maximum=600,
|
||||||
),
|
),
|
||||||
minimum=1,
|
shell=StringSchema(
|
||||||
maximum=600,
|
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
||||||
),
|
nullable=True,
|
||||||
shell=StringSchema(
|
|
||||||
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
login=BooleanSchema(
|
|
||||||
description="Whether to run bash/zsh with login shell semantics (default false).",
|
|
||||||
default=False,
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
yield_time_ms=IntegerSchema(
|
|
||||||
description=(
|
|
||||||
"Optional milliseconds to wait before returning output. "
|
|
||||||
"When set, a still-running command returns a session_id that "
|
|
||||||
"can be polled or written to with write_stdin. Omit this field "
|
|
||||||
"to keep one-shot exec behavior."
|
|
||||||
),
|
),
|
||||||
minimum=0,
|
login=BooleanSchema(
|
||||||
maximum=MAX_YIELD_MS,
|
description="Whether to run bash/zsh with login shell semantics (default false).",
|
||||||
nullable=True,
|
default=False,
|
||||||
),
|
nullable=True,
|
||||||
max_output_chars=IntegerSchema(
|
|
||||||
description=(
|
|
||||||
"Maximum output characters to return when yield_time_ms is used "
|
|
||||||
"(default 10000, max 50000)."
|
|
||||||
),
|
),
|
||||||
minimum=1000,
|
yield_time_ms=IntegerSchema(
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
description=(
|
||||||
nullable=True,
|
"Optional milliseconds to wait before returning output. "
|
||||||
),
|
"When set, a still-running command returns a session_id that "
|
||||||
)
|
"can be polled or written to with write_stdin. Omit this field "
|
||||||
|
"to keep one-shot exec behavior."
|
||||||
_EXEC_TOOL_COMPAT_PARAMETERS = deepcopy(_EXEC_TOOL_PARAMETERS)
|
),
|
||||||
_EXEC_TOOL_COMPAT_PARAMETERS["properties"].update(
|
minimum=0,
|
||||||
{
|
maximum=MAX_YIELD_MS,
|
||||||
"cmd": StringSchema("Compatibility alias for command").to_json_schema(),
|
nullable=True,
|
||||||
"workdir": StringSchema("Compatibility alias for working_dir").to_json_schema(),
|
),
|
||||||
"max_output_tokens": IntegerSchema(
|
max_output_chars=IntegerSchema(
|
||||||
|
description=(
|
||||||
|
"Maximum output characters to return when yield_time_ms is used "
|
||||||
|
"(default 10000, max 50000)."
|
||||||
|
),
|
||||||
|
minimum=1000,
|
||||||
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
max_output_tokens=IntegerSchema(
|
||||||
description=(
|
description=(
|
||||||
"Compatibility alias for max_output_chars. The current runtime "
|
"Compatibility alias for max_output_chars. The current runtime "
|
||||||
"uses a character budget."
|
"uses a character budget."
|
||||||
@@ -130,12 +142,19 @@ _EXEC_TOOL_COMPAT_PARAMETERS["properties"].update(
|
|||||||
minimum=1000,
|
minimum=1000,
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
).to_json_schema(),
|
),
|
||||||
}
|
detach=BooleanSchema(
|
||||||
|
description=(
|
||||||
|
"Run the command as a detached background process that can "
|
||||||
|
"survive after the agent finishes. Use for local servers, "
|
||||||
|
"dev servers, mock APIs, or other services that must remain "
|
||||||
|
"available for later commands or external verification."
|
||||||
|
),
|
||||||
|
default=False,
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(_EXEC_TOOL_PARAMETERS)
|
|
||||||
class ExecTool(Tool):
|
class ExecTool(Tool):
|
||||||
"""Tool to execute shell commands."""
|
"""Tool to execute shell commands."""
|
||||||
_scopes = {"core", "subagent"}
|
_scopes = {"core", "subagent"}
|
||||||
@@ -157,6 +176,7 @@ class ExecTool(Tool):
|
|||||||
working_dir=ctx.workspace,
|
working_dir=ctx.workspace,
|
||||||
timeout=cfg.timeout,
|
timeout=cfg.timeout,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
|
allow_local_service_access=cfg.allow_local_service_access,
|
||||||
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
|
||||||
sandbox=cfg.sandbox,
|
sandbox=cfg.sandbox,
|
||||||
path_prepend=cfg.path_prepend,
|
path_prepend=cfg.path_prepend,
|
||||||
@@ -173,6 +193,7 @@ class ExecTool(Tool):
|
|||||||
deny_patterns: list[str] | None = None,
|
deny_patterns: list[str] | None = None,
|
||||||
allow_patterns: list[str] | None = None,
|
allow_patterns: list[str] | None = None,
|
||||||
restrict_to_workspace: bool = False,
|
restrict_to_workspace: bool = False,
|
||||||
|
allow_local_service_access: bool = False,
|
||||||
webui_allow_local_service_access: bool = True,
|
webui_allow_local_service_access: bool = True,
|
||||||
allow_local_preview_access: bool | None = None,
|
allow_local_preview_access: bool | None = None,
|
||||||
sandbox: str = "",
|
sandbox: str = "",
|
||||||
@@ -205,6 +226,7 @@ class ExecTool(Tool):
|
|||||||
]
|
]
|
||||||
self.allow_patterns = allow_patterns or []
|
self.allow_patterns = allow_patterns or []
|
||||||
self.restrict_to_workspace = restrict_to_workspace
|
self.restrict_to_workspace = restrict_to_workspace
|
||||||
|
self.allow_local_service_access = allow_local_service_access
|
||||||
if allow_local_preview_access is not None:
|
if allow_local_preview_access is not None:
|
||||||
webui_allow_local_service_access = allow_local_preview_access
|
webui_allow_local_service_access = allow_local_preview_access
|
||||||
self.webui_allow_local_service_access = webui_allow_local_service_access
|
self.webui_allow_local_service_access = webui_allow_local_service_access
|
||||||
@@ -244,26 +266,17 @@ class ExecTool(Tool):
|
|||||||
"Use -y or --yes flags to avoid interactive prompts. "
|
"Use -y or --yes flags to avoid interactive prompts. "
|
||||||
"For long-running or interactive commands, pass yield_time_ms; "
|
"For long-running or interactive commands, pass yield_time_ms; "
|
||||||
"if the command keeps running, exec returns a session_id that can "
|
"if the command keeps running, exec returns a session_id that can "
|
||||||
"be polled or written to with write_stdin. Output is truncated at "
|
"be polled or written to with write_stdin. For services that "
|
||||||
"10 000 chars; timeout defaults to 60s."
|
"must remain available after you finish, pass detach=true instead "
|
||||||
|
"of yield_time_ms; detached output is written to a log file and "
|
||||||
|
"the tool returns a pid. Output is truncated at 10 000 chars; "
|
||||||
|
"timeout defaults to 60s."
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def exclusive(self) -> bool:
|
def exclusive(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def cast_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
return self._cast_object(params, _EXEC_TOOL_COMPAT_PARAMETERS)
|
|
||||||
|
|
||||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
|
||||||
if not isinstance(params, dict):
|
|
||||||
return [f"parameters must be an object, got {type(params).__name__}"]
|
|
||||||
return Schema.validate_json_schema_value(
|
|
||||||
params,
|
|
||||||
{**_EXEC_TOOL_COMPAT_PARAMETERS, "type": "object"},
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
self, command: str | None = None, cmd: str | None = None,
|
self, command: str | None = None, cmd: str | None = None,
|
||||||
working_dir: str | None = None, workdir: str | None = None,
|
working_dir: str | None = None, workdir: str | None = None,
|
||||||
@@ -271,12 +284,13 @@ class ExecTool(Tool):
|
|||||||
login: bool | None = None, yield_time_ms: int | None = None,
|
login: bool | None = None, yield_time_ms: int | None = None,
|
||||||
max_output_chars: int | None = None,
|
max_output_chars: int | None = None,
|
||||||
max_output_tokens: int | None = None,
|
max_output_tokens: int | None = None,
|
||||||
|
detach: bool | None = False,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
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
|
||||||
|
|
||||||
@@ -284,10 +298,14 @@ class ExecTool(Tool):
|
|||||||
if isinstance(prepared, str):
|
if isinstance(prepared, str):
|
||||||
return prepared
|
return prepared
|
||||||
|
|
||||||
|
if detach:
|
||||||
|
return await self._execute_detached(prepared)
|
||||||
|
|
||||||
if yield_time_ms is not None:
|
if yield_time_ms is not None:
|
||||||
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
started_at = time.monotonic()
|
||||||
process = await self._spawn(
|
process = await self._spawn(
|
||||||
prepared.command,
|
prepared.command,
|
||||||
prepared.cwd,
|
prepared.cwd,
|
||||||
@@ -303,7 +321,15 @@ 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")
|
result = f"Error: Command timed out after {prepared.timeout} seconds"
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=None,
|
||||||
|
timed_out=True,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
await self._kill_process(process)
|
await self._kill_process(process)
|
||||||
raise
|
raise
|
||||||
@@ -321,20 +347,38 @@ class ExecTool(Tool):
|
|||||||
output_parts.append(f"\nExit code: {process.returncode}")
|
output_parts.append(f"\nExit code: {process.returncode}")
|
||||||
|
|
||||||
result = "\n".join(output_parts) if output_parts else "(no output)"
|
result = "\n".join(output_parts) if output_parts else "(no output)"
|
||||||
|
elapsed_s = max(0.0, time.monotonic() - started_at)
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=process.returncode,
|
||||||
|
)
|
||||||
|
|
||||||
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
|
||||||
if len(result) > max_len:
|
if len(result) > max_len:
|
||||||
half = max_len // 2
|
result = build_structured_output_summary(
|
||||||
result = (
|
"[tool output truncated]",
|
||||||
result[:half]
|
result,
|
||||||
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
|
max_chars=max_len,
|
||||||
+ result[-half:]
|
metadata=[
|
||||||
|
("original_size_chars", len(result)),
|
||||||
|
("exit_code", process.returncode),
|
||||||
|
("duration_s", f"{elapsed_s:.1f}"),
|
||||||
|
],
|
||||||
|
analysis=analysis,
|
||||||
|
guidance=(
|
||||||
|
"Use the structured summary first. Rerun a narrower "
|
||||||
|
"command, grep a specific failure, or inspect the "
|
||||||
|
"named artifact instead of rerunning broad noisy logs."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return result
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
|
||||||
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,
|
||||||
@@ -360,9 +404,69 @@ class ExecTool(Tool):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
result = format_session_poll(session_id, poll)
|
result = format_session_poll(session_id, poll)
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
if poll.done:
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=prepared.command,
|
||||||
|
output=result,
|
||||||
|
exit_code=poll.exit_code,
|
||||||
|
timed_out=poll.timed_out,
|
||||||
|
)
|
||||||
|
record_verification_observation(current_request_session_key(), analysis)
|
||||||
|
return append_verification_feedback(result, analysis)
|
||||||
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return ToolResult.error(f"Error executing command: {exc}")
|
return f"Error executing command: {exc}"
|
||||||
|
|
||||||
|
async def _execute_detached(self, prepared: _PreparedCommand) -> str:
|
||||||
|
log_dir = Path(prepared.cwd) / ".nanobot" / "exec-logs"
|
||||||
|
try:
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = log_dir / f"detached-{uuid.uuid4().hex[:12]}.log"
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error preparing detached command log directory: {exc}"
|
||||||
|
|
||||||
|
log_handle = None
|
||||||
|
try:
|
||||||
|
log_handle = open(log_path, "ab", buffering=0)
|
||||||
|
process = await self._spawn(
|
||||||
|
prepared.command,
|
||||||
|
prepared.cwd,
|
||||||
|
prepared.env,
|
||||||
|
prepared.shell_program,
|
||||||
|
prepared.login,
|
||||||
|
stdout=log_handle,
|
||||||
|
stderr=log_handle,
|
||||||
|
start_new_session=not _IS_WINDOWS,
|
||||||
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if _IS_WINDOWS else 0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error starting detached command: {exc}"
|
||||||
|
finally:
|
||||||
|
if log_handle is not None:
|
||||||
|
with suppress(Exception):
|
||||||
|
log_handle.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
exit_code = await asyncio.wait_for(process.wait(), timeout=_DETACHED_EXIT_GRACE_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return (
|
||||||
|
"Detached process started.\n"
|
||||||
|
f"pid: {process.pid}\n"
|
||||||
|
f"cwd: {prepared.cwd}\n"
|
||||||
|
f"log: {log_path}\n"
|
||||||
|
"Poll the log or run a health check to verify the service is ready."
|
||||||
|
)
|
||||||
|
|
||||||
|
log_text = ""
|
||||||
|
with suppress(Exception):
|
||||||
|
log_text = log_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
if len(log_text) > 4000:
|
||||||
|
log_text = log_text[-4000:]
|
||||||
|
return (
|
||||||
|
f"Detached process exited immediately with code {exit_code}.\n"
|
||||||
|
f"log: {log_path}\n"
|
||||||
|
f"{log_text}"
|
||||||
|
)
|
||||||
|
|
||||||
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).
|
||||||
@@ -404,12 +508,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
|
||||||
)
|
)
|
||||||
@@ -485,6 +589,10 @@ class ExecTool(Tool):
|
|||||||
login: bool = False,
|
login: bool = False,
|
||||||
*,
|
*,
|
||||||
stdin: int = asyncio.subprocess.DEVNULL,
|
stdin: int = asyncio.subprocess.DEVNULL,
|
||||||
|
stdout: Any = asyncio.subprocess.PIPE,
|
||||||
|
stderr: Any = asyncio.subprocess.PIPE,
|
||||||
|
start_new_session: bool = False,
|
||||||
|
creationflags: int = 0,
|
||||||
) -> asyncio.subprocess.Process:
|
) -> asyncio.subprocess.Process:
|
||||||
"""Launch *command* in a platform-appropriate shell."""
|
"""Launch *command* in a platform-appropriate shell."""
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
@@ -492,18 +600,20 @@ class ExecTool(Tool):
|
|||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
"powershell", "-NoProfile", "-Command", command,
|
"powershell", "-NoProfile", "-Command", command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
return await asyncio.create_subprocess_shell(
|
return await asyncio.create_subprocess_shell(
|
||||||
command,
|
command,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
creationflags=creationflags,
|
||||||
)
|
)
|
||||||
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
|
||||||
args = [shell_program]
|
args = [shell_program]
|
||||||
@@ -514,10 +624,11 @@ class ExecTool(Tool):
|
|||||||
return await asyncio.create_subprocess_exec(
|
return await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdin=stdin,
|
stdin=stdin,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=stdout,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=stderr,
|
||||||
cwd=cwd,
|
cwd=cwd,
|
||||||
env=env,
|
env=env,
|
||||||
|
start_new_session=start_new_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -525,24 +636,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
|
||||||
@@ -629,25 +740,26 @@ 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
|
||||||
|
allow_loopback = self.allow_local_service_access or current_scope_allows_loopback(
|
||||||
|
enabled=self.webui_allow_local_service_access,
|
||||||
|
)
|
||||||
if contains_internal_url(
|
if contains_internal_url(
|
||||||
cmd,
|
cmd,
|
||||||
allow_loopback=current_scope_allows_loopback(
|
allow_loopback=allow_loopback,
|
||||||
enabled=self.webui_allow_local_service_access,
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
# 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
|
||||||
)
|
)
|
||||||
@@ -682,7 +794,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(
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""Lightweight verification-result detection for coding workflows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
VerificationStatus = Literal["passed", "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VerificationAnalysis:
|
||||||
|
"""Structured summary of a command that appears to be verification."""
|
||||||
|
|
||||||
|
status: VerificationStatus
|
||||||
|
command: str
|
||||||
|
exit_code: int | None
|
||||||
|
failed_tests: tuple[str, ...] = ()
|
||||||
|
primary_errors: tuple[str, ...] = ()
|
||||||
|
missing_artifacts: tuple[str, ...] = ()
|
||||||
|
timed_out: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VerificationObservation:
|
||||||
|
"""Latest verification signal observed for a session."""
|
||||||
|
|
||||||
|
analysis: VerificationAnalysis
|
||||||
|
sequence: int
|
||||||
|
|
||||||
|
|
||||||
|
_OBSERVATIONS: dict[str, VerificationObservation] = {}
|
||||||
|
_SEQUENCE = 0
|
||||||
|
|
||||||
|
_TEST_COMMAND_RE = re.compile(
|
||||||
|
r"(?ix)"
|
||||||
|
r"("
|
||||||
|
r"\bpytest\b|\bpy\.test\b|\bunittest\b|\bnosetests\b|"
|
||||||
|
r"\btest_outputs\.py\b|\brun_tests?(?:\.sh|\.py)?\b|"
|
||||||
|
r"\bnpm\s+(?:run\s+)?test\b|\byarn\s+test\b|\bpnpm\s+test\b|"
|
||||||
|
r"\bcargo\s+test\b|\bgo\s+test\b|\bctest\b|"
|
||||||
|
r"\bmake\s+(?:[^;&|]*\s+)?test\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_CHECK_COMMAND_RE = re.compile(
|
||||||
|
r"(?ix)"
|
||||||
|
r"("
|
||||||
|
r"\bcmp\b|"
|
||||||
|
r"\bdiff\b|"
|
||||||
|
r"\bsha(?:1|224|256|384|512)?sum\b|"
|
||||||
|
r"\bmd5sum\b|"
|
||||||
|
r"\bgcc\b.*(?:&&|;).*\./|"
|
||||||
|
r"\bclang\b.*(?:&&|;).*\./|"
|
||||||
|
r"\bpython3?\b.*<<['\"]?PY\b.*\bassert\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_COMPARISON_COMMAND_RE = re.compile(r"(?i)\b(?:cmp|diff)\b")
|
||||||
|
_FAILURE_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"^FAILED\s+|"
|
||||||
|
r"\b\d+\s+failed\b|"
|
||||||
|
r"\bAssertionError\b|"
|
||||||
|
r"\bFileNotFoundError\b|"
|
||||||
|
r"\bTimeoutError\b|"
|
||||||
|
r"\bcommand not found\b|"
|
||||||
|
r"\bError:\s+Command timed out\b|"
|
||||||
|
r"\bFAILURES?\b|"
|
||||||
|
r"\bTEST FAILED\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_SUCCESS_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b\d+\s+passed\b|"
|
||||||
|
r"\bOK\b|"
|
||||||
|
r"\bTEST PASSED\b|"
|
||||||
|
r"\bExit code:\s*0\b"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_SUCCESS_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b(?:cmp|diff|test|verify)_exit:\s*0\b|"
|
||||||
|
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*0\s*$"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_ARTIFACT_FAILURE_RE = re.compile(
|
||||||
|
r"(?im)"
|
||||||
|
r"("
|
||||||
|
r"\b(?:cmp|diff|test|verify)_exit:\s*[1-9]\d*\b|"
|
||||||
|
r"^\s*(?:cmp|diff|match|same|image|ppm|stdout|stderr|out|err)[\w.-]*:\s*[1-9]\d*\s*$"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_FAILED_TEST_RE = re.compile(r"(?m)^FAILED\s+([^\s]+)")
|
||||||
|
_PYTEST_SHORT_RE = re.compile(r"(?m)^_{3,}\s+([A-Za-z0-9_./:-]+)\s+_{3,}$")
|
||||||
|
_ERROR_LINE_RE = re.compile(
|
||||||
|
r"(?m)"
|
||||||
|
r"^\s*(?:E\s+)?("
|
||||||
|
r"(?:AssertionError|FileNotFoundError|TimeoutError|ValueError|TypeError|RuntimeError)"
|
||||||
|
r"(?::[^\n]*)?|"
|
||||||
|
r"assert\s+[^\n]+|"
|
||||||
|
r"[^:\n]+:\s+line\s+\d+:\s+[^:\n]+:\s+command not found|"
|
||||||
|
r"Error:\s+[^\n]+|"
|
||||||
|
r"TEST FAILED[^\n]*"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
_MISSING_PATH_RE = re.compile(
|
||||||
|
r"(?i)"
|
||||||
|
r"(?:No such file or directory:\s*['\"]([^'\"]+)['\"]|"
|
||||||
|
r"(?:file|path)\s+([^\s'\"]+)\s+does not exist|"
|
||||||
|
r"cannot open file\s+['\"]([^'\"]+)['\"])"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_verification_result(
|
||||||
|
*,
|
||||||
|
command: str,
|
||||||
|
output: str,
|
||||||
|
exit_code: int | None,
|
||||||
|
timed_out: bool = False,
|
||||||
|
) -> VerificationAnalysis | None:
|
||||||
|
"""Return a verification summary when a command/output looks like a test."""
|
||||||
|
|
||||||
|
command = " ".join((command or "").split())
|
||||||
|
looks_like_test_command = bool(_TEST_COMMAND_RE.search(command))
|
||||||
|
looks_like_artifact_check = bool(_ARTIFACT_CHECK_COMMAND_RE.search(command))
|
||||||
|
looks_like_comparison_command = bool(_COMPARISON_COMMAND_RE.search(command))
|
||||||
|
looks_like_verification = looks_like_test_command or looks_like_artifact_check
|
||||||
|
failure_seen = bool(_FAILURE_RE.search(output))
|
||||||
|
success_seen = bool(_SUCCESS_RE.search(output))
|
||||||
|
artifact_success_seen = bool(_ARTIFACT_SUCCESS_RE.search(output)) and (
|
||||||
|
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*0\b", output, flags=re.I))
|
||||||
|
)
|
||||||
|
artifact_failure_seen = bool(_ARTIFACT_FAILURE_RE.search(output)) and (
|
||||||
|
looks_like_comparison_command or bool(re.search(r"\b(?:test|verify)_exit:\s*[1-9]\d*\b", output, flags=re.I))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not looks_like_test_command and not failure_seen:
|
||||||
|
if not (looks_like_artifact_check and artifact_success_seen and exit_code == 0):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if (
|
||||||
|
(timed_out and looks_like_verification)
|
||||||
|
or (exit_code not in (None, 0) and (looks_like_verification or failure_seen))
|
||||||
|
or failure_seen
|
||||||
|
or artifact_failure_seen
|
||||||
|
):
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
failed_tests=_unique(_FAILED_TEST_RE.findall(output), limit=8),
|
||||||
|
primary_errors=_extract_primary_errors(output),
|
||||||
|
missing_artifacts=_extract_missing_artifacts(output),
|
||||||
|
timed_out=timed_out,
|
||||||
|
)
|
||||||
|
|
||||||
|
if looks_like_test_command and exit_code == 0 and success_seen:
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if looks_like_artifact_check and exit_code == 0 and artifact_success_seen:
|
||||||
|
return VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command=command,
|
||||||
|
exit_code=exit_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def append_verification_feedback(output: str, analysis: VerificationAnalysis | None) -> str:
|
||||||
|
"""Append model-facing feedback for failed verification results."""
|
||||||
|
|
||||||
|
if analysis is None or analysis.status != "failed":
|
||||||
|
return output
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
"[Verification Feedback]",
|
||||||
|
"Verification status: failed.",
|
||||||
|
"Do not call complete_goal or present the task as finished until this is fixed and a verification passes.",
|
||||||
|
]
|
||||||
|
if analysis.command:
|
||||||
|
lines.append(f"Command: {analysis.command[:240]}")
|
||||||
|
if analysis.exit_code is not None:
|
||||||
|
lines.append(f"Exit code: {analysis.exit_code}")
|
||||||
|
if analysis.timed_out:
|
||||||
|
lines.append("Failure type: command timeout")
|
||||||
|
if analysis.failed_tests:
|
||||||
|
lines.append("Failed tests:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||||
|
if analysis.primary_errors:
|
||||||
|
lines.append("Primary errors:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||||
|
if analysis.missing_artifacts:
|
||||||
|
lines.append("Missing artifacts:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||||
|
lines.append("Next action: inspect the failing assertion, fix the implementation or artifact, then rerun the most specific verification command.")
|
||||||
|
lines.append("[/Verification Feedback]")
|
||||||
|
return output.rstrip() + "\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def record_verification_observation(session_key: str | None, analysis: VerificationAnalysis | None) -> None:
|
||||||
|
"""Remember the latest verification signal for a session."""
|
||||||
|
|
||||||
|
if not session_key or analysis is None:
|
||||||
|
return
|
||||||
|
global _SEQUENCE
|
||||||
|
_SEQUENCE += 1
|
||||||
|
_OBSERVATIONS[session_key] = VerificationObservation(
|
||||||
|
analysis=analysis,
|
||||||
|
sequence=_SEQUENCE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def latest_verification_observation(session_key: str | None) -> VerificationObservation | None:
|
||||||
|
if not session_key:
|
||||||
|
return None
|
||||||
|
return _OBSERVATIONS.get(session_key)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_verification_observation(session_key: str | None) -> None:
|
||||||
|
if session_key:
|
||||||
|
_OBSERVATIONS.pop(session_key, None)
|
||||||
|
|
||||||
|
|
||||||
|
def format_completion_gate_message(observation: VerificationObservation) -> str:
|
||||||
|
"""Build the complete_goal soft-gate message for unresolved failures."""
|
||||||
|
|
||||||
|
analysis = observation.analysis
|
||||||
|
lines = [
|
||||||
|
"Recent verification appears to have failed, so the goal is not marked complete yet.",
|
||||||
|
"Continue fixing the task and rerun verification before completing.",
|
||||||
|
]
|
||||||
|
if analysis.command:
|
||||||
|
lines.append(f"Last failed verification command: {analysis.command[:240]}")
|
||||||
|
if analysis.failed_tests:
|
||||||
|
lines.append("Failed tests: " + ", ".join(analysis.failed_tests[:5]))
|
||||||
|
if analysis.primary_errors:
|
||||||
|
lines.append("Primary error: " + analysis.primary_errors[0])
|
||||||
|
if analysis.missing_artifacts:
|
||||||
|
lines.append("Missing artifact: " + analysis.missing_artifacts[0])
|
||||||
|
lines.append(
|
||||||
|
"If you are intentionally stopping with known failures, call complete_goal again with remaining_failures describing them honestly."
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_primary_errors(output: str) -> tuple[str, ...]:
|
||||||
|
candidates: list[str] = []
|
||||||
|
for match in _ERROR_LINE_RE.findall(output):
|
||||||
|
text = " ".join(match.split())
|
||||||
|
if text and text not in candidates:
|
||||||
|
candidates.append(text[:240])
|
||||||
|
if len(candidates) >= 8:
|
||||||
|
break
|
||||||
|
if not candidates:
|
||||||
|
for match in _PYTEST_SHORT_RE.findall(output):
|
||||||
|
text = " ".join(match.split())
|
||||||
|
if text and text not in candidates:
|
||||||
|
candidates.append(text[:240])
|
||||||
|
if len(candidates) >= 4:
|
||||||
|
break
|
||||||
|
return tuple(candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_missing_artifacts(output: str) -> tuple[str, ...]:
|
||||||
|
paths: list[str] = []
|
||||||
|
for groups in _MISSING_PATH_RE.findall(output):
|
||||||
|
path = next((item for item in groups if item), "")
|
||||||
|
if path and path not in paths:
|
||||||
|
paths.append(path[:240])
|
||||||
|
if len(paths) >= 8:
|
||||||
|
break
|
||||||
|
return tuple(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(items: list[str], *, limit: int) -> tuple[str, ...]:
|
||||||
|
out: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
text = " ".join(item.split())
|
||||||
|
if text and text not in out:
|
||||||
|
out.append(text[:240])
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return tuple(out)
|
||||||
+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
|
|
||||||
+15
-12
@@ -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,
|
metadata=meta,
|
||||||
tool_hint=tool_hint,
|
|
||||||
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
|
||||||
@@ -459,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)
|
||||||
@@ -472,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
|
||||||
@@ -487,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:
|
||||||
|
|||||||
+50
-153
@@ -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
|
||||||
@@ -277,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:
|
||||||
@@ -316,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)
|
||||||
@@ -382,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, _stream_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.
|
||||||
@@ -499,15 +404,10 @@ 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)
|
first_metadata = first_msg.metadata or {}
|
||||||
first_stream_id = first_event.stream_id if isinstance(first_event, StreamDeltaEvent) else None
|
target_key = (first_msg.channel, first_msg.chat_id, first_metadata.get("_stream_id"))
|
||||||
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,
|
||||||
@@ -519,29 +419,21 @@ 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)
|
next_metadata = next_msg.metadata or {}
|
||||||
next_stream_id = (
|
|
||||||
next_event.stream_id
|
|
||||||
if isinstance(next_event, StreamDeltaEvent | StreamEndEvent)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
same_target = (
|
same_target = (
|
||||||
next_msg.channel,
|
next_msg.channel,
|
||||||
next_msg.chat_id,
|
next_msg.chat_id,
|
||||||
next_stream_id,
|
next_metadata.get("_stream_id"),
|
||||||
) == target_key
|
) == target_key
|
||||||
is_delta = isinstance(next_event, StreamDeltaEvent)
|
is_delta = next_metadata.get("_stream_delta")
|
||||||
is_end = isinstance(next_event, StreamEndEvent)
|
is_end = next_metadata.get("_stream_end")
|
||||||
|
|
||||||
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:
|
||||||
@@ -549,7 +441,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
|
||||||
@@ -707,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):
|
||||||
@@ -795,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
|
||||||
@@ -890,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="*",
|
content="",
|
||||||
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
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)
|
||||||
|
|||||||
+10
-54
@@ -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"):
|
await self._flush_tool_hints(chat_id)
|
||||||
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)
|
|
||||||
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."""
|
||||||
|
|||||||
@@ -499,30 +499,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,9 +532,6 @@ 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]
|
||||||
|
|||||||
+18
-97
@@ -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()
|
||||||
@@ -806,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(
|
|
||||||
"[red]Error: host is 0.0.0.0 (all interfaces) but api_key is not set. "
|
|
||||||
"Set api.api_key in config to prevent unauthenticated access.[/red]"
|
|
||||||
)
|
|
||||||
raise typer.Exit(1)
|
|
||||||
console.print(
|
console.print(
|
||||||
"[yellow]API is bound to all interfaces "
|
"[yellow]Warning:[/yellow] API is bound to all interfaces. "
|
||||||
"(authentication required).[/yellow]"
|
"Only do this behind a trusted network boundary, firewall, or reverse proxy."
|
||||||
)
|
)
|
||||||
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()
|
||||||
@@ -1464,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()
|
||||||
|
|
||||||
@@ -1472,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
|
||||||
|
|
||||||
@@ -1499,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(
|
||||||
@@ -1552,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] = {}
|
||||||
@@ -1765,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."""
|
||||||
@@ -1801,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)
|
||||||
@@ -1857,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")
|
||||||
@@ -1882,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(
|
||||||
@@ -131,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,
|
||||||
@@ -148,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,
|
||||||
@@ -158,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(
|
||||||
@@ -648,7 +626,7 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
|
|||||||
|
|
||||||
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
|
_GOAL_PROMPT_TEMPLATE = """The user declared a sustained objective for this thread.
|
||||||
|
|
||||||
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
Inspect or clarify if needed, then call `long_task` with the refined objective (and optional short ui_summary). Work proceeds as normal assistant turns using your usual tools. When the objective is fully done and verified, call `complete_goal` with a brief recap plus verification_summary / commands_run / artifacts_created when applicable. If the user later cancels or changes direction, still call `complete_goal` with an honest recap (then `long_task` again only after there is no active goal). Do not use `long_task` / `complete_goal` for trivial one-shot answers.
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
{goal}
|
{goal}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-35
@@ -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
|
||||||
@@ -358,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:
|
||||||
@@ -457,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:
|
||||||
@@ -646,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'))
|
||||||
|
|
||||||
@@ -708,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()
|
||||||
@@ -720,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
|
||||||
@@ -734,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"
|
||||||
@@ -759,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
|
||||||
@@ -794,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"
|
||||||
@@ -839,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):
|
||||||
@@ -859,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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -26,6 +27,25 @@ from nanobot.providers.openai_responses import (
|
|||||||
|
|
||||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
DEFAULT_ORIGINATOR = "nanobot"
|
DEFAULT_ORIGINATOR = "nanobot"
|
||||||
|
_RESPONSE_FAILED_PREFIX = "Response failed:"
|
||||||
|
_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||||
|
"overloaded",
|
||||||
|
"overloaded_error",
|
||||||
|
"rate_limit_exceeded",
|
||||||
|
"request_limit_exceeded",
|
||||||
|
"requests_limit_exceeded",
|
||||||
|
"server_error",
|
||||||
|
"server_is_overloaded",
|
||||||
|
"service_unavailable",
|
||||||
|
"temporarily_unavailable",
|
||||||
|
"too_many_requests",
|
||||||
|
})
|
||||||
|
_NON_RETRYABLE_RESPONSE_FAILED_TOKENS = frozenset({
|
||||||
|
"content_filter",
|
||||||
|
"content_policy_violation",
|
||||||
|
"cyber_policy",
|
||||||
|
"safety_violation",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class OpenAICodexProvider(LLMProvider):
|
class OpenAICodexProvider(LLMProvider):
|
||||||
@@ -33,14 +53,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 +72,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 +94,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 +107,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 +219,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()
|
||||||
@@ -258,6 +266,8 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
|
|
||||||
status_code = getattr(exc, "status_code", None)
|
status_code = getattr(exc, "status_code", None)
|
||||||
error_kind: str | None = None
|
error_kind: str | None = None
|
||||||
|
error_type = getattr(exc, "error_type", None)
|
||||||
|
error_code = getattr(exc, "error_code", None)
|
||||||
default_detail: str | None = None
|
default_detail: str | None = None
|
||||||
should_retry: bool | None = getattr(exc, "should_retry", None)
|
should_retry: bool | None = getattr(exc, "should_retry", None)
|
||||||
|
|
||||||
@@ -277,12 +287,20 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
error_kind = "http"
|
error_kind = "http"
|
||||||
default_detail = "HTTP request failed"
|
default_detail = "HTTP request failed"
|
||||||
|
|
||||||
|
failed_type, failed_code = _extract_response_failed_error(detail)
|
||||||
|
if failed_type or failed_code:
|
||||||
|
error_kind = error_kind or "provider"
|
||||||
|
error_type = failed_type or error_type
|
||||||
|
error_code = failed_code or error_code
|
||||||
|
if should_retry is None:
|
||||||
|
should_retry = _should_retry_response_failed(error_type, error_code, detail)
|
||||||
|
|
||||||
if status_code is not None and should_retry is None:
|
if status_code is not None and should_retry is None:
|
||||||
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
retry_content = None if int(status_code) == 429 and isinstance(exc, _CodexHTTPError) else detail
|
||||||
should_retry = _should_retry_status(
|
should_retry = _should_retry_status(
|
||||||
int(status_code),
|
int(status_code),
|
||||||
getattr(exc, "error_type", None),
|
error_type,
|
||||||
getattr(exc, "error_code", None),
|
error_code,
|
||||||
retry_content,
|
retry_content,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -295,13 +313,56 @@ def _codex_error_response(exc: Exception) -> LLMResponse:
|
|||||||
retry_after=retry_after,
|
retry_after=retry_after,
|
||||||
error_status_code=int(status_code) if status_code is not None else None,
|
error_status_code=int(status_code) if status_code is not None else None,
|
||||||
error_kind=error_kind,
|
error_kind=error_kind,
|
||||||
error_type=getattr(exc, "error_type", None),
|
error_type=error_type,
|
||||||
error_code=getattr(exc, "error_code", None),
|
error_code=error_code,
|
||||||
error_retry_after_s=retry_after,
|
error_retry_after_s=retry_after,
|
||||||
error_should_retry=should_retry,
|
error_should_retry=should_retry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_response_failed_error(detail: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract provider semantic error fields from Responses SSE failures."""
|
||||||
|
if _RESPONSE_FAILED_PREFIX not in detail:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
payload = detail.split(_RESPONSE_FAILED_PREFIX, 1)[1].strip()
|
||||||
|
if not payload:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
parsed: Any = None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(payload)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
parsed = ast.literal_eval(payload)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
error_type, error_code = LLMProvider._extract_error_type_code(parsed or payload)
|
||||||
|
return error_type, error_code
|
||||||
|
|
||||||
|
|
||||||
|
def _should_retry_response_failed(
|
||||||
|
error_type: str | None,
|
||||||
|
error_code: str | None,
|
||||||
|
detail: str,
|
||||||
|
) -> bool | None:
|
||||||
|
semantic_tokens = {
|
||||||
|
token for token in (
|
||||||
|
LLMProvider._normalize_error_token(error_type),
|
||||||
|
LLMProvider._normalize_error_token(error_code),
|
||||||
|
)
|
||||||
|
if token is not None
|
||||||
|
}
|
||||||
|
if any(token in _NON_RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||||
|
return False
|
||||||
|
if any(token in _RETRYABLE_RESPONSE_FAILED_TOKENS for token in semantic_tokens):
|
||||||
|
return True
|
||||||
|
if LLMProvider._is_transient_error(detail):
|
||||||
|
return True
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
def _codex_log_summary(exc_type: str, response: LLMResponse) -> str:
|
||||||
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
"""Return a bounded diagnostic summary without request body or raw upstream payload."""
|
||||||
if response.error_status_code is not None:
|
if response.error_status_code is not None:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+19
-43
@@ -27,8 +27,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 +43,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 +99,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 +132,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 +143,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 +278,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 +359,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 +370,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),
|
||||||
)
|
)
|
||||||
@@ -563,10 +539,9 @@ 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
|
||||||
|
|
||||||
@@ -640,6 +615,7 @@ class SessionManager:
|
|||||||
the most recent writes.
|
the most recent writes.
|
||||||
"""
|
"""
|
||||||
path = self._get_session_path(session.key)
|
path = self._get_session_path(session.key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -918,7 +894,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,
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Those belong to the execution phase after the marker is set.
|
|||||||
|
|
||||||
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
|
- **`long_task`** — Register **one** sustained objective per thread. Call it promptly once the user has asked for a sustained task. The `goal` should follow the idempotent-goal rules below, but it should be produced quickly from the user's request—not after a long hidden planning pass.
|
||||||
|
|
||||||
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
- **`complete_goal`** — Close bookkeeping for the **current** active goal. Call when work is **done**, **and also** when the user **cancels**, **changes direction**, or **replaces** the objective: use **`recap`** to state honestly what happened (e.g. cancelled, partially done, superseded). For coding or file-producing tasks, include **`verification_summary`**, **`commands_run`**, and **`artifacts_created`** when possible; if stopping with known unresolved issues, fill **`remaining_failures`** honestly. Then you may call **`long_task`** again for a **new** objective after the session shows no active goal (or after the user agrees to replace).
|
||||||
|
|
||||||
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
|
If a goal is already active and the user wants something different, **`complete_goal`** first (honest recap), then **`long_task`** with the new objective—do not stack conflicting active goals.
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Use this when the goal is to **build or reshape a codebase** (app, service, tool
|
|||||||
|
|
||||||
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
|
1. **Modular layout** — Split into **meaningful modules** (directories + files with clear responsibilities: entrypoints, domain logic, config, infra, CLI/UI routes, etc.). **Do not** default to dumping an entire project into one giant source file unless the user explicitly wants a minimal single-file artifact.
|
||||||
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
|
2. **Conventional structure** — Follow normal practice for that stack (separation of concerns, sensible naming, config vs code, reusable helpers). Aim for reviewable increments, not unreadable blobs.
|
||||||
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter.
|
3. **Verify as you go** — Run/format/lint/tests the project affords after meaningful chunks so the tree stays truthful; bake **checks or manual steps into the goal** when they matter. Before `complete_goal`, run the smallest reliable verification you can and summarize it in `verification_summary`.
|
||||||
|
|
||||||
## Look things up instead of guessing
|
## Look things up instead of guessing
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+100
-21
@@ -290,7 +290,8 @@ def current_time_str(timezone: str | None = None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS = 800
|
||||||
|
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS = 80
|
||||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||||
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
|
||||||
_TOOL_RESULT_MAX_BUCKETS = 32
|
_TOOL_RESULT_MAX_BUCKETS = 32
|
||||||
@@ -404,22 +405,106 @@ def stringify_text_blocks(content: list[dict[str, Any]]) -> str | None:
|
|||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _render_tool_result_reference(
|
def build_structured_output_summary(
|
||||||
filepath: Path,
|
title: str,
|
||||||
|
text: str,
|
||||||
*,
|
*,
|
||||||
original_size: int,
|
max_chars: int,
|
||||||
preview: str,
|
metadata: list[tuple[str, Any]] | None = None,
|
||||||
truncated_preview: bool,
|
analysis: Any | None = None,
|
||||||
|
guidance: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
result = (
|
"""Return a compact, structured head/tail summary for oversized tool output."""
|
||||||
f"[tool output persisted]\n"
|
|
||||||
f"Full output saved to: {filepath}\n"
|
if max_chars <= 0:
|
||||||
f"Original size: {original_size} chars\n"
|
return text
|
||||||
f"Preview:\n{preview}"
|
edge_chars = min(
|
||||||
|
_TOOL_RESULT_SUMMARY_MAX_EDGE_CHARS,
|
||||||
|
max(_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS, max_chars // 3),
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
head = text[:edge_chars]
|
||||||
|
if len(text) > edge_chars * 2:
|
||||||
|
tail: str | None = text[-edge_chars:]
|
||||||
|
omitted_middle_chars = len(text) - len(head) - len(tail)
|
||||||
|
else:
|
||||||
|
tail = None
|
||||||
|
omitted_middle_chars = 0
|
||||||
|
result = _render_structured_output_summary(
|
||||||
|
title,
|
||||||
|
metadata=metadata or [],
|
||||||
|
guidance=guidance,
|
||||||
|
analysis=analysis,
|
||||||
|
head=head,
|
||||||
|
tail=tail,
|
||||||
|
omitted_middle_chars=omitted_middle_chars,
|
||||||
|
)
|
||||||
|
if len(result) <= max_chars or edge_chars <= _TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS:
|
||||||
|
return truncate_text(result, max_chars)
|
||||||
|
overflow = len(result) - max_chars
|
||||||
|
edge_chars = max(
|
||||||
|
_TOOL_RESULT_SUMMARY_MIN_EDGE_CHARS,
|
||||||
|
edge_chars - max(overflow // 2 + 1, 16),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_structured_output_summary(
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
metadata: list[tuple[str, Any]],
|
||||||
|
guidance: str | None,
|
||||||
|
analysis: Any | None,
|
||||||
|
head: str,
|
||||||
|
tail: str | None,
|
||||||
|
omitted_middle_chars: int,
|
||||||
|
) -> str:
|
||||||
|
lines = [title]
|
||||||
|
lines.extend(f"{key}: {value}" for key, value in metadata)
|
||||||
|
if omitted_middle_chars:
|
||||||
|
lines.append(f"truncation: {omitted_middle_chars:,} chars truncated from the middle")
|
||||||
|
if guidance:
|
||||||
|
lines.append(f"guidance: {guidance}")
|
||||||
|
lines.extend(_verification_summary_lines(analysis))
|
||||||
|
lines.extend(["head:", head])
|
||||||
|
if tail is not None:
|
||||||
|
lines.extend(["tail:", tail])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _verification_summary_lines(analysis: Any | None) -> list[str]:
|
||||||
|
if analysis is None or getattr(analysis, "status", None) != "failed":
|
||||||
|
return []
|
||||||
|
lines = ["verification_status: failed"]
|
||||||
|
if getattr(analysis, "timed_out", False):
|
||||||
|
lines.append("failure_type: command timeout")
|
||||||
|
if getattr(analysis, "failed_tests", ()):
|
||||||
|
lines.append("failed_tests:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.failed_tests)
|
||||||
|
if getattr(analysis, "primary_errors", ()):
|
||||||
|
lines.append("primary_errors:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.primary_errors)
|
||||||
|
if getattr(analysis, "missing_artifacts", ()):
|
||||||
|
lines.append("missing_artifacts:")
|
||||||
|
lines.extend(f"- {item}" for item in analysis.missing_artifacts)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tool_result_reference(filepath: Path, text: str, *, max_chars: int) -> str:
|
||||||
|
return build_structured_output_summary(
|
||||||
|
"[tool output persisted]",
|
||||||
|
text,
|
||||||
|
max_chars=max_chars,
|
||||||
|
metadata=[
|
||||||
|
("tool_output_id", filepath.stem),
|
||||||
|
("original_size_chars", len(text)),
|
||||||
|
("storage", "internal audit artifact"),
|
||||||
|
],
|
||||||
|
guidance=(
|
||||||
|
"Use this head/tail summary first. Avoid reading persisted "
|
||||||
|
"tool-output files wholesale; rerun a narrower command when "
|
||||||
|
"more detail is needed."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if truncated_preview:
|
|
||||||
result += "\n...\n(Read the saved file if you need the full output.)"
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _bucket_mtime(path: Path) -> float:
|
def _bucket_mtime(path: Path) -> float:
|
||||||
@@ -494,13 +579,7 @@ def maybe_persist_tool_result(
|
|||||||
else:
|
else:
|
||||||
_write_text_atomic(path, text_payload)
|
_write_text_atomic(path, text_payload)
|
||||||
|
|
||||||
preview = text_payload[:_TOOL_RESULT_PREVIEW_CHARS]
|
return _build_tool_result_reference(path, text_payload, max_chars=max_chars)
|
||||||
return _render_tool_result_reference(
|
|
||||||
path,
|
|
||||||
original_size=len(text_payload),
|
|
||||||
preview=preview,
|
|
||||||
truncated_preview=len(text_payload) > _TOOL_RESULT_PREVIEW_CHARS,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def split_message(content: str, max_len: int = 2000) -> list[str]:
|
def split_message(content: str, max_len: int = 2000) -> list[str]:
|
||||||
|
|||||||
@@ -42,6 +42,27 @@ SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
|||||||
"objective using your tools, or call complete_goal if the work is truly finished."
|
"objective using your tools, or call complete_goal if the work is truly finished."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RUNTIME_BUDGET_CONVERGENCE_PROMPT = """\
|
||||||
|
[Runtime Budget Notice]
|
||||||
|
You have used {used_iterations} of {max_iterations} model/tool iterations for this turn. \
|
||||||
|
{remaining_iterations} iteration(s) remain before NanoBot must finalize without more tools.
|
||||||
|
|
||||||
|
Switch to convergence mode: stop broad exploration, choose the smallest high-signal command or edit, \
|
||||||
|
verify the likely solution, and preserve enough budget for a final answer. For coding or \
|
||||||
|
file-producing tasks, do not mark the work complete until the smallest reliable verification passes, \
|
||||||
|
or clearly state remaining failures.
|
||||||
|
[/Runtime Budget Notice]"""
|
||||||
|
|
||||||
|
RUNTIME_BUDGET_FINAL_PROMPT = """\
|
||||||
|
[Runtime Budget Notice]
|
||||||
|
Only {remaining_iterations} of {max_iterations} model/tool iteration(s) remain before NanoBot must \
|
||||||
|
finalize without more tools.
|
||||||
|
|
||||||
|
Finalize the solution path now: avoid new broad searches or builds unless essential, make the \
|
||||||
|
smallest final fix or artifact, run one targeted verification if possible, then answer honestly with \
|
||||||
|
the evidence or remaining failures.
|
||||||
|
[/Runtime Budget Notice]"""
|
||||||
|
|
||||||
|
|
||||||
def empty_tool_result_message(tool_name: str) -> str:
|
def empty_tool_result_message(tool_name: str) -> str:
|
||||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||||
@@ -88,6 +109,25 @@ def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
|||||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
||||||
|
|
||||||
|
|
||||||
|
def build_runtime_budget_notice_message(
|
||||||
|
*,
|
||||||
|
level: int,
|
||||||
|
max_iterations: int,
|
||||||
|
used_iterations: int,
|
||||||
|
remaining_iterations: int,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Prompt the model to converge as the generic tool-iteration budget runs low."""
|
||||||
|
template = RUNTIME_BUDGET_FINAL_PROMPT if level >= 2 else RUNTIME_BUDGET_CONVERGENCE_PROMPT
|
||||||
|
return {
|
||||||
|
"role": "user",
|
||||||
|
"content": template.format(
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
used_iterations=used_iterations,
|
||||||
|
remaining_iterations=remaining_iterations,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||||
"""Stable signature for repeated external lookups we want to throttle."""
|
"""Stable signature for repeated external lookups we want to throttle."""
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
|
|||||||
@@ -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,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]]:
|
||||||
@@ -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 item.get(CRON_HISTORY_META) is True:
|
|
||||||
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,
|
||||||
@@ -276,37 +244,31 @@ 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
|
||||||
|
scanned_records += 1
|
||||||
|
scanned_chars += len(line)
|
||||||
|
if (
|
||||||
|
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
|
):
|
||||||
|
break
|
||||||
item = json.loads(line)
|
item = json.loads(line)
|
||||||
timestamp = _visible_message_timestamp(item)
|
if item.get("_type") == "metadata":
|
||||||
if timestamp is not None:
|
continue
|
||||||
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
if item.get(CRON_HISTORY_META) is True:
|
||||||
if not preview_done:
|
continue
|
||||||
scanned_records += 1
|
text = _message_preview_text(item)
|
||||||
scanned_chars += len(line)
|
if not text:
|
||||||
if (
|
continue
|
||||||
scanned_records > _SESSION_LIST_PREVIEW_MAX_RECORDS
|
if item.get("role") == "user":
|
||||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
preview = text
|
||||||
):
|
break
|
||||||
preview_done = True
|
if not fallback_preview and item.get("role") == "assistant":
|
||||||
continue
|
fallback_preview = text
|
||||||
if item.get(CRON_HISTORY_META) is True:
|
|
||||||
continue
|
|
||||||
text = _message_preview_text(item)
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
if item.get("role") == "user":
|
|
||||||
preview = text
|
|
||||||
preview_done = True
|
|
||||||
continue
|
|
||||||
if not fallback_preview and item.get("role") == "assistant":
|
|
||||||
fallback_preview = text
|
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
created_at_s = data.get("created_at")
|
created_at_s = data.get("created_at")
|
||||||
updated_at_s = data.get("updated_at")
|
updated_at_s = data.get("updated_at")
|
||||||
@@ -320,11 +282,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)
|
||||||
|
|||||||
+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):
|
||||||
|
|||||||
@@ -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,13 +8,6 @@ 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
|
||||||
@@ -772,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",
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -781,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"
|
||||||
|
|
||||||
|
|
||||||
@@ -848,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
|
||||||
@@ -862,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:
|
||||||
|
|||||||
@@ -135,46 +135,6 @@ async def test_runner_tool_error_sets_final_content():
|
|||||||
assert result.stop_reason == "tool_error"
|
assert result.stop_reason == "tool_error"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_preserves_successful_exec_output_that_starts_with_error():
|
|
||||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
|
|
||||||
async def chat_with_retry(*, messages, **kwargs):
|
|
||||||
if not any(msg.get("role") == "tool" for msg in messages):
|
|
||||||
return LLMResponse(
|
|
||||||
content="working",
|
|
||||||
tool_calls=[
|
|
||||||
ToolCallRequest(id="call_1", name="exec", arguments={"command": "report"})
|
|
||||||
],
|
|
||||||
usage={},
|
|
||||||
)
|
|
||||||
return LLMResponse(content="done", usage={})
|
|
||||||
|
|
||||||
provider.chat_with_retry = chat_with_retry
|
|
||||||
output = "Error: generated report successfully\n\nExit code: 0"
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
tools.execute = AsyncMock(return_value=output)
|
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
|
||||||
result = await runner.run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "run report"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
fail_on_tool_error=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.final_content == "done"
|
|
||||||
assert result.stop_reason == "completed"
|
|
||||||
assert result.tool_events == [
|
|
||||||
{"name": "exec", "status": "ok", "detail": "Error: generated report successfully Exit code: 0"}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_tool_error_preserves_tool_results_in_messages():
|
async def test_runner_tool_error_preserves_tool_results_in_messages():
|
||||||
"""When a tool raises a fatal error, its results must still be appended
|
"""When a tool raises a fatal error, its results must still be appended
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from nanobot.agent.context_governance import (
|
|||||||
)
|
)
|
||||||
from nanobot.agent.runner import AgentRunSpec
|
from nanobot.agent.runner import AgentRunSpec
|
||||||
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
|
||||||
|
|
||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
@@ -877,162 +877,3 @@ def test_snip_history_no_user_at_all_falls_back_gracefully(monkeypatch):
|
|||||||
assert non_system[0]["role"] in ("user", "tool"), (
|
assert non_system[0]["role"] in ("user", "tool"), (
|
||||||
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
|
f"Safety net should ensure first non-system is user/tool, got {non_system[0]['role']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Malformed tool_call name guard (missing/non-string name wedges the session
|
|
||||||
# upstream: messages.content.N.tool_use.name: Input should be a valid string)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_drop_malformed_tool_calls_trims_response():
|
|
||||||
"""LLM response tool_calls with a missing/empty name are dropped in place."""
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
response = LLMResponse(
|
|
||||||
content=None,
|
|
||||||
tool_calls=[
|
|
||||||
ToolCallRequest(id="1", name=None, arguments={}),
|
|
||||||
ToolCallRequest(id="2", name="", arguments={}),
|
|
||||||
ToolCallRequest(id="3", name="read_file", arguments={}),
|
|
||||||
],
|
|
||||||
finish_reason="tool_calls",
|
|
||||||
)
|
|
||||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
|
||||||
assert [tc.name for tc in response.tool_calls] == ["read_file"]
|
|
||||||
assert response.finish_reason == "tool_calls"
|
|
||||||
assert response.should_execute_tools is True
|
|
||||||
assert dropped == 2
|
|
||||||
assert all_dropped is False
|
|
||||||
assert orig == "tool_calls"
|
|
||||||
|
|
||||||
|
|
||||||
def test_drop_malformed_tool_calls_all_bad_disables_execution():
|
|
||||||
"""If every tool call is malformed, execution is disabled (no empty exec)."""
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
response = LLMResponse(
|
|
||||||
content="some text",
|
|
||||||
tool_calls=[ToolCallRequest(id="1", name=None, arguments={})],
|
|
||||||
finish_reason="tool_calls",
|
|
||||||
)
|
|
||||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
|
||||||
assert response.tool_calls == []
|
|
||||||
assert response.finish_reason == "stop"
|
|
||||||
assert response.should_execute_tools is False
|
|
||||||
assert dropped == 1
|
|
||||||
assert all_dropped is True
|
|
||||||
assert orig == "tool_calls"
|
|
||||||
|
|
||||||
|
|
||||||
def test_drop_malformed_returns_tuple_no_calls():
|
|
||||||
"""No tool calls returns (0, False, current_finish_reason)."""
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
response = LLMResponse(content="hi", finish_reason="stop")
|
|
||||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
|
||||||
assert dropped == 0
|
|
||||||
assert all_dropped is False
|
|
||||||
assert orig == "stop"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_malformed_tool_calls_keeps_valid_calls_in_history():
|
|
||||||
"""A mixed assistant turn keeps only its valid tool_calls."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
|
|
||||||
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
|
|
||||||
]
|
|
||||||
result = ContextGovernor.strip_malformed_tool_calls(messages)
|
|
||||||
assert result is not messages # copied, original untouched
|
|
||||||
assert len(messages[1]["tool_calls"]) == 2 # original preserved
|
|
||||||
kept = result[1]["tool_calls"]
|
|
||||||
assert [tc["function"]["name"] for tc in kept] == ["exec"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_malformed_tool_calls_drops_empty_assistant_turn():
|
|
||||||
"""An assistant turn that is only a malformed call is removed entirely;
|
|
||||||
the existing orphan-result cleanup then drops its dangling tool result,
|
|
||||||
so a polluted session self-heals."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [
|
|
||||||
{"id": "bad", "type": "function", "function": {"name": None, "arguments": "{}"}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "bad", "name": "", "content": "r"},
|
|
||||||
]
|
|
||||||
stripped = ContextGovernor.strip_malformed_tool_calls(messages)
|
|
||||||
assert [m["role"] for m in stripped] == ["user", "tool"]
|
|
||||||
healed = ContextGovernor.drop_orphan_tool_results(stripped)
|
|
||||||
assert [m["role"] for m in healed] == ["user"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_malformed_tool_calls_noop_when_clean():
|
|
||||||
"""Clean history is returned unchanged (same object)."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"tool_calls": [
|
|
||||||
{"id": "ok", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "ok", "name": "exec", "content": "done"},
|
|
||||||
]
|
|
||||||
assert ContextGovernor.strip_malformed_tool_calls(messages) is messages
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_placeholder_assistant_messages_removes_omitted():
|
|
||||||
"""Placeholder assistant messages are removed; real messages kept."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{"role": "assistant", "content": "real response"},
|
|
||||||
{"role": "user", "content": "ok"},
|
|
||||||
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
|
|
||||||
{"role": "user", "content": "?"},
|
|
||||||
{"role": "assistant", "content": "[Previous assistant message omitted.]"},
|
|
||||||
{"role": "user", "content": "hello"},
|
|
||||||
]
|
|
||||||
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
|
|
||||||
assert [m["role"] for m in result] == [
|
|
||||||
"user", "assistant", "user", "user", "user",
|
|
||||||
]
|
|
||||||
assert result[1]["content"] == "real response"
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_placeholder_noop_when_clean():
|
|
||||||
"""Clean history is returned unchanged (same object)."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{"role": "assistant", "content": "hello back"},
|
|
||||||
]
|
|
||||||
assert ContextGovernor.strip_placeholder_assistant_messages(messages) is messages
|
|
||||||
|
|
||||||
|
|
||||||
def test_strip_placeholder_keeps_assistant_with_tool_calls():
|
|
||||||
"""A placeholder assistant that also carries tool_calls is kept."""
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "[Previous assistant message omitted.]",
|
|
||||||
"tool_calls": [
|
|
||||||
{"id": "1", "type": "function", "function": {"name": "exec", "arguments": "{}"}},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": "1", "name": "exec", "content": "done"},
|
|
||||||
]
|
|
||||||
result = ContextGovernor.strip_placeholder_assistant_messages(messages)
|
|
||||||
assert result is messages
|
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ async def test_runner_persists_large_tool_results_for_follow_up_calls(tmp_path):
|
|||||||
assert result.final_content == "done"
|
assert result.final_content == "done"
|
||||||
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
tool_message = next(msg for msg in captured_second_call if msg.get("role") == "tool")
|
||||||
assert "[tool output persisted]" in tool_message["content"]
|
assert "[tool output persisted]" in tool_message["content"]
|
||||||
assert "tool-results" in tool_message["content"]
|
assert "tool_output_id: call_big" in tool_message["content"]
|
||||||
|
assert "original_size_chars: 20000" in tool_message["content"]
|
||||||
|
assert "head:" in tool_message["content"]
|
||||||
|
assert "tail:" in tool_message["content"]
|
||||||
|
assert "Read the saved file" not in tool_message["content"]
|
||||||
|
assert str(tmp_path) not in tool_message["content"]
|
||||||
|
assert len(tool_message["content"]) <= 2048
|
||||||
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
assert (tmp_path / ".nanobot" / "tool-results" / "test_runner" / "call_big.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
@@ -76,6 +82,8 @@ def test_persist_tool_result_prunes_old_session_buckets(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert "[tool output persisted]" in persisted
|
assert "[tool output persisted]" in persisted
|
||||||
|
assert "tool_output_id: call_big" in persisted
|
||||||
|
assert "tool-results" not in persisted
|
||||||
assert not old_bucket.exists()
|
assert not old_bucket.exists()
|
||||||
assert recent_bucket.exists()
|
assert recent_bucket.exists()
|
||||||
assert (root / "current_session" / "call_big.txt").exists()
|
assert (root / "current_session" / "call_big.txt").exists()
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
|
||||||
from nanobot.agent.tools import ToolResult
|
|
||||||
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
|
||||||
|
|
||||||
@@ -22,6 +20,8 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
|
|||||||
we now hand the error back to the LLM as a recoverable tool result and
|
we now hand the error back to the LLM as a recoverable tool result and
|
||||||
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
|
rely on ``repeated_workspace_violation_error`` to throttle bypass loops.
|
||||||
"""
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
LLMResponse(
|
LLMResponse(
|
||||||
@@ -64,6 +64,8 @@ async def test_runner_does_not_abort_on_workspace_violation_anymore():
|
|||||||
|
|
||||||
def test_is_ssrf_violation_recognizes_private_url_blocks():
|
def test_is_ssrf_violation_recognizes_private_url_blocks():
|
||||||
"""SSRF rejections are classified separately from workspace boundaries."""
|
"""SSRF rejections are classified separately from workspace boundaries."""
|
||||||
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
|
ssrf_msg = "Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
|
assert AgentRunner._is_ssrf_violation(ssrf_msg) is True
|
||||||
assert AgentRunner._is_ssrf_violation(
|
assert AgentRunner._is_ssrf_violation(
|
||||||
@@ -86,6 +88,8 @@ def test_is_ssrf_violation_recognizes_private_url_blocks():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
|
async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
|
||||||
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
|
"""SSRF stays blocked, but the runtime gives the LLM a final chance to recover."""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
provider.chat_with_retry = AsyncMock(side_effect=[
|
||||||
LLMResponse(
|
LLMResponse(
|
||||||
@@ -103,7 +107,7 @@ async def test_runner_returns_non_retryable_hint_on_ssrf_violation():
|
|||||||
])
|
])
|
||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
tools.execute = AsyncMock(return_value=ToolResult.error(
|
tools.execute = AsyncMock(return_value=(
|
||||||
"Error: Command blocked by safety guard (internal/private URL detected)"
|
"Error: Command blocked by safety guard (internal/private URL detected)"
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -137,6 +141,8 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
|
|||||||
turn (silent hang on Telegram per #3605); now the LLM gets the soft
|
turn (silent hang on Telegram per #3605); now the LLM gets the soft
|
||||||
error back and can finalize on the next iteration.
|
error back and can finalize on the next iteration.
|
||||||
"""
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
captured_second_call: list[dict] = []
|
captured_second_call: list[dict] = []
|
||||||
|
|
||||||
@@ -157,9 +163,7 @@ async def test_runner_lets_llm_recover_from_shell_guard_path_outside():
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
tools.execute = AsyncMock(
|
tools.execute = AsyncMock(
|
||||||
return_value=ToolResult.error(
|
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
runner = AgentRunner(provider)
|
||||||
@@ -191,6 +195,8 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
|
|||||||
the runner replaces the tool result with a hard "stop trying" message
|
the runner replaces the tool result with a hard "stop trying" message
|
||||||
so the model finally gives up and surfaces the boundary to the user.
|
so the model finally gives up and surfaces the boundary to the user.
|
||||||
"""
|
"""
|
||||||
|
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||||
|
|
||||||
bypass_attempts = [
|
bypass_attempts = [
|
||||||
ToolCallRequest(
|
ToolCallRequest(
|
||||||
id=f"a{i}", name="exec",
|
id=f"a{i}", name="exec",
|
||||||
@@ -209,9 +215,7 @@ async def test_runner_throttles_repeated_workspace_bypass_attempts():
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
tools.execute = AsyncMock(
|
tools.execute = AsyncMock(
|
||||||
return_value=ToolResult.error(
|
return_value="Error: Command blocked by safety guard (path outside working dir)"
|
||||||
"Error: Command blocked by safety guard (path outside working dir)"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
runner = AgentRunner(provider)
|
runner = AgentRunner(provider)
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.context import ToolContext
|
|
||||||
from nanobot.agent.tools.loader import ToolLoader
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
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
|
||||||
@@ -63,40 +61,6 @@ class _DelayTool(Tool):
|
|||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
|
||||||
class _LegacyErrorPluginTool(Tool):
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "legacy_plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "legacy entry-point plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict:
|
|
||||||
return {"type": "object", "properties": {}, "required": []}
|
|
||||||
|
|
||||||
async def execute(self, **kwargs):
|
|
||||||
return "Error: legacy plugin failed"
|
|
||||||
|
|
||||||
|
|
||||||
class _StructuredSuccessPluginTool(Tool):
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "structured_success_plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "structured entry-point plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict:
|
|
||||||
return {"type": "object", "properties": {}, "required": []}
|
|
||||||
|
|
||||||
async def execute(self, **kwargs):
|
|
||||||
return ToolResult("Error: generated report successfully")
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_optional_tool_response(response: LLMResponse):
|
async def _run_optional_tool_response(response: LLMResponse):
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
calls = {"n": 0}
|
calls = {"n": 0}
|
||||||
@@ -127,20 +91,6 @@ async def _run_optional_tool_response(response: LLMResponse):
|
|||||||
return result, shared_events
|
return result, shared_events
|
||||||
|
|
||||||
|
|
||||||
def _load_entry_point_plugin(tool_cls: type[Tool], tmp_path) -> ToolRegistry:
|
|
||||||
mock_ep = MagicMock()
|
|
||||||
mock_ep.name = tool_cls.__name__
|
|
||||||
mock_ep.load.return_value = tool_cls
|
|
||||||
|
|
||||||
registry = ToolRegistry()
|
|
||||||
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
|
|
||||||
ToolLoader(test_classes=[]).load(
|
|
||||||
ToolContext(config=None, workspace=str(tmp_path)),
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
return registry
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_message(result, tool_call_id: str) -> dict:
|
def _tool_message(result, tool_call_id: str) -> dict:
|
||||||
return [
|
return [
|
||||||
msg for msg in result.messages
|
msg for msg in result.messages
|
||||||
@@ -370,63 +320,6 @@ async def test_runner_rejects_openai_responses_array_arguments_without_executing
|
|||||||
assert "parameters must be a JSON object" in tool_message["content"]
|
assert "parameters must be a JSON object" in tool_message["content"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_treats_legacy_entry_point_error_prefix_as_tool_error(tmp_path):
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="working",
|
|
||||||
tool_calls=[ToolCallRequest(id="call_1", name="legacy_plugin", arguments={})],
|
|
||||||
usage={},
|
|
||||||
))
|
|
||||||
|
|
||||||
result = await AgentRunner(provider).run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "run plugin"}],
|
|
||||||
tools=_load_entry_point_plugin(_LegacyErrorPluginTool, tmp_path),
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=1,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
fail_on_tool_error=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "tool_error"
|
|
||||||
assert result.tool_events == [
|
|
||||||
{"name": "legacy_plugin", "status": "error", "detail": "Error: legacy plugin failed"}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_preserves_structured_plugin_success_that_starts_with_error(tmp_path):
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
||||||
LLMResponse(
|
|
||||||
content="working",
|
|
||||||
tool_calls=[
|
|
||||||
ToolCallRequest(id="call_1", name="structured_success_plugin", arguments={})
|
|
||||||
],
|
|
||||||
usage={},
|
|
||||||
),
|
|
||||||
LLMResponse(content="done", tool_calls=[], usage={}),
|
|
||||||
])
|
|
||||||
|
|
||||||
result = await AgentRunner(provider).run(AgentRunSpec(
|
|
||||||
initial_messages=[{"role": "user", "content": "run plugin"}],
|
|
||||||
tools=_load_entry_point_plugin(_StructuredSuccessPluginTool, tmp_path),
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=2,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
fail_on_tool_error=True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "completed"
|
|
||||||
assert result.tool_events == [
|
|
||||||
{
|
|
||||||
"name": "structured_success_plugin",
|
|
||||||
"status": "ok",
|
|
||||||
"detail": "Error: generated report successfully",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_blocks_repeated_external_fetches():
|
async def test_runner_blocks_repeated_external_fetches():
|
||||||
provider = MagicMock()
|
provider = MagicMock()
|
||||||
@@ -465,3 +358,79 @@ async def test_runner_blocks_repeated_external_fetches():
|
|||||||
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
if msg.get("role") == "tool" and msg.get("tool_call_id") == "call_3"
|
||||||
][0]
|
][0]
|
||||||
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
assert "repeated external lookup blocked" in blocked_tool_message["content"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_adds_budget_notice_near_long_tool_budget():
|
||||||
|
provider = MagicMock()
|
||||||
|
captured_final_call: list[dict] = []
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
async def chat_with_retry(*, messages, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] <= 16:
|
||||||
|
return LLMResponse(
|
||||||
|
content="working",
|
||||||
|
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
captured_final_call[:] = messages
|
||||||
|
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
provider.chat_with_retry = chat_with_retry
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(return_value="tool result")
|
||||||
|
|
||||||
|
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "finish a large task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=20,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.final_content == "done"
|
||||||
|
notices = [
|
||||||
|
msg["content"]
|
||||||
|
for msg in captured_final_call
|
||||||
|
if msg.get("role") == "user" and "[Runtime Budget Notice]" in str(msg.get("content"))
|
||||||
|
]
|
||||||
|
assert len(notices) == 1
|
||||||
|
assert "15 of 20 model/tool iterations" in notices[0]
|
||||||
|
assert "Switch to convergence mode" in notices[0]
|
||||||
|
assert tools.execute.await_count == 16
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runner_budget_notice_does_not_affect_short_runs():
|
||||||
|
provider = MagicMock()
|
||||||
|
captured_final_call: list[dict] = []
|
||||||
|
call_count = {"n": 0}
|
||||||
|
|
||||||
|
async def chat_with_retry(*, messages, **kwargs):
|
||||||
|
call_count["n"] += 1
|
||||||
|
if call_count["n"] <= 2:
|
||||||
|
return LLMResponse(
|
||||||
|
content="working",
|
||||||
|
tool_calls=[ToolCallRequest(id=f"call_{call_count['n']}", name="work", arguments={})],
|
||||||
|
usage={},
|
||||||
|
)
|
||||||
|
captured_final_call[:] = messages
|
||||||
|
return LLMResponse(content="done", tool_calls=[], usage={})
|
||||||
|
|
||||||
|
provider.chat_with_retry = chat_with_retry
|
||||||
|
tools = MagicMock()
|
||||||
|
tools.get_definitions.return_value = []
|
||||||
|
tools.execute = AsyncMock(return_value="tool result")
|
||||||
|
|
||||||
|
result = await AgentRunner(provider).run(AgentRunSpec(
|
||||||
|
initial_messages=[{"role": "user", "content": "small task"}],
|
||||||
|
tools=tools,
|
||||||
|
model="test-model",
|
||||||
|
max_iterations=4,
|
||||||
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert result.final_content == "done"
|
||||||
|
assert all("[Runtime Budget Notice]" not in str(msg.get("content")) for msg in captured_final_call)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for atomic session save and corrupt-file repair."""
|
"""Tests for atomic session save and corrupt-file repair."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import shutil
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -36,6 +37,17 @@ class TestAtomicSave:
|
|||||||
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
tmp_files = list(mgr.sessions_dir.glob("*.tmp"))
|
||||||
assert tmp_files == []
|
assert tmp_files == []
|
||||||
|
|
||||||
|
def test_save_recreates_deleted_sessions_dir(self, tmp_path: Path):
|
||||||
|
mgr = SessionManager(tmp_path)
|
||||||
|
shutil.rmtree(mgr.sessions_dir)
|
||||||
|
|
||||||
|
session = Session(key="test:recreate")
|
||||||
|
session.add_message("user", "hello")
|
||||||
|
mgr.save(session)
|
||||||
|
|
||||||
|
path = mgr._get_session_path("test:recreate")
|
||||||
|
assert path.exists()
|
||||||
|
|
||||||
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
def test_tmp_file_cleaned_up_on_write_failure(self, tmp_path: Path):
|
||||||
mgr = SessionManager(tmp_path)
|
mgr = SessionManager(tmp_path)
|
||||||
session = Session(key="test:fail")
|
session = Session(key="test:fail")
|
||||||
|
|||||||
@@ -685,12 +685,12 @@ def test_retain_recent_legal_suffix_returns_dropped_messages():
|
|||||||
for i in range(10):
|
for i in range(10):
|
||||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
|
|
||||||
result = session.retain_recent_legal_suffix(4)
|
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||||
|
|
||||||
assert len(result.dropped) == 6
|
assert len(dropped) == 6
|
||||||
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
|
assert [m["content"] for m in dropped] == [f"msg{i}" for i in range(6)]
|
||||||
assert len(session.messages) == 4
|
assert len(session.messages) == 4
|
||||||
assert result.already_consolidated_count == 0
|
assert already_cons == 0
|
||||||
|
|
||||||
|
|
||||||
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||||
@@ -699,10 +699,10 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
|||||||
for i in range(3):
|
for i in range(3):
|
||||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
|
|
||||||
result = session.retain_recent_legal_suffix(4)
|
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||||
|
|
||||||
assert result.dropped == []
|
assert dropped == []
|
||||||
assert result.already_consolidated_count == 0
|
assert already_cons == 0
|
||||||
assert len(session.messages) == 3
|
assert len(session.messages) == 3
|
||||||
|
|
||||||
|
|
||||||
@@ -713,10 +713,10 @@ def test_retain_recent_legal_suffix_returns_all_on_zero():
|
|||||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
session.last_consolidated = 3
|
session.last_consolidated = 3
|
||||||
|
|
||||||
result = session.retain_recent_legal_suffix(0)
|
dropped, already_cons = session.retain_recent_legal_suffix(0)
|
||||||
|
|
||||||
assert len(result.dropped) == 5
|
assert len(dropped) == 5
|
||||||
assert result.already_consolidated_count == 3
|
assert already_cons == 3
|
||||||
assert session.messages == []
|
assert session.messages == []
|
||||||
|
|
||||||
|
|
||||||
@@ -820,11 +820,11 @@ def test_retain_recent_legal_suffix_last_consolidated_correct_in_else_branch():
|
|||||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
||||||
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated
|
session.last_consolidated = 12 # u0..u9, a0, a1 consolidated
|
||||||
|
|
||||||
result = session.retain_recent_legal_suffix(4)
|
dropped, already_cons = session.retain_recent_legal_suffix(4)
|
||||||
|
|
||||||
# Retained messages start from latest user (u9) + max_messages forward
|
# Retained messages start from latest user (u9) + max_messages forward
|
||||||
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
|
# so retained = [u9, a0..a9][:4] → but these are from original indices 9..12
|
||||||
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
|
# Of those, indices 9,10,11 are < 12 (before_lc), so new_lc = 3
|
||||||
assert session.last_consolidated == 3
|
assert session.last_consolidated == 3
|
||||||
# already_cons should count dropped messages with original index < 12
|
# already_cons should count dropped messages with original index < 12
|
||||||
assert result.already_consolidated_count == 9
|
assert already_cons == 9
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ class TestDispatch:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_streaming_preserves_message_metadata(self):
|
async def test_dispatch_streaming_preserves_message_metadata(self):
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import StreamDeltaEvent, StreamEndEvent
|
|
||||||
|
|
||||||
loop, bus = _make_loop()
|
loop, bus = _make_loop()
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
@@ -157,10 +156,10 @@ class TestDispatch:
|
|||||||
|
|
||||||
assert first.metadata["thread_root_event_id"] == "$root1"
|
assert first.metadata["thread_root_event_id"] == "$root1"
|
||||||
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
|
assert first.metadata["thread_reply_to_event_id"] == "$reply1"
|
||||||
assert isinstance(first.event, StreamDeltaEvent)
|
assert first.metadata["_stream_delta"] is True
|
||||||
assert second.metadata["thread_root_event_id"] == "$root1"
|
assert second.metadata["thread_root_event_id"] == "$root1"
|
||||||
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
|
assert second.metadata["thread_reply_to_event_id"] == "$reply1"
|
||||||
assert isinstance(second.event, StreamEndEvent)
|
assert second.metadata["_stream_end"] is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_processing_lock_serializes(self):
|
async def test_processing_lock_serializes(self):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for tool hint formatting (nanobot.utils.tool_hints)."""
|
"""Tests for tool hint formatting (nanobot.utils.tool_hints)."""
|
||||||
|
|
||||||
from nanobot.providers.base import ToolCallRequest
|
|
||||||
from nanobot.utils.tool_hints import format_tool_hints
|
from nanobot.utils.tool_hints import format_tool_hints
|
||||||
|
from nanobot.providers.base import ToolCallRequest
|
||||||
|
|
||||||
|
|
||||||
def _tc(name: str, args) -> ToolCallRequest:
|
def _tc(name: str, args) -> ToolCallRequest:
|
||||||
@@ -306,22 +306,3 @@ class TestToolHintMaxLength:
|
|||||||
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
|
short = _hint([_tc("list_dir", {"path": long_path})], max_length=40)
|
||||||
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
|
long = _hint([_tc("list_dir", {"path": long_path})], max_length=120)
|
||||||
assert len(long) > len(short)
|
assert len(long) > len(short)
|
||||||
|
|
||||||
|
|
||||||
class TestToolHintMalformedCalls:
|
|
||||||
"""Malformed tool calls must not crash hint formatting (see HKUDS/nanobot)."""
|
|
||||||
|
|
||||||
def test_none_name_is_skipped(self):
|
|
||||||
"""A tool call with name=None should be skipped, not raise AttributeError."""
|
|
||||||
result = _hint([_tc(None, None)])
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
def test_empty_name_is_skipped(self):
|
|
||||||
"""A tool call with an empty name should be skipped."""
|
|
||||||
result = _hint([_tc("", {"path": "foo.txt"})])
|
|
||||||
assert result == ""
|
|
||||||
|
|
||||||
def test_none_name_mixed_with_valid_call(self):
|
|
||||||
"""A degenerate call must not suppress hints for the valid calls beside it."""
|
|
||||||
result = _hint([_tc(None, None), _tc("read_file", {"path": "foo.txt"})])
|
|
||||||
assert result == "read foo.txt"
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
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, is_tool_error_result
|
|
||||||
|
|
||||||
|
|
||||||
def test_loader_discovers_entry_point_tools():
|
def test_loader_discovers_entry_point_tools():
|
||||||
@@ -78,67 +74,3 @@ def test_loader_skips_abstract_entry_point_tools():
|
|||||||
discovered = loader._discover_plugins()
|
discovered = loader._discover_plugins()
|
||||||
|
|
||||||
assert "abstract_plugin" not in discovered
|
assert "abstract_plugin" not in discovered
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
|
|
||||||
"""Only adapt legacy plugin error strings; keep the wrapped tool API intact."""
|
|
||||||
mock_ep = MagicMock()
|
|
||||||
mock_ep.name = "api_plugin"
|
|
||||||
|
|
||||||
class _ApiPluginTool(Tool):
|
|
||||||
config_key = "api_plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "api_plugin"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "Entry-point plugin with custom tool API methods."
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict:
|
|
||||||
return {"type": "object", "properties": {"value": {"type": "string"}}}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def read_only(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def concurrency_safe(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def cast_params(self, params: dict) -> dict:
|
|
||||||
return {"value": str(params["value"])}
|
|
||||||
|
|
||||||
def validate_params(self, params: dict) -> list[str]:
|
|
||||||
return [] if params == {"value": "1"} else ["bad value"]
|
|
||||||
|
|
||||||
def to_schema(self) -> dict:
|
|
||||||
return {"name": self.name, "custom": True}
|
|
||||||
|
|
||||||
async def execute(self, **_):
|
|
||||||
return "Error: plugin failed"
|
|
||||||
|
|
||||||
mock_ep.load.return_value = _ApiPluginTool
|
|
||||||
|
|
||||||
registry = ToolRegistry()
|
|
||||||
with patch("nanobot.agent.tools.loader.entry_points", return_value=[mock_ep]):
|
|
||||||
ToolLoader(test_classes=[]).load(
|
|
||||||
ToolContext(config=None, workspace=str(tmp_path)),
|
|
||||||
registry,
|
|
||||||
)
|
|
||||||
|
|
||||||
tool = registry.get("api_plugin")
|
|
||||||
assert tool is not None
|
|
||||||
assert tool.config_key == "api_plugin"
|
|
||||||
assert tool.read_only is True
|
|
||||||
assert tool.concurrency_safe is False
|
|
||||||
assert tool.cast_params({"value": 1}) == {"value": "1"}
|
|
||||||
assert tool.validate_params({"value": "1"}) == []
|
|
||||||
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
|
|
||||||
|
|
||||||
result = await tool.execute(value="1")
|
|
||||||
assert is_tool_error_result("api_plugin", result) is True
|
|
||||||
assert str(result) == "Error: plugin failed"
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nanobot.agent.verification_state import (
|
||||||
|
analyze_verification_result,
|
||||||
|
append_verification_feedback,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_pytest_failure_extracts_actionable_summary():
|
||||||
|
output = """\
|
||||||
|
FAILED ../tests/test_outputs.py::test_regex_matches_dates - AssertionError: Expected dates
|
||||||
|
E AssertionError: Expected ['2025-01-09'], but got ['bad']
|
||||||
|
E FileNotFoundError: [Errno 2] No such file or directory: '/app/out.txt'
|
||||||
|
============================== 1 failed in 0.05s ===============================
|
||||||
|
Exit code: 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
output=output,
|
||||||
|
exit_code=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
assert analysis.failed_tests == ("../tests/test_outputs.py::test_regex_matches_dates",)
|
||||||
|
assert any("AssertionError" in item for item in analysis.primary_errors)
|
||||||
|
assert "/app/out.txt" in analysis.missing_artifacts
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_verification_feedback_tells_agent_not_to_finish():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="python /app/test_outputs.py",
|
||||||
|
output="FAILED test_outputs.py::test_file\nAssertionError: missing\nExit code: 1",
|
||||||
|
exit_code=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
feedback = append_verification_feedback("raw output\nExit code: 1", analysis)
|
||||||
|
|
||||||
|
assert "[Verification Feedback]" in feedback
|
||||||
|
assert "Do not call complete_goal" in feedback
|
||||||
|
assert "Next action" in feedback
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_passing_test_records_success_without_feedback():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="pytest",
|
||||||
|
output="============================== 3 passed in 0.10s ==============================\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
assert append_verification_feedback("ok", analysis) == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_command_not_found_as_failed_check():
|
||||||
|
output = """\
|
||||||
|
STDERR:
|
||||||
|
/usr/bin/bash: line 1: python3: command not found
|
||||||
|
|
||||||
|
Exit code: 127
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="python3 - <<'PY'\nprint('quick verification')\nPY",
|
||||||
|
output=output,
|
||||||
|
exit_code=127,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
assert any("command not found" in item for item in analysis.primary_errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_artifact_comparison_success_records_pass():
|
||||||
|
output = """\
|
||||||
|
run_exit:0
|
||||||
|
0d115b98 /app/image.ppm
|
||||||
|
0d115b98 /tmp/orig.ppm
|
||||||
|
cmp_exit:0
|
||||||
|
7 21 1024
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"cd /usr/bin && gcc -static -o /app/reversed_final /app/mystery.c -lm "
|
||||||
|
"&& (cd /app && ./reversed_final >/tmp/final_out 2>/tmp/final_err); "
|
||||||
|
"sha256sum /app/image.ppm /tmp/orig.ppm; "
|
||||||
|
"cmp -s /app/image.ppm /tmp/orig.ppm; echo cmp_exit:$?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
assert append_verification_feedback("ok", analysis) == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_plain_checksum_without_success_marker_is_ignored():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="sha256sum /app/image.ppm /tmp/orig.ppm",
|
||||||
|
output="0d115b98 /app/image.ppm\n0d115b98 /tmp/orig.ppm\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_named_comparison_markers_record_pass():
|
||||||
|
output = """\
|
||||||
|
ppm:0
|
||||||
|
stderr:0
|
||||||
|
stdout:0
|
||||||
|
4 26 1011
|
||||||
|
1821 mystery.c
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"gcc -static -O2 -o reversed mystery.c -lm\n"
|
||||||
|
"./reversed > vrout.txt 2> vrerr.txt\n"
|
||||||
|
"cp image.ppm rev.ppm\n"
|
||||||
|
"./mystery > voout.txt 2> voerr.txt\n"
|
||||||
|
"cmp image.ppm rev.ppm\n"
|
||||||
|
"printf 'ppm:%s\\n' $?\n"
|
||||||
|
"cmp voerr.txt vrerr.txt\n"
|
||||||
|
"printf 'stderr:%s\\n' $?\n"
|
||||||
|
"cmp voout.txt vrout.txt\n"
|
||||||
|
"printf 'stdout:%s\\n' $?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "passed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_named_comparison_marker_failure_records_failed():
|
||||||
|
output = """\
|
||||||
|
ppm:0
|
||||||
|
stderr:1
|
||||||
|
stdout:0
|
||||||
|
|
||||||
|
Exit code: 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command=(
|
||||||
|
"cmp image.ppm rev.ppm; printf 'ppm:%s\\n' $?; "
|
||||||
|
"cmp voerr.txt vrerr.txt; printf 'stderr:%s\\n' $?; "
|
||||||
|
"cmp voout.txt vrout.txt; printf 'stdout:%s\\n' $?"
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is not None
|
||||||
|
assert analysis.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_plain_run_status_marker_without_comparison_is_ignored():
|
||||||
|
analysis = analyze_verification_result(
|
||||||
|
command="gcc -static -O2 -o reversed mystery.c -lm && ./reversed",
|
||||||
|
output="rc:0\nExit code: 0",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert analysis is None
|
||||||
@@ -13,7 +13,11 @@ from nanobot.agent.tools.long_task import (
|
|||||||
CompleteGoalTool,
|
CompleteGoalTool,
|
||||||
LongTaskTool,
|
LongTaskTool,
|
||||||
)
|
)
|
||||||
from nanobot.bus.outbound_events import GoalStateSyncEvent
|
from nanobot.agent.verification_state import (
|
||||||
|
VerificationAnalysis,
|
||||||
|
clear_verification_observation,
|
||||||
|
record_verification_observation,
|
||||||
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.session.goal_state import GOAL_STATE_KEY
|
from nanobot.session.goal_state import GOAL_STATE_KEY
|
||||||
@@ -145,8 +149,8 @@ async def test_long_task_publishes_goal_state_ws_after_save(tmp_path):
|
|||||||
call = bus.publish_outbound.await_args.args[0]
|
call = bus.publish_outbound.await_args.args[0]
|
||||||
assert call.channel == "websocket"
|
assert call.channel == "websocket"
|
||||||
assert call.chat_id == "chat-99"
|
assert call.chat_id == "chat-99"
|
||||||
assert isinstance(call.event, GoalStateSyncEvent)
|
assert call.metadata.get("_goal_state_sync") is True
|
||||||
assert call.event.goal_state == {
|
assert call.metadata["goal_state"] == {
|
||||||
"active": True,
|
"active": True,
|
||||||
"ui_summary": "alpha",
|
"ui_summary": "alpha",
|
||||||
"objective": "Objective alpha",
|
"objective": "Objective alpha",
|
||||||
@@ -181,8 +185,7 @@ async def test_complete_goal_publishes_inactive_goal_state_ws(tmp_path):
|
|||||||
|
|
||||||
bus.publish_outbound.assert_awaited_once()
|
bus.publish_outbound.assert_awaited_once()
|
||||||
call = bus.publish_outbound.await_args.args[0]
|
call = bus.publish_outbound.await_args.args[0]
|
||||||
assert isinstance(call.event, GoalStateSyncEvent)
|
assert call.metadata["goal_state"] == {"active": False}
|
||||||
assert call.event.goal_state == {"active": False}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -194,6 +197,66 @@ async def test_complete_goal_without_active_is_noop_message(tmp_path):
|
|||||||
assert "No active" in out
|
assert "No active" in out
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_goal_blocks_unresolved_verification_failure(tmp_path):
|
||||||
|
sm = SessionManager(tmp_path)
|
||||||
|
lt, cg = _tools(sm)
|
||||||
|
await lt.execute(goal="Fix the tests")
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=1,
|
||||||
|
failed_tests=("test_outputs.py::test_output",),
|
||||||
|
primary_errors=("AssertionError: wrong output",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
out = await cg.execute(recap="Done.")
|
||||||
|
|
||||||
|
assert "not marked complete" in out
|
||||||
|
assert "test_outputs.py::test_output" in out
|
||||||
|
assert sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]["status"] == "active"
|
||||||
|
clear_verification_observation("websocket:c1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_goal_allows_after_later_successful_verification(tmp_path):
|
||||||
|
sm = SessionManager(tmp_path)
|
||||||
|
lt, cg = _tools(sm)
|
||||||
|
await lt.execute(goal="Fix the tests")
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="failed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=1,
|
||||||
|
failed_tests=("test_outputs.py::test_output",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
record_verification_observation(
|
||||||
|
"websocket:c1",
|
||||||
|
VerificationAnalysis(
|
||||||
|
status="passed",
|
||||||
|
command="pytest /tests/test_outputs.py",
|
||||||
|
exit_code=0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
out = await cg.execute(
|
||||||
|
recap="Done.",
|
||||||
|
verification_summary="pytest /tests/test_outputs.py passed",
|
||||||
|
commands_run="pytest /tests/test_outputs.py",
|
||||||
|
artifacts_created="/app/out.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "marked complete" in out
|
||||||
|
blob = sm.get_or_create("websocket:c1").metadata[GOAL_STATE_KEY]
|
||||||
|
assert blob["status"] == "completed"
|
||||||
|
assert blob["verification_summary"] == "pytest /tests/test_outputs.py passed"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
|
async def test_long_task_skips_ws_publish_without_bus(tmp_path):
|
||||||
sm = SessionManager(tmp_path)
|
sm = SessionManager(tmp_path)
|
||||||
|
|||||||
@@ -236,12 +236,10 @@ class TestModifyRestricted:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_context_window_valid(self):
|
async def test_modify_context_window_valid(self):
|
||||||
loop = _make_mock_loop(_sync_replay_max_messages=MagicMock())
|
tool = _make_tool()
|
||||||
tool = _make_tool(runtime_state=loop)
|
|
||||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||||
assert "Set context_window_tokens" in result
|
assert "Set context_window_tokens" in result
|
||||||
assert loop.context_window_tokens == 131072
|
assert tool._runtime_state.context_window_tokens == 131072
|
||||||
loop._sync_replay_max_messages.assert_called_once_with()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_modify_none_value_for_restricted_int(self):
|
async def test_modify_none_value_for_restricted_int(self):
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
GoalStateSyncEvent,
|
|
||||||
GoalStatusEvent,
|
|
||||||
ProgressEvent,
|
|
||||||
RetryWaitEvent,
|
|
||||||
RuntimeModelUpdatedEvent,
|
|
||||||
SessionUpdatedEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
TurnEndEvent,
|
|
||||||
outbound_event_from_message,
|
|
||||||
outbound_message_for_event,
|
|
||||||
replace_outbound_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_progress_event_lives_on_outbound_message_event_field() -> None:
|
|
||||||
tool_events = [{"phase": "start", "name": "read_file"}]
|
|
||||||
file_edit_events = [{"phase": "end", "path": "app.py"}]
|
|
||||||
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
event=ProgressEvent(
|
|
||||||
content="working",
|
|
||||||
tool_hint=True,
|
|
||||||
reasoning_delta=True,
|
|
||||||
stream_id="r1",
|
|
||||||
tool_events=tool_events,
|
|
||||||
file_edit_events=file_edit_events,
|
|
||||||
),
|
|
||||||
metadata={"origin_message_id": "m1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert msg.content == "working"
|
|
||||||
assert msg.metadata == {"origin_message_id": "m1"}
|
|
||||||
|
|
||||||
event = outbound_event_from_message(msg)
|
|
||||||
assert isinstance(event, ProgressEvent)
|
|
||||||
assert event.content == "working"
|
|
||||||
assert event.tool_hint is True
|
|
||||||
assert event.reasoning_delta is True
|
|
||||||
assert event.stream_id == "r1"
|
|
||||||
assert event.tool_events == tool_events
|
|
||||||
assert event.file_edit_events == file_edit_events
|
|
||||||
|
|
||||||
|
|
||||||
def test_normal_outbound_message_has_no_runtime_event() -> None:
|
|
||||||
msg = OutboundMessage(channel="websocket", chat_id="chat-1", content="hello")
|
|
||||||
|
|
||||||
assert outbound_event_from_message(msg) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_progress_metadata_flags_create_runtime_event() -> None:
|
|
||||||
tool_events = [{"phase": "start", "name": "read_file"}]
|
|
||||||
file_edit_events = [{"phase": "end", "path": "app.py"}]
|
|
||||||
msg = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="legacy progress",
|
|
||||||
metadata={
|
|
||||||
"_progress": True,
|
|
||||||
"_tool_hint": True,
|
|
||||||
"_reasoning_delta": True,
|
|
||||||
"_stream_id": "r1",
|
|
||||||
"_tool_events": tool_events,
|
|
||||||
"_file_edit_events": file_edit_events,
|
|
||||||
"message_id": "platform-routing-context",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
event = outbound_event_from_message(msg)
|
|
||||||
assert isinstance(event, ProgressEvent)
|
|
||||||
assert event.content == "legacy progress"
|
|
||||||
assert event.tool_hint is True
|
|
||||||
assert event.reasoning_delta is True
|
|
||||||
assert event.stream_id == "r1"
|
|
||||||
assert event.tool_events == tool_events
|
|
||||||
assert event.file_edit_events == file_edit_events
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_stream_metadata_flags_create_runtime_events() -> None:
|
|
||||||
delta = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="hello",
|
|
||||||
metadata={"_stream_delta": True, "_stream_id": "s1"},
|
|
||||||
)
|
|
||||||
end = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_stream_end": True, "_stream_id": "s1", "_resuming": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
delta_event = outbound_event_from_message(delta)
|
|
||||||
assert isinstance(delta_event, StreamDeltaEvent)
|
|
||||||
assert delta_event.content == "hello"
|
|
||||||
assert delta_event.stream_id == "s1"
|
|
||||||
|
|
||||||
end_event = outbound_event_from_message(end)
|
|
||||||
assert isinstance(end_event, StreamEndEvent)
|
|
||||||
assert end_event.stream_id == "s1"
|
|
||||||
assert end_event.resuming is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_webui_runtime_metadata_flags_create_runtime_events() -> None:
|
|
||||||
runtime = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="*",
|
|
||||||
content="",
|
|
||||||
metadata={
|
|
||||||
"_runtime_model_updated": True,
|
|
||||||
"model": "gpt-5.5",
|
|
||||||
"model_preset": "high",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
goal_state = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_goal_state_sync": True, "goal_state": {"active": True}},
|
|
||||||
)
|
|
||||||
goal_status = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_goal_status": True, "goal_status": "running", "started_at": 1.25},
|
|
||||||
)
|
|
||||||
turn_end = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_turn_end": True, "latency_ms": 42.0, "goal_state": {"active": False}},
|
|
||||||
)
|
|
||||||
session_updated = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_session_updated": True, "_session_update_scope": "metadata"},
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_event = outbound_event_from_message(runtime)
|
|
||||||
assert isinstance(runtime_event, RuntimeModelUpdatedEvent)
|
|
||||||
assert runtime_event.model == "gpt-5.5"
|
|
||||||
assert runtime_event.model_preset == "high"
|
|
||||||
|
|
||||||
goal_state_event = outbound_event_from_message(goal_state)
|
|
||||||
assert isinstance(goal_state_event, GoalStateSyncEvent)
|
|
||||||
assert goal_state_event.goal_state == {"active": True}
|
|
||||||
|
|
||||||
goal_status_event = outbound_event_from_message(goal_status)
|
|
||||||
assert isinstance(goal_status_event, GoalStatusEvent)
|
|
||||||
assert goal_status_event.status == "running"
|
|
||||||
assert goal_status_event.started_at == 1.25
|
|
||||||
|
|
||||||
turn_end_event = outbound_event_from_message(turn_end)
|
|
||||||
assert isinstance(turn_end_event, TurnEndEvent)
|
|
||||||
assert turn_end_event.latency_ms == 42
|
|
||||||
assert turn_end_event.goal_state == {"active": False}
|
|
||||||
|
|
||||||
session_updated_event = outbound_event_from_message(session_updated)
|
|
||||||
assert isinstance(session_updated_event, SessionUpdatedEvent)
|
|
||||||
assert session_updated_event.scope == "metadata"
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_metadata_numbers_ignore_bool_values() -> None:
|
|
||||||
goal_status = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_goal_status": True, "goal_status": "running", "started_at": True},
|
|
||||||
)
|
|
||||||
turn_end = OutboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
content="",
|
|
||||||
metadata={"_turn_end": True, "latency_ms": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
goal_status_event = outbound_event_from_message(goal_status)
|
|
||||||
assert isinstance(goal_status_event, GoalStatusEvent)
|
|
||||||
assert goal_status_event.started_at is None
|
|
||||||
|
|
||||||
turn_end_event = outbound_event_from_message(turn_end)
|
|
||||||
assert isinstance(turn_end_event, TurnEndEvent)
|
|
||||||
assert turn_end_event.latency_ms is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_retry_wait_and_streamed_flags_create_runtime_events() -> None:
|
|
||||||
retry = OutboundMessage(
|
|
||||||
channel="cli",
|
|
||||||
chat_id="direct",
|
|
||||||
content="waiting",
|
|
||||||
metadata={"_retry_wait": True},
|
|
||||||
)
|
|
||||||
streamed = OutboundMessage(
|
|
||||||
channel="cli",
|
|
||||||
chat_id="direct",
|
|
||||||
content="final answer",
|
|
||||||
metadata={"_streamed": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
retry_event = outbound_event_from_message(retry)
|
|
||||||
assert isinstance(retry_event, RetryWaitEvent)
|
|
||||||
assert retry_event.content == "waiting"
|
|
||||||
assert isinstance(outbound_event_from_message(streamed), StreamedResponseEvent)
|
|
||||||
|
|
||||||
|
|
||||||
def test_replace_outbound_event_keeps_routing_metadata() -> None:
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
event=StreamDeltaEvent(content="hello", stream_id="s1"),
|
|
||||||
metadata={"message_id": "m1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
updated = replace_outbound_event(
|
|
||||||
msg,
|
|
||||||
StreamEndEvent(stream_id="s1", resuming=True),
|
|
||||||
content="hello world",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert updated.content == "hello world"
|
|
||||||
assert updated.metadata == {"message_id": "m1"}
|
|
||||||
assert isinstance(updated.event, StreamEndEvent)
|
|
||||||
assert updated.event.stream_id == "s1"
|
|
||||||
assert updated.event.resuming is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_streamed_response_event_keeps_final_content_outside_event_payload() -> None:
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="cli",
|
|
||||||
chat_id="direct",
|
|
||||||
event=StreamedResponseEvent(),
|
|
||||||
content="final answer",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert msg.content == "final answer"
|
|
||||||
assert isinstance(outbound_event_from_message(msg), StreamedResponseEvent)
|
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
|
"""Tests for ChannelManager delta coalescing to reduce streaming latency."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
ProgressEvent,
|
|
||||||
RetryWaitEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
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.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
@@ -38,187 +29,221 @@ class MockChannel(BaseChannel):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
async def send(self, msg):
|
async def send(self, msg):
|
||||||
|
"""Implement abstract method."""
|
||||||
return await self._send_mock(msg)
|
return await self._send_mock(msg)
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id, delta, metadata=None):
|
||||||
self,
|
"""Override send_delta for testing."""
|
||||||
chat_id,
|
return await self._send_delta_mock(chat_id, delta, metadata)
|
||||||
delta,
|
|
||||||
metadata=None,
|
|
||||||
*,
|
|
||||||
stream_id=None,
|
|
||||||
stream_end=False,
|
|
||||||
resuming=False,
|
|
||||||
):
|
|
||||||
return await self._send_delta_mock(
|
|
||||||
chat_id,
|
|
||||||
delta,
|
|
||||||
metadata,
|
|
||||||
stream_id=stream_id,
|
|
||||||
stream_end=stream_end,
|
|
||||||
resuming=resuming,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def config():
|
def config():
|
||||||
|
"""Create a minimal config for testing."""
|
||||||
return Config()
|
return Config()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def bus():
|
def bus():
|
||||||
|
"""Create a message bus for testing."""
|
||||||
return MessageBus()
|
return MessageBus()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def manager(config, bus):
|
def manager(config, bus):
|
||||||
|
"""Create a channel manager with a mock channel."""
|
||||||
manager = ChannelManager(config, bus)
|
manager = ChannelManager(config, bus)
|
||||||
manager.channels["mock"] = MockChannel({}, bus)
|
manager.channels["mock"] = MockChannel({}, bus)
|
||||||
return manager
|
return manager
|
||||||
|
|
||||||
|
|
||||||
def _delta(content: str, *, chat_id: str = "chat1", stream_id: str | None = None):
|
|
||||||
return outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id=chat_id,
|
|
||||||
event=StreamDeltaEvent(content=content, stream_id=stream_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _end(
|
|
||||||
content: str = "",
|
|
||||||
*,
|
|
||||||
chat_id: str = "chat1",
|
|
||||||
stream_id: str | None = None,
|
|
||||||
resuming: bool = False,
|
|
||||||
):
|
|
||||||
return outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id=chat_id,
|
|
||||||
event=StreamEndEvent(content=content, stream_id=stream_id, resuming=resuming),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeltaCoalescing:
|
class TestDeltaCoalescing:
|
||||||
"""Tests for stream delta message coalescing."""
|
"""Tests for _stream_delta message coalescing."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_single_delta_not_coalesced(self, manager, bus):
|
async def test_single_delta_not_coalesced(self, manager, bus):
|
||||||
msg = _delta("Hello")
|
"""A single delta should be sent as-is."""
|
||||||
|
msg = OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Hello",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
)
|
||||||
await bus.publish_outbound(msg)
|
await bus.publish_outbound(msg)
|
||||||
|
|
||||||
|
# Process one message
|
||||||
async def process_one():
|
async def process_one():
|
||||||
try:
|
try:
|
||||||
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1)
|
m = await asyncio.wait_for(bus.consume_outbound(), timeout=0.1)
|
||||||
event = outbound_event_from_message(m)
|
if m.metadata.get("_stream_delta"):
|
||||||
if isinstance(event, StreamDeltaEvent):
|
|
||||||
m, pending = manager._coalesce_stream_deltas(m)
|
m, pending = manager._coalesce_stream_deltas(m)
|
||||||
|
# Put pending back (none expected)
|
||||||
for p in pending:
|
for p in pending:
|
||||||
await bus.publish_outbound(p)
|
await bus.publish_outbound(p)
|
||||||
channel = manager.channels.get(m.channel)
|
channel = manager.channels.get(m.channel)
|
||||||
event = outbound_event_from_message(m)
|
if channel:
|
||||||
if channel and isinstance(event, StreamDeltaEvent):
|
await channel.send_delta(m.chat_id, m.content, m.metadata)
|
||||||
await channel.send_delta(
|
|
||||||
m.chat_id,
|
|
||||||
m.content,
|
|
||||||
m.metadata,
|
|
||||||
stream_id=event.stream_id,
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await process_one()
|
await process_one()
|
||||||
|
|
||||||
manager.channels["mock"]._send_delta_mock.assert_called_once_with(
|
manager.channels["mock"]._send_delta_mock.assert_called_once_with(
|
||||||
"chat1",
|
"chat1", "Hello", {"_stream_delta": True}
|
||||||
"Hello",
|
|
||||||
{},
|
|
||||||
stream_id=None,
|
|
||||||
stream_end=False,
|
|
||||||
resuming=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_deltas_coalesced(self, manager, bus):
|
async def test_multiple_deltas_coalesced(self, manager, bus):
|
||||||
|
"""Multiple consecutive deltas for same chat should be merged."""
|
||||||
|
# Put multiple deltas in queue
|
||||||
for text in ["Hello", " ", "world", "!"]:
|
for text in ["Hello", " ", "world", "!"]:
|
||||||
await bus.publish_outbound(_delta(text))
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content=text,
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
|
||||||
|
# Process using coalescing logic
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
|
|
||||||
|
# Should have merged all deltas
|
||||||
assert merged.content == "Hello world!"
|
assert merged.content == "Hello world!"
|
||||||
assert isinstance(merged.event, StreamDeltaEvent)
|
assert merged.metadata.get("_stream_delta") is True
|
||||||
|
# No pending messages (all were coalesced)
|
||||||
assert len(pending) == 0
|
assert len(pending) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_deltas_different_chats_not_coalesced(self, manager, bus):
|
async def test_deltas_different_chats_not_coalesced(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("Hello", chat_id="chat1"))
|
"""Deltas for different chats should not be merged."""
|
||||||
await bus.publish_outbound(_delta("World", chat_id="chat2"))
|
# Put deltas for different chats
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Hello",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat2",
|
||||||
|
content="World",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
|
|
||||||
|
# First chat should not include second chat's content
|
||||||
assert merged.content == "Hello"
|
assert merged.content == "Hello"
|
||||||
assert merged.chat_id == "chat1"
|
assert merged.chat_id == "chat1"
|
||||||
|
# Second chat should be in pending
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert pending[0].chat_id == "chat2"
|
assert pending[0].chat_id == "chat2"
|
||||||
assert pending[0].content == "World"
|
assert pending[0].content == "World"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
|
async def test_deltas_different_stream_ids_not_coalesced(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("A1", stream_id="stream-a"))
|
"""Deltas for the same chat but different streams should not be merged."""
|
||||||
await bus.publish_outbound(_delta("B1", stream_id="stream-b"))
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="A1",
|
||||||
|
metadata={"_stream_delta": True, "_stream_id": "stream-a"},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="B1",
|
||||||
|
metadata={"_stream_delta": True, "_stream_id": "stream-b"},
|
||||||
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
|
|
||||||
assert merged.content == "A1"
|
assert merged.content == "A1"
|
||||||
assert isinstance(merged.event, StreamDeltaEvent)
|
assert merged.metadata.get("_stream_id") == "stream-a"
|
||||||
assert merged.event.stream_id == "stream-a"
|
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert pending[0].content == "B1"
|
assert pending[0].content == "B1"
|
||||||
assert isinstance(pending[0].event, StreamDeltaEvent)
|
assert pending[0].metadata.get("_stream_id") == "stream-b"
|
||||||
assert pending[0].event.stream_id == "stream-b"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_end_terminates_coalescing(self, manager, bus):
|
async def test_stream_end_terminates_coalescing(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("Hello"))
|
"""_stream_end should stop coalescing and be included in final message."""
|
||||||
await bus.publish_outbound(_end(" world"))
|
# Put deltas with stream_end at the end
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Hello",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content=" world",
|
||||||
|
metadata={"_stream_delta": True, "_stream_end": True},
|
||||||
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
|
|
||||||
|
# Should have merged content
|
||||||
assert merged.content == "Hello world"
|
assert merged.content == "Hello world"
|
||||||
assert isinstance(merged.event, StreamEndEvent)
|
# Should have stream_end flag
|
||||||
|
assert merged.metadata.get("_stream_end") is True
|
||||||
|
# No pending
|
||||||
assert len(pending) == 0
|
assert len(pending) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus):
|
async def test_coalescing_stops_at_first_non_matching_boundary(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("Hello", stream_id="seg-1"))
|
"""Only consecutive deltas should be merged; later deltas stay queued."""
|
||||||
await bus.publish_outbound(_end(stream_id="seg-1"))
|
await bus.publish_outbound(OutboundMessage(
|
||||||
await bus.publish_outbound(_delta("world", stream_id="seg-2"))
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Hello",
|
||||||
|
metadata={"_stream_delta": True, "_stream_id": "seg-1"},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="",
|
||||||
|
metadata={"_stream_end": True, "_stream_id": "seg-1"},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="world",
|
||||||
|
metadata={"_stream_delta": True, "_stream_id": "seg-2"},
|
||||||
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
|
|
||||||
assert merged.content == "Hello"
|
assert merged.content == "Hello"
|
||||||
assert isinstance(merged.event, StreamDeltaEvent)
|
assert merged.metadata.get("_stream_end") is None
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert isinstance(pending[0].event, StreamEndEvent)
|
assert pending[0].metadata.get("_stream_end") is True
|
||||||
assert pending[0].event.stream_id == "seg-1"
|
assert pending[0].metadata.get("_stream_id") == "seg-1"
|
||||||
|
|
||||||
|
# The next stream segment must remain in queue order for later dispatch.
|
||||||
remaining = await bus.consume_outbound()
|
remaining = await bus.consume_outbound()
|
||||||
assert remaining.content == "world"
|
assert remaining.content == "world"
|
||||||
assert isinstance(remaining.event, StreamDeltaEvent)
|
assert remaining.metadata.get("_stream_id") == "seg-2"
|
||||||
assert remaining.event.stream_id == "seg-2"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_delta_message_preserved(self, manager, bus):
|
async def test_non_delta_message_preserved(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("Delta"))
|
"""Non-delta messages should be preserved in pending list."""
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Delta",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
await bus.publish_outbound(OutboundMessage(
|
await bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="Final message",
|
content="Final message",
|
||||||
|
metadata={}, # Not a delta
|
||||||
))
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
@@ -227,11 +252,17 @@ class TestDeltaCoalescing:
|
|||||||
assert merged.content == "Delta"
|
assert merged.content == "Delta"
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert pending[0].content == "Final message"
|
assert pending[0].content == "Final message"
|
||||||
assert pending[0].event is None
|
assert pending[0].metadata.get("_stream_delta") is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_empty_queue_stops_coalescing(self, manager, bus):
|
async def test_empty_queue_stops_coalescing(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("Only message"))
|
"""Coalescing should stop when queue is empty."""
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="Only message",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
|
||||||
first_msg = await bus.consume_outbound()
|
first_msg = await bus.consume_outbound()
|
||||||
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
merged, pending = manager._coalesce_stream_deltas(first_msg)
|
||||||
@@ -245,35 +276,49 @@ class TestDispatchOutboundWithCoalescing:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
|
async def test_dispatch_coalesces_and_processes_pending(self, manager, bus):
|
||||||
await bus.publish_outbound(_delta("A"))
|
"""_dispatch_outbound should coalesce deltas and process pending messages."""
|
||||||
await bus.publish_outbound(_delta("B"))
|
# Put multiple deltas followed by a regular message
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="A",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
|
await bus.publish_outbound(OutboundMessage(
|
||||||
|
channel="mock",
|
||||||
|
chat_id="chat1",
|
||||||
|
content="B",
|
||||||
|
metadata={"_stream_delta": True},
|
||||||
|
))
|
||||||
await bus.publish_outbound(OutboundMessage(
|
await bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="Final",
|
content="Final",
|
||||||
|
metadata={}, # Regular message
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Run one iteration of dispatch logic manually
|
||||||
pending = []
|
pending = []
|
||||||
processed = []
|
processed = []
|
||||||
|
|
||||||
msg = pending.pop(0) if pending else await bus.consume_outbound()
|
# First iteration: should coalesce A+B
|
||||||
event = outbound_event_from_message(msg)
|
if pending:
|
||||||
if isinstance(event, StreamDeltaEvent):
|
msg = pending.pop(0)
|
||||||
|
else:
|
||||||
|
msg = await bus.consume_outbound()
|
||||||
|
|
||||||
|
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
|
||||||
msg, extra_pending = manager._coalesce_stream_deltas(msg)
|
msg, extra_pending = manager._coalesce_stream_deltas(msg)
|
||||||
pending.extend(extra_pending)
|
pending.extend(extra_pending)
|
||||||
|
|
||||||
channel = manager.channels.get(msg.channel)
|
channel = manager.channels.get(msg.channel)
|
||||||
event = outbound_event_from_message(msg)
|
if channel:
|
||||||
if channel and isinstance(event, StreamDeltaEvent):
|
await channel.send_delta(msg.chat_id, msg.content, msg.metadata)
|
||||||
await channel.send_delta(
|
|
||||||
msg.chat_id,
|
|
||||||
msg.content,
|
|
||||||
msg.metadata,
|
|
||||||
stream_id=event.stream_id,
|
|
||||||
)
|
|
||||||
processed.append(("delta", msg.content))
|
processed.append(("delta", msg.content))
|
||||||
|
|
||||||
|
# Should have sent coalesced delta
|
||||||
assert processed == [("delta", "AB")]
|
assert processed == [("delta", "AB")]
|
||||||
|
# Should have pending regular message
|
||||||
assert len(pending) == 1
|
assert len(pending) == 1
|
||||||
assert pending[0].content == "Final"
|
assert pending[0].content == "Final"
|
||||||
|
|
||||||
@@ -309,20 +354,23 @@ class TestProgressFiltering:
|
|||||||
|
|
||||||
assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False
|
assert manager._resolve_bool_override(FakeSection(), "send_progress", True) is False
|
||||||
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True
|
assert manager._resolve_bool_override(FakeSection(), "send_tool_hints", False) is True
|
||||||
|
# Missing attribute falls back to default
|
||||||
assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
|
assert manager._resolve_bool_override(FakeSection(), "unknown_key", True) is True
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_channel_override_can_drop_progress_message(self, manager, bus):
|
async def test_channel_override_can_drop_progress_message(self, manager, bus):
|
||||||
manager.channels["mock"].send_progress = False
|
manager.channels["mock"].send_progress = False
|
||||||
await bus.publish_outbound(outbound_message_for_event(
|
await bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
event=ProgressEvent(content="thinking"),
|
content="thinking",
|
||||||
|
metadata={"_progress": True},
|
||||||
))
|
))
|
||||||
await bus.publish_outbound(OutboundMessage(
|
await bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="final answer",
|
content="final answer",
|
||||||
|
metadata={},
|
||||||
))
|
))
|
||||||
|
|
||||||
task = asyncio.create_task(manager._dispatch_outbound())
|
task = asyncio.create_task(manager._dispatch_outbound())
|
||||||
@@ -343,37 +391,13 @@ class TestProgressFiltering:
|
|||||||
assert send_mock.await_args_list[0].args[0].content == "final answer"
|
assert send_mock.await_args_list[0].args[0].content == "final answer"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_progress_flag_uses_runtime_progress_filter(self, manager, bus):
|
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
|
||||||
manager.channels["mock"].send_progress = False
|
manager.channels["mock"].send_tool_hints = True
|
||||||
await bus.publish_outbound(OutboundMessage(
|
await bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="legacy progress-shaped message",
|
content="read_file(foo.py)",
|
||||||
metadata={"_progress": True},
|
metadata={"_progress": True, "_tool_hint": True},
|
||||||
))
|
|
||||||
|
|
||||||
task = asyncio.create_task(manager._dispatch_outbound())
|
|
||||||
try:
|
|
||||||
for _ in range(30):
|
|
||||||
if manager.channels["mock"]._send_mock.await_count >= 1:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
finally:
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
assert manager.channels["mock"]._send_mock.await_count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
|
|
||||||
manager.channels["mock"].send_tool_hints = True
|
|
||||||
await bus.publish_outbound(outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id="chat1",
|
|
||||||
event=ProgressEvent(content="read_file(foo.py)", tool_hint=True),
|
|
||||||
))
|
))
|
||||||
|
|
||||||
task = asyncio.create_task(manager._dispatch_outbound())
|
task = asyncio.create_task(manager._dispatch_outbound())
|
||||||
@@ -399,15 +423,24 @@ class TestRetryWaitFiltering:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_retry_wait_message_dropped(self, manager, bus):
|
async def test_retry_wait_message_dropped(self, manager, bus):
|
||||||
retry_msg = outbound_message_for_event(
|
"""A ``_retry_wait`` message must be filtered before channel dispatch.
|
||||||
|
|
||||||
|
Regression: provider retry diagnostics like
|
||||||
|
``Model request failed, retry in 1s (attempt 1).`` were being
|
||||||
|
delivered to end-user channels because the runner bound
|
||||||
|
``on_retry_wait`` to the progress callback.
|
||||||
|
"""
|
||||||
|
retry_msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
event=RetryWaitEvent(content="Model request failed, retry in 1s (attempt 1)."),
|
content="Model request failed, retry in 1s (attempt 1).",
|
||||||
|
metadata={"_retry_wait": True},
|
||||||
)
|
)
|
||||||
real_msg = OutboundMessage(
|
real_msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="chat1",
|
chat_id="chat1",
|
||||||
content="final answer",
|
content="final answer",
|
||||||
|
metadata={},
|
||||||
)
|
)
|
||||||
await bus.publish_outbound(retry_msg)
|
await bus.publish_outbound(retry_msg)
|
||||||
await bus.publish_outbound(real_msg)
|
await bus.publish_outbound(real_msg)
|
||||||
@@ -429,4 +462,4 @@ class TestRetryWaitFiltering:
|
|||||||
assert send_mock.await_count == 1
|
assert send_mock.await_count == 1
|
||||||
sent = send_mock.await_args_list[0].args[0]
|
sent = send_mock.await_args_list[0].args[0]
|
||||||
assert sent.content == "final answer"
|
assert sent.content == "final answer"
|
||||||
assert sent.event is None
|
assert not sent.metadata.get("_retry_wait")
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ channels that opt in via ``channel.show_reasoning``; plugins without a
|
|||||||
low-emphasis UI primitive keep the base no-op and the content silently
|
low-emphasis UI primitive keep the base no-op and the content silently
|
||||||
drops at dispatch.
|
drops at dispatch.
|
||||||
|
|
||||||
One-shot reasoning frames are represented as typed progress events and
|
One-shot ``_reasoning`` frames are accepted for back-compat with hooks
|
||||||
``BaseChannel.send_reasoning`` expands them to a single delta + end pair so
|
that haven't migrated yet — ``BaseChannel.send_reasoning`` expands them
|
||||||
plugins only implement the streaming primitives.
|
to a single delta + end pair so plugins only implement the streaming
|
||||||
|
primitives.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,7 +22,6 @@ from unittest.mock import AsyncMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import ProgressEvent, 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.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
@@ -48,11 +48,11 @@ class _MockChannel(BaseChannel):
|
|||||||
async def send(self, msg):
|
async def send(self, msg):
|
||||||
return await self._send_mock(msg)
|
return await self._send_mock(msg)
|
||||||
|
|
||||||
async def send_reasoning_delta(self, chat_id, delta, metadata=None, *, stream_id=None):
|
async def send_reasoning_delta(self, chat_id, delta, metadata=None):
|
||||||
return await self._delta_mock(chat_id, delta, metadata, stream_id=stream_id)
|
return await self._delta_mock(chat_id, delta, metadata)
|
||||||
|
|
||||||
async def send_reasoning_end(self, chat_id, metadata=None, *, stream_id=None):
|
async def send_reasoning_end(self, chat_id, metadata=None):
|
||||||
return await self._end_mock(chat_id, metadata, stream_id=stream_id)
|
return await self._end_mock(chat_id, metadata)
|
||||||
|
|
||||||
async def send_file_edit_events(self, chat_id, edits, metadata=None):
|
async def send_file_edit_events(self, chat_id, edits, metadata=None):
|
||||||
return await self._file_edit_mock(chat_id, edits, metadata)
|
return await self._file_edit_mock(chat_id, edits, metadata)
|
||||||
@@ -94,17 +94,17 @@ def test_websocket_gateway_uses_configured_workspace_restriction(tmp_path, monke
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
|
async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
|
||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content="step-by-step", reasoning_delta=True, stream_id="r1"),
|
content="step-by-step",
|
||||||
|
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
|
||||||
)
|
)
|
||||||
await manager._send_once(channel, msg)
|
await manager._send_once(channel, msg)
|
||||||
channel._delta_mock.assert_awaited_once()
|
channel._delta_mock.assert_awaited_once()
|
||||||
args = channel._delta_mock.await_args.args
|
args = channel._delta_mock.await_args.args
|
||||||
assert args[0] == "c1"
|
assert args[0] == "c1"
|
||||||
assert args[1] == "step-by-step"
|
assert args[1] == "step-by-step"
|
||||||
assert channel._delta_mock.await_args.kwargs["stream_id"] == "r1"
|
|
||||||
channel._send_mock.assert_not_awaited()
|
channel._send_mock.assert_not_awaited()
|
||||||
channel._end_mock.assert_not_awaited()
|
channel._end_mock.assert_not_awaited()
|
||||||
|
|
||||||
@@ -112,10 +112,11 @@ async def test_reasoning_delta_routes_to_send_reasoning_delta(manager):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_end_routes_to_send_reasoning_end(manager):
|
async def test_reasoning_end_routes_to_send_reasoning_end(manager):
|
||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
|
content="",
|
||||||
|
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
|
||||||
)
|
)
|
||||||
await manager._send_once(channel, msg)
|
await manager._send_once(channel, msg)
|
||||||
channel._end_mock.assert_awaited_once()
|
channel._end_mock.assert_awaited_once()
|
||||||
@@ -123,13 +124,16 @@ async def test_reasoning_end_routes_to_send_reasoning_end(manager):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
|
async def test_legacy_one_shot_reasoning_expands_to_delta_plus_end(manager):
|
||||||
"""One-shot reasoning expands to a single delta + end."""
|
"""`_reasoning` (no delta/end pair) falls back through `send_reasoning`
|
||||||
|
which the base class expands to a single delta + end. Hooks that haven't
|
||||||
|
migrated still surface in WebUI as a complete stream segment."""
|
||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content="one-shot reasoning", reasoning=True),
|
content="one-shot reasoning",
|
||||||
|
metadata={"_progress": True, "_reasoning": True},
|
||||||
)
|
)
|
||||||
await manager._send_once(channel, msg)
|
await manager._send_once(channel, msg)
|
||||||
channel._delta_mock.assert_awaited_once()
|
channel._delta_mock.assert_awaited_once()
|
||||||
@@ -140,10 +144,11 @@ async def test_one_shot_reasoning_expands_to_delta_plus_end(manager):
|
|||||||
async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
|
async def test_dispatch_drops_reasoning_when_channel_opts_out(manager):
|
||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
channel.show_reasoning = False
|
channel.show_reasoning = False
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content="hidden thinking", reasoning_delta=True),
|
content="hidden thinking",
|
||||||
|
metadata={"_progress": True, "_reasoning_delta": True},
|
||||||
)
|
)
|
||||||
await manager.bus.publish_outbound(msg)
|
await manager.bus.publish_outbound(msg)
|
||||||
|
|
||||||
@@ -159,15 +164,17 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
|
|||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
channel.show_reasoning = True
|
channel.show_reasoning = True
|
||||||
for chunk in ("first ", "second"):
|
for chunk in ("first ", "second"):
|
||||||
await manager.bus.publish_outbound(outbound_message_for_event(
|
await manager.bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content=chunk, reasoning_delta=True, stream_id="r1"),
|
content=chunk,
|
||||||
|
metadata={"_progress": True, "_reasoning_delta": True, "_stream_id": "r1"},
|
||||||
))
|
))
|
||||||
await manager.bus.publish_outbound(outbound_message_for_event(
|
await manager.bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
|
content="",
|
||||||
|
metadata={"_progress": True, "_reasoning_end": True, "_stream_id": "r1"},
|
||||||
))
|
))
|
||||||
|
|
||||||
await _pump_one(manager)
|
await _pump_one(manager)
|
||||||
@@ -178,10 +185,11 @@ async def test_dispatch_delivers_reasoning_when_channel_opts_in(manager):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
|
async def test_dispatch_silently_drops_reasoning_for_unknown_channel(manager):
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="ghost",
|
channel="ghost",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content="nobody home", reasoning_delta=True),
|
content="nobody home",
|
||||||
|
metadata={"_progress": True, "_reasoning_delta": True},
|
||||||
)
|
)
|
||||||
await manager.bus.publish_outbound(msg)
|
await manager.bus.publish_outbound(msg)
|
||||||
|
|
||||||
@@ -221,34 +229,17 @@ async def test_base_channel_reasoning_primitives_are_noop_safe():
|
|||||||
async def test_file_edit_events_route_to_channel_capability(manager):
|
async def test_file_edit_events_route_to_channel_capability(manager):
|
||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
|
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(file_edit_events=edits),
|
content="",
|
||||||
|
metadata={"_progress": True, "_file_edit_events": edits},
|
||||||
)
|
)
|
||||||
|
|
||||||
await manager._send_once(channel, msg)
|
await manager._send_once(channel, msg)
|
||||||
|
|
||||||
channel._file_edit_mock.assert_awaited_once_with(
|
channel._file_edit_mock.assert_awaited_once_with(
|
||||||
"c1", edits, msg.metadata
|
"c1", edits, {"_progress": True, "_file_edit_events": edits}
|
||||||
)
|
|
||||||
channel._send_mock.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_typed_file_edit_event_routes_to_channel_capability(manager):
|
|
||||||
channel = manager.channels["mock"]
|
|
||||||
edits = [{"version": 1, "phase": "start", "path": "src/app.py"}]
|
|
||||||
msg = outbound_message_for_event(
|
|
||||||
channel="mock",
|
|
||||||
chat_id="c1",
|
|
||||||
event=ProgressEvent(file_edit_events=edits),
|
|
||||||
)
|
|
||||||
|
|
||||||
await manager._send_once(channel, msg)
|
|
||||||
|
|
||||||
channel._file_edit_mock.assert_awaited_once_with(
|
|
||||||
"c1", edits, msg.metadata
|
|
||||||
)
|
)
|
||||||
channel._send_mock.assert_not_awaited()
|
channel._send_mock.assert_not_awaited()
|
||||||
|
|
||||||
@@ -279,10 +270,11 @@ async def test_reasoning_routing_does_not_consult_send_progress(manager):
|
|||||||
channel = manager.channels["mock"]
|
channel = manager.channels["mock"]
|
||||||
channel.send_progress = False
|
channel.send_progress = False
|
||||||
channel.show_reasoning = True
|
channel.show_reasoning = True
|
||||||
await manager.bus.publish_outbound(outbound_message_for_event(
|
await manager.bus.publish_outbound(OutboundMessage(
|
||||||
channel="mock",
|
channel="mock",
|
||||||
chat_id="c1",
|
chat_id="c1",
|
||||||
event=ProgressEvent(content="still surfaces", reasoning_delta=True),
|
content="still surfaces",
|
||||||
|
metadata={"_progress": True, "_reasoning_delta": True},
|
||||||
))
|
))
|
||||||
|
|
||||||
await _pump_one(manager)
|
await _pump_one(manager)
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
|
||||||
ProgressEvent,
|
|
||||||
StreamDeltaEvent,
|
|
||||||
StreamedResponseEvent,
|
|
||||||
StreamEndEvent,
|
|
||||||
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.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
@@ -725,7 +718,7 @@ async def test_send_with_retry_no_retry_when_max_is_zero():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_with_retry_calls_send_delta():
|
async def test_send_with_retry_calls_send_delta():
|
||||||
"""_send_with_retry should call send_delta for stream delta events."""
|
"""_send_with_retry should call send_delta when metadata has _stream_delta."""
|
||||||
send_delta_called = False
|
send_delta_called = False
|
||||||
|
|
||||||
class _StreamingChannel(BaseChannel):
|
class _StreamingChannel(BaseChannel):
|
||||||
@@ -741,16 +734,7 @@ async def test_send_with_retry_calls_send_delta():
|
|||||||
async def send(self, msg: OutboundMessage) -> None:
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
pass # Should not be called
|
pass # Should not be called
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
nonlocal send_delta_called
|
nonlocal send_delta_called
|
||||||
send_delta_called = True
|
send_delta_called = True
|
||||||
|
|
||||||
@@ -765,147 +749,18 @@ async def test_send_with_retry_calls_send_delta():
|
|||||||
mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
|
mgr.channels = {"streaming": _StreamingChannel(fake_config, mgr.bus)}
|
||||||
mgr._dispatch_task = None
|
mgr._dispatch_task = None
|
||||||
|
|
||||||
msg = outbound_message_for_event(
|
msg = OutboundMessage(
|
||||||
channel="streaming",
|
channel="streaming", chat_id="123", content="test delta",
|
||||||
chat_id="123",
|
metadata={"_stream_delta": True}
|
||||||
event=StreamDeltaEvent(content="test delta"),
|
|
||||||
)
|
)
|
||||||
await mgr._send_with_retry(mgr.channels["streaming"], msg)
|
await mgr._send_with_retry(mgr.channels["streaming"], msg)
|
||||||
|
|
||||||
assert send_delta_called is True
|
assert send_delta_called is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_with_retry_supports_legacy_stream_delta_signature():
|
|
||||||
"""External plugins with the old send_delta signature should keep working."""
|
|
||||||
calls: list[tuple[str, str, dict]] = []
|
|
||||||
|
|
||||||
class _LegacyStreamingChannel(BaseChannel):
|
|
||||||
name = "legacy_streaming"
|
|
||||||
display_name = "Legacy Streaming"
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send_delta(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
calls.append((chat_id, delta, dict(metadata or {})))
|
|
||||||
|
|
||||||
fake_config = SimpleNamespace(
|
|
||||||
channels=ChannelsConfig(send_max_retries=3),
|
|
||||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
|
||||||
)
|
|
||||||
mgr = ChannelManager.__new__(ChannelManager)
|
|
||||||
mgr.config = fake_config
|
|
||||||
mgr.bus = MessageBus()
|
|
||||||
mgr.channels = {"legacy_streaming": _LegacyStreamingChannel(fake_config, mgr.bus)}
|
|
||||||
mgr._dispatch_task = None
|
|
||||||
|
|
||||||
await mgr._send_with_retry(
|
|
||||||
mgr.channels["legacy_streaming"],
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="legacy_streaming",
|
|
||||||
chat_id="123",
|
|
||||||
event=StreamDeltaEvent(content="hello", stream_id="s1"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await mgr._send_with_retry(
|
|
||||||
mgr.channels["legacy_streaming"],
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="legacy_streaming",
|
|
||||||
chat_id="123",
|
|
||||||
event=StreamEndEvent(content="", stream_id="s1", resuming=True),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert calls == [
|
|
||||||
("123", "hello", {"_stream_id": "s1", "_stream_delta": True}),
|
|
||||||
("123", "", {"_stream_id": "s1", "_stream_end": True}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_with_retry_supports_legacy_reasoning_signature():
|
|
||||||
"""External plugins with the old reasoning hook signature should keep working."""
|
|
||||||
deltas: list[tuple[str, str, dict]] = []
|
|
||||||
ends: list[tuple[str, dict]] = []
|
|
||||||
|
|
||||||
class _LegacyReasoningChannel(BaseChannel):
|
|
||||||
name = "legacy_reasoning"
|
|
||||||
display_name = "Legacy Reasoning"
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send(self, msg: OutboundMessage) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def send_reasoning_delta(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
deltas.append((chat_id, delta, dict(metadata or {})))
|
|
||||||
|
|
||||||
async def send_reasoning_end(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
) -> None:
|
|
||||||
ends.append((chat_id, dict(metadata or {})))
|
|
||||||
|
|
||||||
fake_config = SimpleNamespace(
|
|
||||||
channels=ChannelsConfig(send_max_retries=3),
|
|
||||||
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
|
||||||
)
|
|
||||||
mgr = ChannelManager.__new__(ChannelManager)
|
|
||||||
mgr.config = fake_config
|
|
||||||
mgr.bus = MessageBus()
|
|
||||||
mgr.channels = {"legacy_reasoning": _LegacyReasoningChannel(fake_config, mgr.bus)}
|
|
||||||
mgr._dispatch_task = None
|
|
||||||
|
|
||||||
await mgr._send_with_retry(
|
|
||||||
mgr.channels["legacy_reasoning"],
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="legacy_reasoning",
|
|
||||||
chat_id="123",
|
|
||||||
event=ProgressEvent(content="thinking", reasoning_delta=True, stream_id="r1"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await mgr._send_with_retry(
|
|
||||||
mgr.channels["legacy_reasoning"],
|
|
||||||
outbound_message_for_event(
|
|
||||||
channel="legacy_reasoning",
|
|
||||||
chat_id="123",
|
|
||||||
event=ProgressEvent(reasoning_end=True, stream_id="r1"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert deltas == [
|
|
||||||
("123", "thinking", {"_reasoning_delta": True, "_stream_id": "r1"}),
|
|
||||||
]
|
|
||||||
assert ends == [
|
|
||||||
("123", {"_reasoning_end": True, "_stream_id": "r1"}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_with_retry_skips_send_when_streamed():
|
async def test_send_with_retry_skips_send_when_streamed():
|
||||||
"""_send_with_retry should not call send for streamed response events."""
|
"""_send_with_retry should not call send when metadata has _streamed flag."""
|
||||||
send_called = False
|
send_called = False
|
||||||
send_delta_called = False
|
send_delta_called = False
|
||||||
|
|
||||||
@@ -923,16 +778,7 @@ async def test_send_with_retry_skips_send_when_streamed():
|
|||||||
nonlocal send_called
|
nonlocal send_called
|
||||||
send_called = True
|
send_called = True
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(self, chat_id: str, delta: str, metadata: dict | None = None) -> None:
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
delta: str,
|
|
||||||
metadata: dict | None = None,
|
|
||||||
*,
|
|
||||||
stream_id: str | None = None,
|
|
||||||
stream_end: bool = False,
|
|
||||||
resuming: bool = False,
|
|
||||||
) -> None:
|
|
||||||
nonlocal send_delta_called
|
nonlocal send_delta_called
|
||||||
send_delta_called = True
|
send_delta_called = True
|
||||||
|
|
||||||
@@ -947,11 +793,10 @@ async def test_send_with_retry_skips_send_when_streamed():
|
|||||||
mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
|
mgr.channels = {"streamed": _StreamedChannel(fake_config, mgr.bus)}
|
||||||
mgr._dispatch_task = None
|
mgr._dispatch_task = None
|
||||||
|
|
||||||
msg = outbound_message_for_event(
|
# _streamed means message was already sent via send_delta, so skip send
|
||||||
channel="streamed",
|
msg = OutboundMessage(
|
||||||
chat_id="123",
|
channel="streamed", chat_id="123", content="test",
|
||||||
event=StreamedResponseEvent(),
|
metadata={"_streamed": True}
|
||||||
content="test",
|
|
||||||
)
|
)
|
||||||
await mgr._send_with_retry(mgr.channels["streamed"], msg)
|
await mgr._send_with_retry(mgr.channels["streamed"], msg)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pytest.importorskip("discord")
|
|||||||
import discord
|
import discord
|
||||||
|
|
||||||
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.discord import (
|
from nanobot.channels.discord import (
|
||||||
MAX_MESSAGE_LEN,
|
MAX_MESSAGE_LEN,
|
||||||
@@ -719,9 +718,9 @@ async def test_send_delta_streams_by_editing_message(monkeypatch) -> None:
|
|||||||
times = iter([1.0, 3.0, 5.0])
|
times = iter([1.0, 3.0, 5.0])
|
||||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
|
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 5.0))
|
||||||
|
|
||||||
await owner.send_delta("123", "hel", stream_id="s1")
|
await owner.send_delta("123", "hel", {"_stream_delta": True, "_stream_id": "s1"})
|
||||||
await owner.send_delta("123", "lo", stream_id="s1")
|
await owner.send_delta("123", "lo", {"_stream_delta": True, "_stream_id": "s1"})
|
||||||
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
|
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
||||||
|
|
||||||
assert target.sent_payloads[0] == {"content": "hel"}
|
assert target.sent_payloads[0] == {"content": "hel"}
|
||||||
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
|
assert target.sent_messages[0].edits == [{"content": "hello"}, {"content": "hello"}]
|
||||||
@@ -746,9 +745,9 @@ async def test_send_delta_stream_end_splits_oversized_reply(monkeypatch) -> None
|
|||||||
times = iter([1.0, 3.0])
|
times = iter([1.0, 3.0])
|
||||||
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
|
monkeypatch.setattr("nanobot.channels.discord.time.monotonic", lambda: next(times, 3.0))
|
||||||
|
|
||||||
await owner.send_delta("123", prefix, stream_id="s1")
|
await owner.send_delta("123", prefix, {"_stream_delta": True, "_stream_id": "s1"})
|
||||||
await owner.send_delta("123", suffix, stream_id="s1")
|
await owner.send_delta("123", suffix, {"_stream_delta": True, "_stream_id": "s1"})
|
||||||
await owner.send_delta("123", "", stream_id="s1", stream_end=True)
|
await owner.send_delta("123", "", {"_stream_end": True, "_stream_id": "s1"})
|
||||||
|
|
||||||
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
|
assert target.sent_payloads == [{"content": prefix}, {"content": chunks[1]}]
|
||||||
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
|
assert target.sent_messages[0].edits == [{"content": chunks[0]}, {"content": chunks[0]}]
|
||||||
@@ -1074,7 +1073,7 @@ async def test_send_stops_typing_after_send() -> None:
|
|||||||
channel="discord",
|
channel="discord",
|
||||||
chat_id="123",
|
chat_id="123",
|
||||||
content="progress",
|
content="progress",
|
||||||
event=ProgressEvent(content="progress"),
|
metadata={"_progress": True},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
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.email import EmailChannel, EmailConfig
|
from nanobot.channels.email import EmailChannel, EmailConfig
|
||||||
|
|
||||||
@@ -869,7 +868,10 @@ async def test_send_skips_progress_messages_before_smtp(monkeypatch) -> None:
|
|||||||
channel="email",
|
channel="email",
|
||||||
chat_id="alice@example.com",
|
chat_id="alice@example.com",
|
||||||
content="",
|
content="",
|
||||||
event=ProgressEvent(tool_events=[{"phase": "end", "name": "exec"}]),
|
metadata={
|
||||||
|
"_progress": True,
|
||||||
|
"_tool_events": [{"phase": "end", "name": "exec"}],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -193,8 +193,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"message_id": "om_001"},
|
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||||
stream_end=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||||
@@ -211,7 +210,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
stream_end=True,
|
metadata={"_stream_end": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
@@ -228,8 +227,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"message_id": "om_001"},
|
metadata={"_stream_end": True, "message_id": "om_001"},
|
||||||
stream_end=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
@@ -244,7 +242,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
|
||||||
ch._remove_reaction = AsyncMock()
|
ch._remove_reaction = AsyncMock()
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|
||||||
@@ -262,7 +260,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_removal_when_resuming(self):
|
async def test_no_removal_when_resuming(self):
|
||||||
"""resuming=True means more tool-call rounds follow; reaction must persist."""
|
"""_resuming=True means more tool-call rounds follow; reaction must persist."""
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
ch.config.done_emoji = "DONE"
|
ch.config.done_emoji = "DONE"
|
||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||||
@@ -276,9 +274,7 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"message_id": "om_001"},
|
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
|
||||||
stream_end=True,
|
|
||||||
resuming=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
@@ -303,23 +299,19 @@ class TestStreamEndReactionCleanup:
|
|||||||
# Intermediate stream end (more tool calls coming).
|
# Intermediate stream end (more tool calls coming).
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"message_id": "om_001"},
|
metadata={"_stream_end": True, "_resuming": True, "message_id": "om_001"},
|
||||||
stream_end=True,
|
|
||||||
resuming=True,
|
|
||||||
)
|
)
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
ch._add_reaction.assert_not_called()
|
ch._add_reaction.assert_not_called()
|
||||||
|
|
||||||
# Re-prime the stream buffer for the final round (the previous stream end popped it).
|
# Re-prime the stream buffer for the final round (the previous _stream_end popped it).
|
||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||||
text="t", card_id="card_1", sequence=5, last_edit=0.0,
|
text="t", card_id="card_1", sequence=5, last_edit=0.0,
|
||||||
)
|
)
|
||||||
# Final stream end (resuming=False): OnIt removed, done_emoji added.
|
# Final stream end (resuming=False): OnIt removed, done_emoji added.
|
||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1", "",
|
"oc_chat1", "",
|
||||||
metadata={"message_id": "om_001"},
|
metadata={"_stream_end": True, "_resuming": False, "message_id": "om_001"},
|
||||||
stream_end=True,
|
|
||||||
resuming=False,
|
|
||||||
)
|
)
|
||||||
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
|
||||||
ch._add_reaction.assert_called_once_with("om_001", "DONE")
|
ch._add_reaction.assert_called_once_with("om_001", "DONE")
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ if not FEISHU_AVAILABLE:
|
|||||||
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
pytest.skip("Feishu dependencies not installed (lark-oapi)", allow_module_level=True)
|
||||||
|
|
||||||
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.feishu import FeishuChannel, FeishuConfig
|
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
|
||||||
|
|
||||||
@@ -333,8 +332,7 @@ async def test_send_skips_reply_for_progress_messages() -> None:
|
|||||||
channel="feishu",
|
channel="feishu",
|
||||||
chat_id="oc_abc",
|
chat_id="oc_abc",
|
||||||
content="thinking...",
|
content="thinking...",
|
||||||
event=ProgressEvent(content="thinking..."),
|
metadata={"message_id": "om_001", "_progress": True},
|
||||||
metadata={"message_id": "om_001"},
|
|
||||||
))
|
))
|
||||||
|
|
||||||
channel._client.im.v1.message.create.assert_called_once()
|
channel._client.im.v1.message.create.assert_called_once()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
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.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
from nanobot.channels.feishu import FeishuChannel, FeishuConfig, _FeishuStreamBuf
|
||||||
|
|
||||||
@@ -273,7 +272,7 @@ class TestSendDelta:
|
|||||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
ch._client.cardkit.v1.card_element.content.assert_called_once()
|
||||||
@@ -290,7 +289,7 @@ class TestSendDelta:
|
|||||||
)
|
)
|
||||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||||
@@ -307,8 +306,7 @@ class TestSendDelta:
|
|||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1",
|
"oc_chat1",
|
||||||
"",
|
"",
|
||||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
stream_end=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._client.im.v1.message.create.assert_called_once()
|
ch._client.im.v1.message.create.assert_called_once()
|
||||||
@@ -328,11 +326,11 @@ class TestSendDelta:
|
|||||||
"oc_chat1",
|
"oc_chat1",
|
||||||
"",
|
"",
|
||||||
metadata={
|
metadata={
|
||||||
|
"_stream_end": True,
|
||||||
"message_id": "om_001",
|
"message_id": "om_001",
|
||||||
"chat_type": "group",
|
"chat_type": "group",
|
||||||
"thread_id": "ot_001",
|
"thread_id": "ot_001",
|
||||||
},
|
},
|
||||||
stream_end=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._client.im.v1.message.reply.assert_called_once()
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
@@ -353,8 +351,7 @@ class TestSendDelta:
|
|||||||
await ch.send_delta(
|
await ch.send_delta(
|
||||||
"oc_chat1",
|
"oc_chat1",
|
||||||
"",
|
"",
|
||||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
metadata={"_stream_end": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
stream_end=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
ch._client.im.v1.message.reply.assert_called_once()
|
ch._client.im.v1.message.reply.assert_called_once()
|
||||||
@@ -372,7 +369,7 @@ class TestSendDelta:
|
|||||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response(success=False)
|
||||||
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
ch._client.im.v1.message.create.return_value = _mock_send_response("om_fb")
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
assert ch._client.cardkit.v1.card.settings.call_count == 2
|
assert ch._client.cardkit.v1.card.settings.call_count == 2
|
||||||
@@ -391,7 +388,7 @@ class TestSendDelta:
|
|||||||
]
|
]
|
||||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response(True)
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
assert ch._client.cardkit.v1.card_element.content.call_count == 2
|
assert ch._client.cardkit.v1.card_element.content.call_count == 2
|
||||||
@@ -401,7 +398,7 @@ class TestSendDelta:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stream_end_without_buf_is_noop(self):
|
async def test_stream_end_without_buf_is_noop(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
ch._client.cardkit.v1.card_element.content.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -449,7 +446,7 @@ class TestToolHintInlineStreaming:
|
|||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='web_fetch("https://example.com")',
|
content='web_fetch("https://example.com")',
|
||||||
event=ProgressEvent(content='web_fetch("https://example.com")', tool_hint=True),
|
metadata={"_tool_hint": True},
|
||||||
)
|
)
|
||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
@@ -485,7 +482,7 @@ class TestToolHintInlineStreaming:
|
|||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='read_file("path")',
|
content='read_file("path")',
|
||||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
metadata={"_tool_hint": True},
|
||||||
)
|
)
|
||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
@@ -500,8 +497,7 @@ class TestToolHintInlineStreaming:
|
|||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='read_file("path")',
|
content='read_file("path")',
|
||||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
|
||||||
)
|
)
|
||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
@@ -518,8 +514,8 @@ class TestToolHintInlineStreaming:
|
|||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='read_file("path")',
|
content='read_file("path")',
|
||||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
|
||||||
metadata={
|
metadata={
|
||||||
|
"_tool_hint": True,
|
||||||
"message_id": "om_001",
|
"message_id": "om_001",
|
||||||
"chat_type": "group",
|
"chat_type": "group",
|
||||||
"thread_id": "ot_001",
|
"thread_id": "ot_001",
|
||||||
@@ -542,8 +538,7 @@ class TestToolHintInlineStreaming:
|
|||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='read_file("path")',
|
content='read_file("path")',
|
||||||
event=ProgressEvent(content='read_file("path")', tool_hint=True),
|
metadata={"_tool_hint": True, "message_id": "om_001", "chat_type": "group"},
|
||||||
metadata={"message_id": "om_001", "chat_type": "group"},
|
|
||||||
)
|
)
|
||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
@@ -563,15 +558,13 @@ class TestToolHintInlineStreaming:
|
|||||||
|
|
||||||
msg1 = OutboundMessage(
|
msg1 = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='$ cd /project',
|
content='$ cd /project', metadata={"_tool_hint": True},
|
||||||
event=ProgressEvent(content='$ cd /project', tool_hint=True),
|
|
||||||
)
|
)
|
||||||
await ch.send(msg1)
|
await ch.send(msg1)
|
||||||
|
|
||||||
msg2 = OutboundMessage(
|
msg2 = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content='$ git status',
|
content='$ git status', metadata={"_tool_hint": True},
|
||||||
event=ProgressEvent(content='$ git status', tool_hint=True),
|
|
||||||
)
|
)
|
||||||
await ch.send(msg2)
|
await ch.send(msg2)
|
||||||
|
|
||||||
@@ -584,7 +577,7 @@ class TestToolHintInlineStreaming:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_hint_preserved_on_final_stream_end(self):
|
async def test_tool_hint_preserved_on_final_stream_end(self):
|
||||||
"""When stream end closes the card, tool hint is kept in the final text."""
|
"""When final _stream_end closes the card, tool hint is kept in the final text."""
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||||
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
|
text="Final content\n\n🔧 web_fetch(\"url\")\n\n",
|
||||||
@@ -593,7 +586,7 @@ class TestToolHintInlineStreaming:
|
|||||||
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
ch._client.cardkit.v1.card_element.content.return_value = _mock_content_response()
|
||||||
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
ch._client.cardkit.v1.card.settings.return_value = _mock_content_response()
|
||||||
|
|
||||||
await ch.send_delta("oc_chat1", "", stream_end=True)
|
await ch.send_delta("oc_chat1", "", metadata={"_stream_end": True})
|
||||||
|
|
||||||
assert "oc_chat1" not in ch._stream_bufs
|
assert "oc_chat1" not in ch._stream_bufs
|
||||||
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
|
update_call = ch._client.cardkit.v1.card_element.content.call_args[0][0]
|
||||||
@@ -610,8 +603,7 @@ class TestToolHintInlineStreaming:
|
|||||||
for content in ("", " ", "\t\n"):
|
for content in ("", " ", "\t\n"):
|
||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
channel="feishu", chat_id="oc_chat1",
|
channel="feishu", chat_id="oc_chat1",
|
||||||
content=content,
|
content=content, metadata={"_tool_hint": True},
|
||||||
event=ProgressEvent(content=content, tool_hint=True),
|
|
||||||
)
|
)
|
||||||
await ch.send(msg)
|
await ch.send(msg)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user