Compare commits

...
Author SHA1 Message Date
chengyongru 42ebe3a6b0 fix(whatsapp): inspect remote media before dispatch 2026-08-05 15:37:34 +08:00
chengyongru 47029757e5 fix(whatsapp): normalize detected audio MIME aliases 2026-08-01 12:31:53 +08:00
chengyongru 10ce839e3d test(whatsapp): reuse media send client mock 2026-08-01 12:14:17 +08:00
chengyongru 353dfed502 fix(whatsapp): route media by detected MIME type 2026-08-01 12:09:54 +08:00
chengyongruandGitHub cdb75f8e7d feat(providers): support DeepSeek Responses API (#5197) 2026-08-01 11:53:51 +08:00
chengyongruandGitHub 971b977a84 fix(weixin): recover refreshed state after session expiry (#5196) 2026-08-01 00:28:21 +08:00
54650332fb fix(slack): scope channel thread openers to their own session
A top-level channel message that opens a thread fell back to the
channel-wide session, because the session key required `raw_thread_ts` —
which Slack only sets on messages that already arrived inside a thread.
Every new thread therefore began life in one shared channel session and
only became thread-scoped from its first reply onward, so unrelated
threads saw each other's opening turns.

Key off `thread_ts` instead. It is set both for messages arriving inside
a thread and for channel messages that `reply_in_thread` opens a thread
for. DM roots never get a `thread_ts`, so they keep the default per-chat
session and the DM routing from 82c5083 is preserved; with
`reply_in_thread` disabled no thread exists and the channel session is
still used.

This restores the per-thread isolation introduced in #1048.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:47:26 +08:00
chengyongruandGitHub 172fe4f991 fix(webui): preserve user scroll ownership near tail (#5193) 2026-07-31 23:37:26 +08:00
shixi-liandchengyongru dda9b61b1e fix(config): install timezone data on all platforms 2026-07-31 19:55:22 +08:00
chengyongruandGitHub 6a1a45d07a feat: preserve Responses reasoning state and compact context (#5172) 2026-07-30 22:39:43 +08:00
Solaris-starandXubin Ren 511c764f45 fix(agent): route finish_reason='length' with blank content to length recovery
When an LLM response arrives with finish_reason='length' and has_tool_calls
but blank text content (e.g. the model spent its whole output budget on a
tool call whose closing tag was truncated), the runner dropped the tool
calls and then misrouted the blank response into the empty-response retry
branch. Retrying the same prompt cannot recover from output-budget
exhaustion, so every retry hit the same length ceiling and the turn ended
in the generic apology.

The length-recovery branch was gated on 'finish_reason == length and not
is_blank_text(clean)', so a blank-but-truncated turn could never reach it.

- The empty-response retry branch now excludes finish_reason == 'length'
  (in addition to 'error').
- The length-recovery branch no longer requires non-blank content, so a
  blank-but-truncated turn enters recovery and appends
  build_length_recovery_message (which handles a blank tail safely).

Adds a regression test asserting the length-recovery path is taken; it
fails on the unfixed code and passes with the fix.

Fixes #5133
2026-07-30 19:55:19 +08:00
Xubin Ren 0eac82984c test(mcp): stabilize idle reconnect timing 2026-07-30 19:44:09 +08:00
yu-xin-candXubin Ren 5e67fbf93e fix(exec): bound buffered session output 2026-07-30 19:44:09 +08:00
yu-xin-candXubin Ren 9ec4420104 fix(agent): release idle session locks 2026-07-30 19:17:37 +08:00
KDBandXubin Ren 52680dbe19 fix(pairing): keep approvals across transient store read failures
_load() treated any OSError like corruption and returned an empty store. When pairing.json was transiently unreadable, an unapproved DM could deny the sender, generate a pairing code from the empty view, and overwrite the store without its approved senders.

Keep the existing JSONDecodeError reset behavior, but propagate OSError so mutations cannot persist unreadable state. Read-only checks fail closed without writing; mutating /pairing subcommands report temporary unavailability; and the DM pairing path skips one reply instead of crashing the handler.

This mirrors the refuse-to-overwrite strategy used by the cron and trigger stores.
2026-07-30 19:02:37 +08:00
KDBandXubin Ren e633f867e8 fix(session): tolerate invalid idle-compaction timestamps 2026-07-30 18:52:16 +08:00
KDBandXubin Ren 07c2677eed fix(webui): drop malformed token-usage day keys
normalize_token_usage_state only length-checked persisted day keys, so a
hand-edited or foreign 10-char key (e.g. "not-a-dat3" or "2026-13-01") in
token-usage.json survived reads and atomic rewrites. token_usage_payload
then parsed every day key with an unguarded datetime.fromisoformat, so one
such key failed every /api/settings and /api/settings/usage request until
the file was repaired by hand.

Validate day keys in normalize_token_usage_state, the shared boundary that
every read, record, and rewrite already funnels through. Malformed keys are
dropped like other malformed rows and scrubbed from the file on the next
write; valid state is unchanged.
2026-07-30 18:41:44 +08:00
92361cbeac fix(gitstore): return real git object ids instead of hex-of-hex
`porcelain.commit()` and `repo.refs[...]` hand back object ids as a
40-character hex string that is already encoded to bytes. Calling `.hex()`
on that encodes the ASCII a second time, so every id GitStore produced or
displayed was double-encoded:

    auto_commit()          -> '62623234'
    git log --abbrev=8     -> 'bb244606'

The module is self-consistently wrong, so `/dream-log` and `/dream-restore`
work as long as the id came from nanobot itself. What does not work is
crossing the boundary: ids in logs and commit output match nothing in
`git log`, and an id copied from `git log` cannot be resolved:

    _resolve_sha(own id)      -> b'bb244606d780...'
    _resolve_sha(real git id) -> None

Use `.decode()` at the four sites that consume dulwich object ids. Nothing
persists an id — callers either display it or resolve it live — so there is
no stored state in the old format.

Adds two regression tests: the id returned by `auto_commit` must equal
`git log --abbrev=8`, and a real git id must resolve through `_resolve_sha`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 18:25:56 +08:00
chengyongruandchengyongru bb2f6cf324 fix(webui): preserve automation source on streamed replies 2026-07-30 17:57:31 +08:00
chengyongruandGitHub 606ac56e8f feat(webui): support remote Codex OAuth login (#5174) 2026-07-30 15:06:34 +08:00
chengyongruandGitHub e2563e2e74 refactor(cli): split commands into focused modules (#5175) 2026-07-30 15:01:35 +08:00
chengyongruandGitHub ad6900e56c refactor(session): separate persistence behind SessionStore (#5170) 2026-07-30 11:51:13 +08:00
chengyongruandGitHub c33c188afb fix(session): preserve history during idle compaction (#5167) 2026-07-30 10:45:45 +08:00
chengyongruandGitHub 11fcd9cc5f fix(webui): prevent redundant thread and media reloads (#5164) 2026-07-30 10:25:22 +08:00
chengyongruandchengyongru fc73d5ff39 fix(webui): avoid false microphone silence errors 2026-07-30 09:02:40 +08:00
Xubin Ren 0fe3c5aa2c fix(webui): satisfy strict skill marketplace typing 2026-07-30 01:13:37 +08:00
Xubin Ren c440695aef fix(webui): harden skill marketplace lifecycle 2026-07-30 01:13:37 +08:00
Xubin Ren 8a56eb06ad fix(webui): harden skill management 2026-07-30 01:13:37 +08:00
Xubin Ren d276fe1386 perf(webui): make marketplace filters instant 2026-07-30 01:13:37 +08:00
Xubin Ren 12ebc7b8e2 style(webui): use package install icon 2026-07-30 01:13:37 +08:00
Xubin Ren 164726c43f style(webui): quiet marketplace install actions 2026-07-30 01:13:37 +08:00
Xubin Ren 62b132b698 style(webui): reduce repeated marketplace labels 2026-07-30 01:13:37 +08:00
Xubin Ren e66eb204d0 feat(webui): add SkillHub marketplace source 2026-07-30 01:13:37 +08:00
Xubin Ren 42ee34e34d fix(webui): localize skills view tabs 2026-07-30 01:13:37 +08:00
Xubin Ren 73d3a49a27 style(webui): float in conversation highlight 2026-07-30 01:13:37 +08:00
Xubin Ren 0a8bc0ac29 style(webui): slide active conversation highlight 2026-07-30 01:13:37 +08:00
Xubin Ren 962cdb968d style(webui): animate settings section transitions 2026-07-30 01:13:37 +08:00
Xubin Ren 7561d846fa style(webui): highlight selected conversations 2026-07-30 01:13:37 +08:00
Xubin Ren cad9b07deb fix(webui): render adjacent CJK emphasis 2026-07-30 01:13:37 +08:00
Xubin Ren 9c6e1b073f fix(webui): align ordered list markers 2026-07-30 01:13:37 +08:00
Xubin Ren 9b5a5c5302 style(webui): refine skills mobile experience 2026-07-30 01:13:37 +08:00
Xubin Ren 655cd80651 style(webui): clarify skill row interactions 2026-07-30 01:13:37 +08:00
Xubin Ren 9ceacc540c fix(webui): avoid parsing currency as math 2026-07-30 01:13:37 +08:00
Xubin Ren ba0ba4749d feat(webui): add skills marketplace 2026-07-30 01:13:37 +08:00
chengyongruandGitHub 129b74b4cf feat(webui): track optimistic message delivery status (#5162) 2026-07-29 23:15:45 +08:00
ZhouandGitHub 5a28a6165c fix(shell): preserve UTF-8 native input on PowerShell 5 (#5160)
Windows PowerShell 5.1 defaults $OutputEncoding to US-ASCII, corrupting non-ASCII strings piped to native commands. Set it from the console encoding only on legacy versions so PowerShell 7 keeps its defaults.
2026-07-29 21:47:01 +08:00
chengyongruandGitHub 757ad9c764 refactor: enforce BasedPyright strict type checking (#5158) 2026-07-29 21:37:11 +08:00
e703481755 fix(memory): expose media references to session consolidation (#5157)
Co-authored-by: shakewingo <yaoyingshakewin@gmail.com>
Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com>
2026-07-29 15:18:44 +08:00
chengyongruandGitHub 393d429e0a fix(ci): stabilize and speed up CI (#5145) 2026-07-28 22:55:59 +08:00
296 changed files with 23920 additions and 6723 deletions
+8
View File
@@ -24,6 +24,14 @@ Fix bugs by changing only what is necessary. Do not bundle unrelated refactors o
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review. A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
## Type dynamic boundaries at the edge
Wire payloads, persisted records, and third-party SDK objects are untrusted dynamic boundaries. Prefer a parser or small normalizer at the owning edge, and use `TypedDict` for stable dictionary shapes, so validation happens once and internal code receives a concrete type. Do not spread raw dynamic dictionaries or SDK objects through the core.
Stable first-party dependencies must be typed where they are stored or passed. Do not declare an internal service, context field, or callback result as `Any` and then recover its real type with consumer-side casts. Use the concrete type or a narrow `Protocol`; reserve `Any` for genuinely dynamic boundaries.
`typing.cast` performs no runtime validation. Every new cast must be supported by a runtime check on the same path or by an explicit invariant that is clear from construction and control flow (and documented locally when it is not obvious). If input can violate the claimed type, handle that invalid case before casting; never use `cast` only to silence BasedPyright.
## Explicit over magical ## Explicit over magical
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class. Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
+30
View File
@@ -5,10 +5,28 @@ on:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- docs/** - docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- docs/** - docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -68,14 +86,18 @@ jobs:
os: ubuntu-latest os: ubuntu-latest
python-version: "3.11" python-version: "3.11"
coverage: false coverage: false
pytest_args: ""
- name: latest, 3.14 + coverage - name: latest, 3.14 + coverage
os: ubuntu-latest os: ubuntu-latest
python-version: "3.14" python-version: "3.14"
coverage: true coverage: true
pytest_args: ""
- name: Windows, 3.14 - name: Windows, 3.14
os: windows-latest os: windows-latest
python-version: "3.14" python-version: "3.14"
coverage: false coverage: false
# Keep each test file in one worker while using both hosted-runner cores.
pytest_args: "-n 2 --dist loadfile"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -98,12 +120,19 @@ jobs:
- name: Install channel dependencies - name: Install channel dependencies
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
- name: Verify dependency consistency
run: uv pip check
# Channel requirements live in manifests rather than uv.lock. Avoid a # Channel requirements live in manifests rather than uv.lock. Avoid a
# later uv run sync pruning the packages installed by the previous step. # later uv run sync pruning the packages installed by the previous step.
- name: Lint with ruff - name: Lint with ruff
if: matrix.coverage if: matrix.coverage
run: uv run --no-sync ruff check nanobot tests conftest.py run: uv run --no-sync ruff check nanobot tests conftest.py
- name: Type check with BasedPyright (strict)
if: matrix.coverage
run: uv run --no-sync basedpyright
- name: Run tests with coverage - name: Run tests with coverage
if: matrix.coverage if: matrix.coverage
run: >- run: >-
@@ -115,6 +144,7 @@ jobs:
if: ${{ !matrix.coverage }} if: ${{ !matrix.coverage }}
run: >- run: >-
uv run --no-sync python -m pytest uv run --no-sync python -m pytest
${{ matrix.pytest_args }}
--durations=25 --durations-min=1.0 --durations=25 --durations-min=1.0
webui: webui:
+5
View File
@@ -11,6 +11,11 @@ nanobot is a lightweight, open-source AI agent framework written in Python with
pytest tests/test_openai_api.py::test_function -v pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/ ruff check nanobot/
# Strict type checking (matches CI)
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
# WebUI: dev server (proxies API/WS to gateway :8765), build, test # WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel) # Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
+14
View File
@@ -78,6 +78,20 @@ ruff check nanobot/
ruff format <files-you-changed> ruff format <files-you-changed>
``` ```
### Strict Type Checking
Strict type checking covers optional providers and channels. Reproduce the CI environment
with the same dependency sources and commands:
```bash
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
```
Keep `--no-sync` on the final commands: channel dependencies come from their package
manifests and are installed explicitly by the setup step.
## Contribution License ## Contribution License
By submitting a contribution, you confirm that you have the right to submit it By submitting a contribution, you confirm that you have the right to submit it
+11
View File
@@ -9,6 +9,17 @@ from collections.abc import Iterator
import certifi import certifi
import pytest import pytest
from loguru import logger
@pytest.fixture(autouse=True)
def _isolate_nanobot_log_activation() -> Iterator[None]:
"""Keep CLI log settings from leaking into later tests in the same process."""
logger.enable("nanobot")
try:
yield
finally:
logger.enable("nanobot")
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
+13
View File
@@ -348,6 +348,19 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
</details> </details>
<a id="responses-state-and-compaction"></a>
### Responses conversation state and compaction
Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required.
<details> <details>
<summary><b>Azure OpenAI</b></summary> <summary><b>Azure OpenAI</b></summary>
+4 -2
View File
@@ -229,7 +229,9 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
} }
``` ```
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. `providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
### Custom OpenAI-Compatible Endpoint ### Custom OpenAI-Compatible Endpoint
@@ -458,7 +460,7 @@ For GitHub Copilot:
nanobot provider login github-copilot --set-main nanobot provider login github-copilot --set-main
``` ```
Each command authenticates the selected provider and makes its current default model active. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors. Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
## Provider Resolution ## Provider Resolution
+1 -1
View File
@@ -150,7 +150,7 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. | | Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
| OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. | | OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. | | Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. | | Codex login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same callback URL back into the terminal. |
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. | | Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. | | Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. | | Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
+27 -1
View File
@@ -6,6 +6,32 @@ import tomllib
from importlib.metadata import PackageNotFoundError from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .agent.tools.context import RequestContext
from .bus.runtime_events import SessionTurnPersisted
from .nanobot import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
Nanobot,
RunResult,
RunStream,
SessionInfo,
SessionSnapshot,
StreamEvent,
StreamEventType,
)
from .runtime_context import RuntimeContextBlock, RuntimeContextProvider
def _read_pyproject_version() -> str | None: def _read_pyproject_version() -> str | None:
@@ -54,7 +80,7 @@ _LAZY_EXPORTS = {
} }
def __getattr__(name: str): def __getattr__(name: str) -> Any:
module_path = _LAZY_EXPORTS.get(name) module_path = _LAZY_EXPORTS.get(name)
if module_path is None: if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+21 -8
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection from collections.abc import Collection
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Callable, Coroutine from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
from loguru import logger from loguru import logger
@@ -31,9 +31,19 @@ class AutoCompact:
now: datetime | None = None) -> bool: now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts: if self._ttl <= 0 or not ts:
return False return False
if isinstance(ts, str): try:
ts = datetime.fromisoformat(ts) if isinstance(ts, str):
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60 ts = datetime.fromisoformat(ts)
current = now or datetime.now()
if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None:
idle_seconds = current.timestamp() - ts.timestamp()
else:
idle_seconds = (current - ts).total_seconds()
except (OSError, OverflowError, TypeError, ValueError):
# list_sessions() forwards raw persisted metadata; an unusable value
# must not escape the idle scan and stop the agent loop.
return False
return idle_seconds >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool: def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
@@ -65,7 +75,7 @@ class AutoCompact:
def check_expired( def check_expired(
self, self,
schedule_background: Callable[[Coroutine], None], schedule_background: Callable[[Coroutine[Any, Any, None]], None],
resolve_runtime: Callable[[Session], LLMRuntime], resolve_runtime: Callable[[Session], LLMRuntime],
active_session_keys: Collection[str] = (), active_session_keys: Collection[str] = (),
) -> None: ) -> None:
@@ -103,8 +113,8 @@ class AutoCompact:
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
if isinstance(meta, dict): if isinstance(meta, dict):
self._summaries[key] = ( self._summaries[key] = (
meta["text"], cast(str, meta["text"]),
datetime.fromisoformat(meta["last_active"]), datetime.fromisoformat(cast(str, meta["last_active"])),
) )
except Exception: except Exception:
logger.exception("Auto-compact: failed for {}", key) logger.exception("Auto-compact: failed for {}", key)
@@ -126,5 +136,8 @@ class AutoCompact:
# Cold path: summary persisted in session metadata (process restarted). # Cold path: summary persisted in session metadata (process restarted).
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
if isinstance(meta, dict): if isinstance(meta, dict):
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"])) return session, self._format_summary(
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
return session, None return session, None
+60 -20
View File
@@ -4,7 +4,7 @@ import base64
import mimetypes import mimetypes
import platform import platform
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence, cast
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
@@ -70,6 +70,7 @@ class ContextBuilder:
def build_system_prompt( def build_system_prompt(
self, self,
*, *,
active_skill_names: Sequence[str] | None = None,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
workspace: Path | None = None, workspace: Path | None = None,
@@ -91,13 +92,18 @@ class ContextBuilder:
if memory and not self._is_template_content(memory, "memory/MEMORY.md"): if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}") parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
always_skills = self.skills.get_always_skills() active_skills = self.skills.get_always_skills()
if always_skills: active_skills.extend(
always_content = self.skills.load_skills_for_context(always_skills) name
if always_content: for name in (active_skill_names or ())
parts.append(f"# Active Skills\n\n{always_content}") if name not in active_skills
)
if active_skills:
active_content = self.skills.load_skills_for_context(active_skills)
if active_content:
parts.append(f"# Active Skills\n\n{active_content}")
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills)) skills_summary = self.skills.build_skills_summary(exclude=set(active_skills))
if skills_summary: if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
@@ -148,7 +154,12 @@ class ContextBuilder:
def _to_blocks(value: Any) -> list[dict[str, Any]]: def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list): if isinstance(value, list):
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value] return [
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
]
if value is None: if value is None:
return [] return []
return [{"type": "text", "text": str(value)}] return [{"type": "text", "text": str(value)}]
@@ -157,7 +168,7 @@ class ContextBuilder:
def _load_bootstrap_files(self, workspace: Path | None = None) -> str: def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load project instructions plus the agent's global profile files.""" """Load project instructions plus the agent's global profile files."""
parts = [] parts: list[str] = []
project_root = workspace or self.workspace project_root = workspace or self.workspace
sources = [ sources = [
("AGENTS.md", project_root), ("AGENTS.md", project_root),
@@ -209,13 +220,16 @@ class ContextBuilder:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
root = workspace or self.workspace root = workspace or self.workspace
user_content = self.build_user_content(current_message, image_paths=media) active_skill_names = (
blocks = list(runtime_context_blocks or ()) if current_role == "user" else [] self.skills.get_explicitly_invoked_skills(current_message)
merged, runtime_context_meta = append_runtime_context(user_content, blocks) if current_role == "user"
messages = [ else []
)
messages: list[dict[str, Any]] = [
{ {
"role": "system", "role": "system",
"content": self.build_system_prompt( "content": self.build_system_prompt(
active_skill_names=active_skill_names,
channel=channel, channel=channel,
session_summary=session_summary, session_summary=session_summary,
workspace=root, workspace=root,
@@ -226,21 +240,47 @@ class ContextBuilder:
}, },
*history, *history,
] ]
current = self.build_current_message(
current_message,
media=media,
current_role=current_role,
runtime_context_blocks=runtime_context_blocks,
)
if messages[-1].get("role") == current_role: if messages[-1].get("role") == current_role:
last = dict(messages[-1]) last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged) last["content"] = self._merge_message_content(
if current_role == "user" and runtime_context_meta is not None: last.get("content"),
current.get("content"),
)
current_meta = current.get("_meta")
if current_role == "user" and isinstance(current_meta, dict):
internal_meta = dict(last.get("_meta") or {}) internal_meta = dict(last.get("_meta") or {})
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta internal_meta.update(cast(dict[str, Any], current_meta))
last["_meta"] = internal_meta last["_meta"] = internal_meta
messages[-1] = last messages[-1] = last
return messages return messages
current = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
messages.append(current) messages.append(current)
return messages return messages
def build_current_message(
self,
current_message: str,
*,
media: list[str] | None = None,
current_role: str = "user",
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
) -> dict[str, Any]:
"""Build only the fresh turn message without merging it into history."""
content = self.build_user_content(current_message, image_paths=media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(content, blocks)
current: dict[str, Any] = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {
RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta,
}
return current
def build_user_content( def build_user_content(
self, self,
text: str, text: str,
@@ -250,7 +290,7 @@ class ContextBuilder:
if not image_paths: if not image_paths:
return text return text
image_blocks = [] image_blocks: list[dict[str, Any]] = []
for path in image_paths: for path in image_paths:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
+22 -14
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from loguru import logger from loguru import logger
@@ -23,6 +23,7 @@ from nanobot.utils.helpers import (
from nanobot.utils.runtime import ensure_nonempty_tool_result from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024 SNIP_SAFETY_BUFFER = 1024
@@ -49,8 +50,9 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
""" """
if not isinstance(tool_call, dict): if not isinstance(tool_call, dict):
return False return False
fn = tool_call.get("function") tool_call_data = cast(dict[str, Any], tool_call)
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name") fn = tool_call_data.get("function")
name = cast(dict[str, Any], fn).get("name") if isinstance(fn, dict) else tool_call_data.get("name")
return isinstance(name, str) and bool(name) return isinstance(name, str) and bool(name)
@@ -58,7 +60,7 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
class ContextGovernanceConfig: class ContextGovernanceConfig:
provider: LLMProvider provider: LLMProvider
model: str model: str
tools: Any tools: ToolRegistry
workspace: Path | None workspace: Path | None
session_key: str | None session_key: str | None
max_tool_result_chars: int max_tool_result_chars: int
@@ -199,7 +201,7 @@ class ContextGovernor:
if updated is not None: if updated is not None:
updated.append(msg) updated.append(msg)
continue continue
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)] kept = [tc for tc in cast(list[Any], calls) if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls): if len(kept) == len(calls):
if updated is not None: if updated is not None:
updated.append(msg) updated.append(msg)
@@ -238,9 +240,11 @@ class ContextGovernor:
for idx, msg in enumerate(messages): for idx, msg in enumerate(messages):
role = msg.get("role") role = msg.get("role")
if role == "assistant": if role == "assistant":
for tc in msg.get("tool_calls") or []: for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict) and tc.get("id"): if isinstance(tc, dict):
declared.add(str(tc["id"])) tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
declared.add(str(tool_call["id"]))
if role == "tool": if role == "tool":
tid = msg.get("tool_call_id") tid = msg.get("tool_call_id")
tid_str = str(tid) if tid else "" tid_str = str(tid) if tid else ""
@@ -266,13 +270,17 @@ class ContextGovernor:
for idx, msg in enumerate(messages): for idx, msg in enumerate(messages):
role = msg.get("role") role = msg.get("role")
if role == "assistant": if role == "assistant":
for tc in msg.get("tool_calls") or []: for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict) and tc.get("id"): if isinstance(tc, dict):
name = "" name = ""
func = tc.get("function") tool_call = cast(dict[str, Any], tc)
if isinstance(func, dict): if tool_call.get("id"):
name = func.get("name", "") func = tool_call.get("function")
declared.append((idx, str(tc["id"]), name)) if isinstance(func, dict):
func_data = cast(dict[str, Any], func)
raw_name = func_data.get("name", "")
name = raw_name if isinstance(raw_name, str) else str(raw_name)
declared.append((idx, str(tool_call["id"]), name))
elif role == "tool": elif role == "tool":
tid = msg.get("tool_call_id") tid = msg.get("tool_call_id")
if tid: if tid:
+7 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from nanobot.agent.hook import ( from nanobot.agent.hook import (
AgentHook, AgentHook,
@@ -56,17 +56,21 @@ class FileEditActivityHook(AgentHook):
) -> None: ) -> None:
if self._on_progress is None or not isinstance(params, dict): if self._on_progress is None or not isinstance(params, dict):
return return
typed_params = cast(dict[str, Any], params)
trackers = prepare_file_edit_trackers( trackers = prepare_file_edit_trackers(
call_id=tool_call.id, call_id=tool_call.id,
tool_name=tool_call.name, tool_name=tool_call.name,
tool=tool, tool=tool,
workspace=self._workspace, workspace=self._workspace,
params=params, params=typed_params,
) )
if not trackers: if not trackers:
return return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers]) await self._emit([
build_file_edit_start_event(tracker, typed_params)
for tracker in trackers
])
async def after_execute_tool( async def after_execute_tool(
self, self,
+297 -90
View File
@@ -1,5 +1,7 @@
"""Agent loop: the core processing engine.""" """Agent loop: the core processing engine."""
# pyright: reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -7,13 +9,14 @@ import dataclasses
import inspect import inspect
import os import os
import time import time
from collections.abc import Mapping import weakref
from collections.abc import Coroutine, Iterable, Mapping
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, cast
from loguru import logger from loguru import logger
@@ -46,7 +49,7 @@ from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider, ProviderConversationState
from nanobot.providers.factory import ProviderSnapshot from nanobot.providers.factory import ProviderSnapshot
from nanobot.runtime_context import ( from nanobot.runtime_context import (
RUNTIME_CONTEXT_HISTORY_META, RUNTIME_CONTEXT_HISTORY_META,
@@ -94,12 +97,16 @@ if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection from nanobot.agent.tools.mcp import MCPConnection
from nanobot.config.schema import ( from nanobot.config.schema import (
ChannelsConfig, ChannelsConfig,
Config,
MCPServerConfig,
ProviderConfig, ProviderConfig,
ToolsConfig, ToolsConfig,
) )
from nanobot.cron.service import CronService from nanobot.cron.service import CronService
from nanobot.triggers.local_store import LocalTriggerStore
_T = TypeVar("_T") _T = TypeVar("_T")
_SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id"
class TurnKind(Enum): class TurnKind(Enum):
@@ -120,6 +127,7 @@ class TurnContext:
history: list[dict[str, Any]] = field(default_factory=list) history: list[dict[str, Any]] = field(default_factory=list)
initial_messages: list[dict[str, Any]] = field(default_factory=list) initial_messages: list[dict[str, Any]] = field(default_factory=list)
provider_state: ProviderConversationState | None = field(default=None, repr=False)
request_context: RequestContext | None = None request_context: RequestContext | None = None
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list) runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
attributes: dict[str, Any] = field(default_factory=dict) attributes: dict[str, Any] = field(default_factory=dict)
@@ -142,7 +150,7 @@ class TurnContext:
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
on_retry_wait: Callable[[str], Awaitable[None]] | None = None on_retry_wait: Callable[[str], Awaitable[None]] | None = None
pending_queue: asyncio.Queue | None = None pending_queue: asyncio.Queue[InboundMessage] | None = None
pending_summary: str | None = None pending_summary: str | None = None
ephemeral: bool = False ephemeral: bool = False
@@ -156,6 +164,18 @@ class TurnContext:
visible_run_started_at: float | None = None visible_run_started_at: float | None = None
turn_latency_ms: int | None = None turn_latency_ms: int | None = None
def require_runtime(self) -> LLMRuntime:
"""Return the runtime established by the BUILD stage."""
if self.runtime is None:
raise RuntimeError("turn runtime is not initialized; BUILD must run before this stage")
return self.runtime
def require_session(self) -> Session:
"""Return the session established by the RESTORE stage."""
if self.session is None:
raise RuntimeError("turn session is not initialized; RESTORE must run before this stage")
return self.session
class AgentLoop: class AgentLoop:
""" """
@@ -225,6 +245,8 @@ class AgentLoop:
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint" _RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
_PENDING_USER_TURN_KEY = "pending_user_turn" _PENDING_USER_TURN_KEY = "pending_user_turn"
_PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
_PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
def __init__( def __init__(
self, self,
@@ -243,7 +265,7 @@ class AgentLoop:
cron_service: CronService | None = None, cron_service: CronService | None = None,
restrict_to_workspace: bool = False, restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None, session_manager: SessionManager | None = None,
mcp_servers: dict | None = None, mcp_servers: dict[str, MCPServerConfig] | None = None,
channels_config: ChannelsConfig | None = None, channels_config: ChannelsConfig | None = None,
timezone: str | None = None, timezone: str | None = None,
session_ttl_minutes: int = 0, session_ttl_minutes: int = 0,
@@ -266,7 +288,7 @@ class AgentLoop:
turn_delivery_factory: TurnDeliveryFactory | None = None, turn_delivery_factory: TurnDeliveryFactory | 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", restart_mode: str = "auto",
local_trigger_store: Any | None = None, local_trigger_store: LocalTriggerStore | None = None,
idle_compact_check_interval_seconds: int = 0, idle_compact_check_interval_seconds: int = 0,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@@ -377,11 +399,13 @@ class AgentLoop:
self._runtime_context_providers: list[RuntimeContextProvider] = [] self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {} self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._background_tasks: set[asyncio.Task[Any]] = set() self._background_tasks: set[asyncio.Task[Any]] = set()
self._session_locks: dict[str, asyncio.Lock] = {} self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
weakref.WeakValueDictionary()
)
# Per-session pending queues for mid-turn message injection. # Per-session pending queues for mid-turn message injection.
# When a session has an active task, new messages for that session # When a session has an active task, new messages for that session
# are routed here instead of creating a new task. # are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {} self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {} self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator( self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound, publish_inbound=self.bus.publish_inbound,
@@ -430,7 +454,7 @@ class AgentLoop:
@classmethod @classmethod
def from_config( def from_config(
cls, cls,
config: Any, config: Config,
bus: MessageBus | None = None, bus: MessageBus | None = None,
**extra: Any, **extra: Any,
) -> AgentLoop: ) -> AgentLoop:
@@ -657,12 +681,17 @@ class AgentLoop:
""" """
if not turn_continuation.should_persist_user_message(msg.metadata): if not turn_continuation.should_persist_user_message(msg.metadata):
return False return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p] media_paths = [
has_text = isinstance(msg.content, str) and msg.content.strip() path
for path in (msg.media or [])
if isinstance(cast(object, path), str) and path
]
content_value = cast(object, msg.content)
has_text = isinstance(content_value, str) and content_value.strip()
if has_text or media_paths or runtime_context_blocks: if has_text or media_paths or runtime_context_blocks:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata) extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs) extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else "" text = content_value if isinstance(content_value, str) else ""
text_override, automation_extra = automation_history_overrides(msg.metadata) text_override, automation_extra = automation_history_overrides(msg.metadata)
if text_override is not None: if text_override is not None:
text = text_override text = text_override
@@ -810,7 +839,7 @@ class AgentLoop:
async def _run_agent_loop( async def _run_agent_loop(
self, self,
initial_messages: list[dict], initial_messages: list[dict[str, Any]],
on_progress: Callable[..., Awaitable[None]] | None = None, on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None,
@@ -824,7 +853,7 @@ class AgentLoop:
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
session_key: str | None = None, session_key: str | None = None,
original_user_text: str | None = None, original_user_text: str | None = None,
pending_queue: asyncio.Queue | None = None, pending_queue: asyncio.Queue[InboundMessage] | None = None,
ephemeral: bool = False, ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False, run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None, hooks: list[AgentHook] | None = None,
@@ -832,7 +861,8 @@ class AgentLoop:
turn_scopes: list[AbstractContextManager[Any]] | None = None, turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None, tools: ToolRegistry | None = None,
request_context: RequestContext | None = None, request_context: RequestContext | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]: provider_state: ProviderConversationState | None = None,
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
"""Run the agent iteration loop. """Run the agent iteration loop.
*on_stream*: called with each content delta during streaming. *on_stream*: called with each content delta during streaming.
@@ -847,7 +877,18 @@ class AgentLoop:
async def _checkpoint(payload: dict[str, Any]) -> None: async def _checkpoint(payload: dict[str, Any]) -> None:
if session is None: if session is None:
return return
self._set_runtime_checkpoint(session, payload) public_payload = dict(payload)
private_state = public_payload.pop("provider_state", None)
public_payload.pop(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY, None)
if "provider_state" in payload and (
private_state is None
or isinstance(private_state, ProviderConversationState)
):
session.provider_state = private_state
public_payload[self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY] = (
self._PROVIDER_STATE_CHECKPOINT_VERSION
)
self._set_runtime_checkpoint(session, public_payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]: async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
"""Drain follow-up messages from the pending queue. """Drain follow-up messages from the pending queue.
@@ -875,7 +916,12 @@ class AgentLoop:
image_paths=image_paths, image_paths=image_paths,
) )
row: dict[str, Any] = {"role": "user", "content": user_content} row: dict[str, Any] = {"role": "user", "content": user_content}
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {} metadata_value = cast(object, pending_msg.metadata)
metadata = (
pending_msg.metadata
if isinstance(metadata_value, dict)
else {}
)
if pending_msg.channel != "system": if pending_msg.channel != "system":
scope = self.workspace_scopes.for_turn( scope = self.workspace_scopes.for_turn(
channel=pending_msg.channel, channel=pending_msg.channel,
@@ -899,19 +945,24 @@ class AgentLoop:
pending_request, pending_request,
effective_tools, effective_tools,
) )
row["content"], marker = append_runtime_context(user_content, blocks) row["content"], runtime_marker = append_runtime_context(
if marker is not None: user_content,
row["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: marker} blocks,
)
if runtime_marker is not None:
row["_meta"] = {
RUNTIME_CONTEXT_MESSAGE_META: runtime_marker,
}
if ( if (
pending_msg.sender_id == "subagent" pending_msg.sender_id == "subagent"
and metadata.get("injected_event") == "subagent_result" and metadata.get("injected_event") == "subagent_result"
): ):
marker: dict[str, Any] = {"kind": "subagent_result"} subagent_marker: dict[str, Any] = {"kind": "subagent_result"}
task_id = metadata.get("subagent_task_id") task_id = metadata.get("subagent_task_id")
if isinstance(task_id, str) and task_id: if isinstance(task_id, str) and task_id:
marker["subagent_task_id"] = task_id subagent_marker["subagent_task_id"] = task_id
row["subagent_task_id"] = task_id row["subagent_task_id"] = task_id
row[HIDDEN_HISTORY_META] = marker row[HIDDEN_HISTORY_META] = subagent_marker
row["injected_event"] = "subagent_result" row["injected_event"] = "subagent_result"
return row return row
@@ -1035,6 +1086,7 @@ class AgentLoop:
session_metadata=session_metadata, session_metadata=session_metadata,
message_metadata=metadata, message_metadata=metadata,
), ),
provider_state=provider_state,
)) ))
finally: finally:
turn_scope_stack.close() turn_scope_stack.close()
@@ -1042,6 +1094,8 @@ class AgentLoop:
reset_request_context(request_token) reset_request_context(request_token)
reset_file_states(file_state_token) reset_file_states(file_state_token)
self._last_usage = result.usage self._last_usage = result.usage
if session is not None and not ephemeral:
session.provider_state = result.provider_state
if result.stop_reason == "max_iterations": if result.stop_reason == "max_iterations":
logger.warning("Max iterations ({}) reached", self.max_iterations) logger.warning("Max iterations ({}) reached", self.max_iterations)
should_stream = turn_continuation.should_stream_budget_response( should_stream = turn_continuation.should_stream_budget_response(
@@ -1071,7 +1125,7 @@ class AgentLoop:
return return
self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s
self.auto_compact.check_expired( self.auto_compact.check_expired(
self._schedule_background, self.schedule_background,
self.runtime_for_session, self.runtime_for_session,
active_session_keys=self._pending_queues.keys(), active_session_keys=self._pending_queues.keys(),
) )
@@ -1174,11 +1228,11 @@ class AgentLoop:
session_key = self._effective_session_key(msg) session_key = self._effective_session_key(msg)
if session_key != msg.session_key: if session_key != msg.session_key:
msg = dataclasses.replace(msg, session_key_override=session_key) msg = dataclasses.replace(msg, session_key_override=session_key)
lock = self._session_locks.setdefault(session_key, asyncio.Lock()) lock = self._get_session_lock(session_key)
gate = self._concurrency_gate or nullcontext() gate = self._concurrency_gate or nullcontext()
delivery = self.turn_delivery_factory.unrouted(msg, session_key) delivery = self.turn_delivery_factory.unrouted(msg, session_key)
pending: asyncio.Queue | None = None pending: asyncio.Queue[InboundMessage] | None = None
try: try:
async with lock, gate: async with lock, gate:
# Only the task that owns the session lock may publish the # Only the task that owns the session lock may publish the
@@ -1304,7 +1358,7 @@ class AgentLoop:
if errors: if errors:
raise BaseExceptionGroup("failed to close agent resources", errors) raise BaseExceptionGroup("failed to close agent resources", errors)
def _schedule_background(self, coro) -> None: def schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown).""" """Schedule a coroutine as a tracked background task (drained on shutdown)."""
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
self._background_tasks.add(task) self._background_tasks.add(task)
@@ -1322,7 +1376,7 @@ class AgentLoop:
on_progress: Callable[..., Awaitable[None]] | None = None, on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None, on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None, on_stream_end: Callable[..., Awaitable[None]] | None = None,
pending_queue: asyncio.Queue | None = None, pending_queue: asyncio.Queue[InboundMessage] | None = None,
ephemeral: bool = False, ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False, run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None, hooks: list[AgentHook] | None = None,
@@ -1518,27 +1572,33 @@ class AgentLoop:
# ensure it exists in case this handler is invoked independently. # ensure it exists in case this handler is invoked independently.
if ctx.session is None: if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key) ctx.session = self.sessions.get_or_create(ctx.session_key)
session = ctx.session
self._remember_unified_session_route( self._remember_unified_session_route(
ctx.session, session,
msg, msg,
is_user_turn=ctx.original_user_text is not None, is_user_turn=ctx.original_user_text is not None,
) )
await ctx.delivery.started() await ctx.delivery.started()
if ctx.kind is TurnKind.USER: if ctx.kind is TurnKind.USER:
self.workspace_scopes.persist_message_scope(ctx.session, msg) self.workspace_scopes.persist_message_scope(session, msg)
if self._restore_runtime_checkpoint(ctx.session): if self._restore_runtime_checkpoint(session):
self.sessions.save(ctx.session) self.sessions.save(session)
if self._restore_pending_user_turn(ctx.session): if self._restore_pending_user_turn(session):
self.sessions.save(ctx.session) self.sessions.save(session)
async def _compact_session(self, ctx: TurnContext) -> None: async def _compact_session(self, ctx: TurnContext) -> None:
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key) session = ctx.require_session()
ctx.session, pending = self.auto_compact.prepare_session(
session,
ctx.session_key,
)
ctx.pending_summary = pending ctx.pending_summary = pending
async def _dispatch_command(self, ctx: TurnContext) -> bool: async def _dispatch_command(self, ctx: TurnContext) -> bool:
if ctx.kind is TurnKind.SYSTEM: if ctx.kind is TurnKind.SYSTEM:
return False return False
session = ctx.require_session()
raw = ctx.msg.content.strip() raw = ctx.msg.content.strip()
_, automation_metadata = automation_history_overrides(ctx.msg.metadata) _, automation_metadata = automation_history_overrides(ctx.msg.metadata)
is_user_turn = ( is_user_turn = (
@@ -1549,7 +1609,7 @@ class AgentLoop:
) )
cmd_ctx = CommandContext( cmd_ctx = CommandContext(
msg=ctx.msg, msg=ctx.msg,
session=ctx.session, session=session,
key=ctx.session_key, key=ctx.session_key,
raw=raw, raw=raw,
loop=self, loop=self,
@@ -1567,13 +1627,13 @@ class AgentLoop:
# intentionally clears the session. # intentionally clears the session.
if cmd_ctx.raw.lower() != "/new": if cmd_ctx.raw.lower() != "/new":
ctx.input_persisted_early = self._persist_user_message_early( ctx.input_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session, _command=True ctx.msg, session, _command=True
) )
ctx.session.add_message( session.add_message(
"assistant", result.content, _command=True "assistant", result.content, _command=True
) )
self._clear_pending_user_turn(ctx.session) self._clear_pending_user_turn(session)
self.sessions.save(ctx.session) self.sessions.save(session)
if not ctx.ephemeral: if not ctx.ephemeral:
await self.runtime_event_publisher.session_turn_persisted( await self.runtime_event_publisher.session_turn_persisted(
ctx.msg, ctx.msg,
@@ -1585,9 +1645,10 @@ class AgentLoop:
return False return False
async def _build_turn(self, ctx: TurnContext) -> None: async def _build_turn(self, ctx: TurnContext) -> None:
session = ctx.require_session()
runtime = ctx.runtime runtime = ctx.runtime
if runtime is None: if runtime is None:
runtime = self.runtime_for_session(ctx.session) runtime = self.runtime_for_session(session)
ctx.runtime = runtime ctx.runtime = runtime
if ctx.session_key.startswith("dream:"): if ctx.session_key.startswith("dream:"):
logger.info( logger.info(
@@ -1602,7 +1663,7 @@ class AgentLoop:
) )
if not ctx.ephemeral: if not ctx.ephemeral:
await self.consolidator.maybe_consolidate_by_tokens( await self.consolidator.maybe_consolidate_by_tokens(
ctx.session, session,
runtime=runtime, runtime=runtime,
replay_max_messages=replay_max_messages, replay_max_messages=replay_max_messages,
) )
@@ -1617,29 +1678,91 @@ class AgentLoop:
"max_tokens": self._replay_token_budget(runtime), "max_tokens": self._replay_token_budget(runtime),
"extend_to_user": is_subagent, "extend_to_user": is_subagent,
} }
ctx.history = ctx.session.get_history(**_hist_kwargs) ctx.history = session.get_history(**_hist_kwargs)
stored_state = session.provider_state
subagent_followup_persisted = False
if is_subagent: if is_subagent:
# Keep the durable internal delivery as an assistant record, but # Keep the durable internal delivery as an assistant record, but
# present this completion to the model as fresh follow-up input. # present this completion to the model as fresh follow-up input.
# Providers without assistant-prefill support drop trailing # Providers without assistant-prefill support drop trailing
# assistant messages, so using the persisted record as the current # assistant messages, so using the persisted record as the current
# prompt would hide an independently dispatched subagent result. # prompt would hide an independently dispatched subagent result.
if self._persist_subagent_followup(ctx.session, ctx.msg): subagent_followup_persisted = self._persist_subagent_followup(
session,
ctx.msg,
)
if subagent_followup_persisted:
logger.debug("Subagent result persisted for session {}", ctx.session_key) logger.debug("Subagent result persisted for session {}", ctx.session_key)
self.sessions.save(ctx.session) # Establish a durable, replay-safe baseline before any fallible
# provider compatibility or prompt assembly work. A compatible
# staged state replaces this in a second atomic save below.
session.provider_state = None
self.sessions.save(session)
ctx.input_persisted_early = True ctx.input_persisted_early = True
ctx.delivery.record_runtime(ctx.runtime) ctx.delivery.record_runtime(runtime)
ctx.request_context = self._request_context_for_turn(ctx) ctx.request_context = self._request_context_for_turn(ctx)
if ctx.kind is TurnKind.USER: if ctx.kind is TurnKind.USER:
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx) ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
ctx.initial_messages = self._build_initial_messages(ctx) staged_provider_state = False
if stored_state is not None and runtime.provider.can_resume_conversation_state(
stored_state,
runtime.model,
):
current_provider_message = self.context.build_current_message(
ctx.msg.content,
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
runtime_context_blocks=ctx.runtime_context_blocks,
)
task_id = ctx.msg.metadata.get("subagent_task_id") if is_subagent else None
already_staged = False
if isinstance(task_id, str) and task_id:
internal_meta = current_provider_message.get("_meta")
current_provider_message["_meta"] = {
**(
cast(dict[str, Any], internal_meta)
if isinstance(internal_meta, dict)
else {}
),
_SUBAGENT_PROVIDER_TASK_META: task_id,
}
already_staged = any(
isinstance(message.get("_meta"), dict)
and cast(dict[str, Any], message["_meta"]).get(
_SUBAGENT_PROVIDER_TASK_META
)
== task_id
for message in stored_state.pending_messages
)
ctx.provider_state = (
stored_state
if already_staged
else stored_state.with_pending_messages([
*stored_state.pending_messages,
current_provider_message,
])
)
if (
not ctx.ephemeral
and (ctx.kind is TurnKind.USER or subagent_followup_persisted)
):
session.provider_state = ctx.provider_state
staged_provider_state = True
elif stored_state is not None:
session.provider_state = None
if ctx.kind is TurnKind.USER: if ctx.kind is TurnKind.USER:
ctx.input_persisted_early = self._persist_user_message_early( ctx.input_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.msg,
ctx.session, session,
runtime_context_blocks=ctx.runtime_context_blocks, runtime_context_blocks=ctx.runtime_context_blocks,
) )
if staged_provider_state and not ctx.input_persisted_early:
session.provider_state = stored_state
elif subagent_followup_persisted and staged_provider_state:
# Upgrade the replay-safe baseline to the resumable state before
# prompt assembly and the first model checkpoint.
self.sessions.save(session)
ctx.initial_messages = self._build_initial_messages(ctx)
if ctx.on_progress is None: if ctx.on_progress is None:
ctx.on_progress = ctx.delivery.progress_callback() ctx.on_progress = ctx.delivery.progress_callback()
@@ -1647,12 +1770,13 @@ class AgentLoop:
ctx.on_retry_wait = ctx.delivery.retry_wait_callback() ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
async def _run_turn(self, ctx: TurnContext) -> None: async def _run_turn(self, ctx: TurnContext) -> None:
runtime = ctx.require_runtime()
if ctx.visible_run_started_at is None: if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time() ctx.visible_run_started_at = time.time()
await ctx.delivery.running(started_at=ctx.visible_run_started_at) await ctx.delivery.running(started_at=ctx.visible_run_started_at)
result = await self._run_agent_loop( result = await self._run_agent_loop(
ctx.initial_messages, ctx.initial_messages,
runtime=ctx.runtime, runtime=runtime,
on_progress=ctx.on_progress, on_progress=ctx.on_progress,
on_stream=ctx.on_stream, on_stream=ctx.on_stream,
on_stream_end=ctx.on_stream_end, on_stream_end=ctx.on_stream_end,
@@ -1672,6 +1796,7 @@ class AgentLoop:
turn_scopes=ctx.turn_scopes, turn_scopes=ctx.turn_scopes,
tools=ctx.tools, tools=ctx.tools,
request_context=ctx.request_context, request_context=ctx.request_context,
provider_state=ctx.provider_state,
) )
final_content, _, all_msgs, stop_reason, had_injections = result final_content, _, all_msgs, stop_reason, had_injections = result
ctx.final_content = final_content ctx.final_content = final_content
@@ -1682,6 +1807,8 @@ class AgentLoop:
await turn_continuation.maybe_continue_turn(ctx) await turn_continuation.maybe_continue_turn(ctx)
async def _persist_turn(self, ctx: TurnContext) -> None: async def _persist_turn(self, ctx: TurnContext) -> None:
runtime = ctx.require_runtime()
session = ctx.require_session()
turn_continuation.prepare_save_boundary(ctx) turn_continuation.prepare_save_boundary(ctx)
if ( if (
@@ -1702,26 +1829,26 @@ class AgentLoop:
) )
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000)) ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
self._save_turn( self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip, session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms, turn_latency_ms=ctx.turn_latency_ms,
) )
ctx.delivery.record_latency(ctx.turn_latency_ms) ctx.delivery.record_latency(ctx.turn_latency_ms)
if not ctx.ephemeral: if not ctx.ephemeral:
ctx.session.enforce_file_cap( session.enforce_file_cap(
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key) on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
) )
self._schedule_background( self.schedule_background(
self.consolidator.maybe_consolidate_by_tokens( self.consolidator.maybe_consolidate_by_tokens(
ctx.session, session,
runtime=ctx.runtime, runtime=runtime,
replay_max_messages=replay_max_messages_for_context( replay_max_messages=replay_max_messages_for_context(
ctx.runtime.context_window_tokens runtime.context_window_tokens
), ),
) )
) )
self._clear_pending_user_turn(ctx.session) self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(ctx.session) self._clear_runtime_checkpoint(session)
self.sessions.save(ctx.session) self.sessions.save(session)
if not ctx.ephemeral: if not ctx.ephemeral:
await self.runtime_event_publisher.session_turn_persisted( await self.runtime_event_publisher.session_turn_persisted(
ctx.msg, ctx.msg,
@@ -1744,7 +1871,7 @@ class AgentLoop:
return return
ctx.outbound = self._assemble_outbound( ctx.outbound = self._assemble_outbound(
ctx.msg, ctx.msg,
ctx.final_content, cast(str, ctx.final_content),
ctx.stop_reason, ctx.stop_reason,
ctx.had_injections, ctx.had_injections,
ctx.streamed_content, ctx.streamed_content,
@@ -1755,39 +1882,47 @@ class AgentLoop:
def _sanitize_persisted_blocks( def _sanitize_persisted_blocks(
self, self,
content: list[dict[str, Any]], content: list[object],
*, *,
should_truncate_text: bool = False, should_truncate_text: bool = False,
) -> list[dict[str, Any]]: ) -> list[object]:
"""Strip volatile multimodal payloads before writing session history.""" """Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = [] filtered: list[object] = []
for block in content: for block in content:
if not isinstance(block, dict): if not isinstance(block, dict):
filtered.append(block) filtered.append(block)
continue continue
if block.get("type") == "image_url" and block.get("image_url", {}).get( block_data = cast(dict[str, Any], block)
"url", "" image_url = cast(dict[str, Any], block_data.get("image_url", {}))
if block_data.get("type") == "image_url" and str(
image_url.get("url", "")
).startswith("data:image/"): ).startswith("data:image/"):
path = (block.get("_meta") or {}).get("path", "") internal_meta = cast(dict[str, Any], block_data.get("_meta") or {})
filtered.append({"type": "text", "text": image_placeholder_text(path)}) path = cast(str, internal_meta.get("path", ""))
filtered.append(
{"type": "text", "text": image_placeholder_text(path)}
)
continue continue
if block.get("type") == "text" and isinstance(block.get("text"), str): if block_data.get("type") == "text" and isinstance(
text = block["text"] block_data.get("text"),
str,
):
text = cast(str, block_data["text"])
if should_truncate_text and len(text) > self.max_tool_result_chars: if should_truncate_text and len(text) > self.max_tool_result_chars:
text = truncate_text_fn(text, self.max_tool_result_chars) text = truncate_text_fn(text, self.max_tool_result_chars)
filtered.append({**block, "text": text}) filtered.append({**block_data, "text": text})
continue continue
filtered.append(block) filtered.append(block_data)
return filtered return filtered
def _save_turn( def _save_turn(
self, self,
session: Session, session: Session,
messages: list[dict], messages: list[dict[str, Any]],
skip: int, skip: int,
*, *,
turn_latency_ms: int | None = None, turn_latency_ms: int | None = None,
@@ -1799,8 +1934,10 @@ class AgentLoop:
str(tc["id"]) str(tc["id"])
for m in session.messages for m in session.messages
if m.get("role") == "assistant" if m.get("role") == "assistant"
for tc in m.get("tool_calls") or [] for tc_value in cast(Iterable[object], m.get("tool_calls") or [])
if isinstance(tc, dict) and tc.get("id") if isinstance(tc_value, dict)
for tc in (cast(dict[str, Any], tc_value),)
if tc.get("id")
} }
fulfilled_tool_call_ids = { fulfilled_tool_call_ids = {
str(m["tool_call_id"]) str(m["tool_call_id"])
@@ -1810,9 +1947,11 @@ class AgentLoop:
last_assistant_idx: int | None = None last_assistant_idx: int | None = None
for m in messages[skip:]: for m in messages[skip:]:
entry = dict(m) entry = dict(m)
internal_meta = entry.pop("_meta", None) internal_meta = cast(object, entry.pop("_meta", None))
runtime_context_meta = ( runtime_context_meta = (
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META) cast(dict[str, Any], internal_meta).get(
RUNTIME_CONTEXT_MESSAGE_META
)
if isinstance(internal_meta, dict) if isinstance(internal_meta, dict)
else None else None
) )
@@ -1838,7 +1977,10 @@ class AgentLoop:
if isinstance(content, str) and len(content) > self.max_tool_result_chars: if isinstance(content, str) and len(content) > self.max_tool_result_chars:
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars) entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
elif isinstance(content, list): elif isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True) filtered = self._sanitize_persisted_blocks(
cast(list[object], content),
should_truncate_text=True,
)
if not filtered: if not filtered:
# Preserve the tool_call/result pair after block filtering. # Preserve the tool_call/result pair after block filtering.
filtered = [ filtered = [
@@ -1847,7 +1989,9 @@ class AgentLoop:
entry["content"] = filtered entry["content"] = filtered
elif role == "user": elif role == "user":
if isinstance(content, list): if isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content) filtered = self._sanitize_persisted_blocks(
cast(list[object], content),
)
if not filtered: if not filtered:
continue continue
entry["content"] = filtered entry["content"] = filtered
@@ -1859,8 +2003,13 @@ class AgentLoop:
last_assistant_idx = len(session.messages) - 1 last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update( declared_tool_call_ids.update(
str(tc["id"]) str(tc["id"])
for tc in entry.get("tool_calls") or [] for tc_value in cast(
if isinstance(tc, dict) and tc.get("id") Iterable[object],
entry.get("tool_calls") or [],
)
if isinstance(tc_value, dict)
for tc in (cast(dict[str, Any], tc_value),)
if tc.get("id")
) )
if turn_latency_ms is not None and last_assistant_idx is not None: if turn_latency_ms is not None and last_assistant_idx is not None:
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms) session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
@@ -1875,7 +2024,12 @@ class AgentLoop:
""" """
if not msg.content: if not msg.content:
return False return False
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None metadata_value = cast(object, msg.metadata)
task_id = (
msg.metadata.get("subagent_task_id")
if isinstance(metadata_value, dict)
else None
)
if task_id and any( if task_id and any(
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
for m in session.messages for m in session.messages
@@ -1921,29 +2075,44 @@ class AgentLoop:
"""Materialize an unfinished turn into session history before a new request.""" """Materialize an unfinished turn into session history before a new request."""
from datetime import datetime from datetime import datetime
checkpoint = session.metadata.get(self._RUNTIME_CHECKPOINT_KEY) checkpoint = cast(
object,
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
)
if not isinstance(checkpoint, dict): if not isinstance(checkpoint, dict):
return False return False
checkpoint_data = cast(dict[str, Any], checkpoint)
assistant_message = checkpoint.get("assistant_message") assistant_message = cast(object, checkpoint_data.get("assistant_message"))
completed_tool_results = checkpoint.get("completed_tool_results") or [] completed_tool_results = cast(
pending_tool_calls = checkpoint.get("pending_tool_calls") or [] Iterable[object],
checkpoint_data.get("completed_tool_results") or [],
)
pending_tool_calls = cast(
Iterable[object],
checkpoint_data.get("pending_tool_calls") or [],
)
restored_messages: list[dict[str, Any]] = [] restored_messages: list[dict[str, Any]] = []
if isinstance(assistant_message, dict): if isinstance(assistant_message, dict):
restored = dict(assistant_message) restored = dict(cast(dict[str, Any], assistant_message))
restored.setdefault("timestamp", datetime.now().isoformat()) restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored) restored_messages.append(restored)
for message in completed_tool_results: for message in completed_tool_results:
if isinstance(message, dict): if isinstance(message, dict):
restored = dict(message) restored = dict(cast(dict[str, Any], message))
restored.setdefault("timestamp", datetime.now().isoformat()) restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored) restored_messages.append(restored)
for tool_call in pending_tool_calls: for tool_call in pending_tool_calls:
if not isinstance(tool_call, dict): if not isinstance(tool_call, dict):
continue continue
tool_id = tool_call.get("id") tool_call_data = cast(dict[str, Any], tool_call)
name = ((tool_call.get("function") or {}).get("name")) or "tool" tool_id = tool_call_data.get("id")
function_data = cast(
dict[str, Any],
tool_call_data.get("function") or {},
)
name = function_data.get("name") or "tool"
restored_messages.append( restored_messages.append(
{ {
"role": "tool", "role": "tool",
@@ -1965,7 +2134,36 @@ class AgentLoop:
): ):
overlap = size overlap = size
break break
session.messages.extend(restored_messages[overlap:]) appended_messages = restored_messages[overlap:]
session.messages.extend(appended_messages)
assistant_message_data = (
cast(dict[str, Any], assistant_message)
if isinstance(assistant_message, dict)
else None
)
provider_state_is_synchronized = (
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
== self._PROVIDER_STATE_CHECKPOINT_VERSION
)
phase = checkpoint_data.get("phase")
exact_final_response = (
phase == "final_response"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("completed_tool_results"))
and not bool(checkpoint_data.get("pending_tool_calls"))
)
exact_completed_tools = (
phase == "tools_completed"
and assistant_message_data is not None
and assistant_message_data.get("role") == "assistant"
and not bool(checkpoint_data.get("pending_tool_calls"))
)
if not (
provider_state_is_synchronized
and (exact_final_response or exact_completed_tools)
):
session.provider_state = None
self._clear_pending_user_turn(session) self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session) self._clear_runtime_checkpoint(session)
@@ -1986,6 +2184,7 @@ class AgentLoop:
"timestamp": datetime.now().isoformat(), "timestamp": datetime.now().isoformat(),
} }
) )
session.provider_state = None
session.updated_at = datetime.now() session.updated_at = datetime.now()
self._clear_pending_user_turn(session) self._clear_pending_user_turn(session)
@@ -2024,7 +2223,7 @@ class AgentLoop:
content=content, media=media or [], metadata=metadata, content=content, media=media or [], metadata=metadata,
) )
# Share the dispatch lock so direct calls serialize with bus turns. # Share the dispatch lock so direct calls serialize with bus turns.
lock = self._session_locks.setdefault(session_key, asyncio.Lock()) lock = self._get_session_lock(session_key)
try: try:
async with lock: async with lock:
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
@@ -2055,3 +2254,11 @@ class AgentLoop:
finally: finally:
await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle") await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle")
self.runtime_event_publisher.clear_turn(session_key) self.runtime_event_publisher.clear_turn(session_key)
def _get_session_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared lock while allowing idle session entries to expire."""
lock = self._session_locks.get(session_key)
if lock is None:
lock = asyncio.Lock()
self._session_locks[session_key] = lock
return lock
+74 -62
View File
@@ -1,5 +1,10 @@
"""Memory system: pure file I/O store and lightweight Consolidator.""" """Memory system: pure file I/O store and lightweight Consolidator."""
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
# runtime; static analyzers cannot observe that it clears ``parameters`` from
# ``__abstractmethods__`` before these classes are instantiated.
# pyright: reportAbstractUsage=false, reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -11,7 +16,7 @@ import weakref
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from loguru import logger from loguru import logger
@@ -19,6 +24,7 @@ from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir, ensure_dir,
estimate_message_tokens, estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
@@ -37,6 +43,7 @@ from nanobot.utils.workspace_prompts import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -57,7 +64,7 @@ class DreamRunProgress:
**_kwargs: Any, **_kwargs: Any,
) -> None: ) -> None:
if any( if any(
isinstance(event, dict) and event.get("phase") == "error" isinstance(cast(object, event), dict) and event.get("phase") == "error"
for event in tool_events or () for event in tool_events or ()
): ):
self.had_tool_errors = True self.had_tool_errors = True
@@ -473,11 +480,11 @@ class MemoryStore:
line = line.strip() line = line.strip()
if line: if line:
try: try:
parsed = json.loads(line) parsed: object = json.loads(line)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if isinstance(parsed, dict): if isinstance(parsed, dict):
entries.append(parsed) entries.append(cast(dict[str, Any], parsed))
return entries return entries
@@ -495,8 +502,8 @@ class MemoryStore:
lines = [line for line in data.split("\n") if line.strip()] lines = [line for line in data.split("\n") if line.strip()]
if not lines: if not lines:
return None return None
parsed = json.loads(lines[-1]) parsed: object = json.loads(lines[-1])
return parsed if isinstance(parsed, dict) else None return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return None return None
@@ -611,7 +618,7 @@ class MemoryStore:
("USER.md", self.user_file), ("USER.md", self.user_file),
("memory/MEMORY.md", self.memory_file), ("memory/MEMORY.md", self.memory_file),
] ]
blocks = [] blocks: list[str] = []
for label, path in files: for label, path in files:
try: try:
content = path.read_text(encoding="utf-8") if path.exists() else "" content = path.read_text(encoding="utf-8") if path.exists() else ""
@@ -632,7 +639,7 @@ class MemoryStore:
return "" return ""
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS)) return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
def build_dream_tools(self): def build_dream_tools(self) -> ToolRegistry:
"""Build the restricted tool registry used by Dream runs.""" """Build the restricted tool registry used by Dream runs."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.apply_patch import ApplyPatchTool from nanobot.agent.tools.apply_patch import ApplyPatchTool
@@ -683,29 +690,39 @@ class MemoryStore:
) -> bool: ) -> bool:
"""Return True only when a Dream turn completed without tool failures.""" """Return True only when a Dream turn completed without tool failures."""
metadata = getattr(resp, "metadata", None) metadata = getattr(resp, "metadata", None)
return ( if had_tool_errors or not isinstance(metadata, dict):
not had_tool_errors return False
and isinstance(metadata, dict) return cast(dict[str, Any], metadata).get("_stop_reason") == "completed"
and metadata.get("_stop_reason") == "completed"
)
# -- message formatting utility ------------------------------------------ # -- message formatting utility ------------------------------------------
@staticmethod @staticmethod
def _format_messages(messages: list[dict]) -> str: def _format_messages(messages: list[dict[str, Any]]) -> str:
lines = [] lines: list[str] = []
for message in messages: for message in messages:
if not message.get("content"): content = content_with_media_breadcrumbs(
message.get("role"),
message.get("content", ""),
message.get("media"),
)
if not content:
continue continue
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else "" tools_used = message.get("tools_used")
tools = (
f" [tools: {', '.join(cast(list[str], tools_used))}]"
if tools_used
else ""
)
timestamp = cast(str, message.get("timestamp", "?"))
role = cast(str, message["role"])
lines.append( lines.append(
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}" f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
) )
return "\n".join(lines) return "\n".join(lines)
def raw_archive( def raw_archive(
self, self,
messages: list[dict], messages: list[dict[str, Any]],
*, *,
max_chars: int | None = None, max_chars: int | None = None,
session_key: str | None = None, session_key: str | None = None,
@@ -759,9 +776,9 @@ class MemoryStore:
Only current base64url-encoded Dream session keys are considered. Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched. Non-dream session files are never touched.
""" """
dream_files = [] dream_files: list[Path] = []
for path in sessions_dir.glob("*.jsonl"): for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager._decode_storage_key(path.stem) decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"): if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append(path) dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime) dream_files.sort(key=lambda p: p.stat().st_mtime)
@@ -790,7 +807,7 @@ _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class Consolidator: class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl.""" """Summarize compacted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
@@ -914,6 +931,7 @@ class Consolidator:
session_key=session.key, session_key=session.key,
) )
session.last_consolidated = end_idx session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
return summary return summary
@@ -936,7 +954,13 @@ class Consolidator:
channel = session.key.split(":", 1)[0] if ":" in session.key else None channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it. # Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None) summary = (
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
)
probe_messages = self._build_messages( probe_messages = self._build_messages(
history=history, history=history,
current_message="[token-probe]", current_message="[token-probe]",
@@ -969,20 +993,15 @@ class Consolidator:
async def archive( async def archive(
self, self,
messages: list[dict], messages: list[dict[str, Any]],
*, *,
runtime: LLMRuntime, runtime: LLMRuntime,
session_key: str | None = None, session_key: str | None = None,
summary_messages: list[dict] | None = None, summary_messages: list[dict[str, Any]] | None = None,
) -> str | None: ) -> str | None:
"""Summarize messages via LLM and append to history.jsonl. """Summarize messages and append the result to history.jsonl.
``messages`` are the messages being archived (removed from the live ``summary_messages`` adds context but is excluded from raw fallback.
session); they are what gets raw-dumped if the LLM call fails.
``summary_messages``, when given, lets callers include retained
messages in the summary without archiving them.
Returns the summary text on success, None if nothing to archive.
""" """
if not messages: if not messages:
return None return None
@@ -1118,6 +1137,7 @@ class Consolidator:
if summary: if summary:
last_summary = summary last_summary = summary
session.last_consolidated = end_idx session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
if not summary: if not summary:
# LLM is degraded — stop hammering it this call; # LLM is degraded — stop hammering it this call;
@@ -1143,13 +1163,7 @@ class Consolidator:
runtime: LLMRuntime, runtime: LLMRuntime,
max_suffix: int = 8, max_suffix: int = 8,
) -> str | None: ) -> str | None:
"""Hard-truncate an idle session under the consolidation lock. """Archive an idle prefix and hide it from replay without deleting it."""
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key) lock = self.get_lock(session_key)
async with lock: async with lock:
self.sessions.invalidate(session_key) self.sessions.invalidate(session_key)
@@ -1169,24 +1183,21 @@ class Consolidator:
last_consolidated=0, last_consolidated=0,
) )
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True) result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
messages_to_keep = probe.messages visible_suffix = probe.messages
messages_to_remove = result.dropped[result.already_consolidated_count:] messages_to_remove = result.dropped
if not messages_to_remove and not messages_to_keep: if not messages_to_remove:
self.sessions.save(session) self.sessions.save(session)
return "" return ""
last_active = session.updated_at last_active = session.updated_at
summary: str | None = "" # The visible suffix informs the summary but stays out of raw fallback.
if messages_to_remove: summary = await self.archive(
# Summarize the retained suffix too, but only remove/raw-dump messages_to_remove,
# the messages that are no longer kept in the live session. runtime=runtime,
summary = await self.archive( session_key=session_key,
messages_to_remove, summary_messages=messages_to_summarize,
runtime=runtime, )
session_key=session_key,
summary_messages=messages_to_summarize,
)
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
session.metadata["_last_summary"] = { session.metadata["_last_summary"] = {
@@ -1194,17 +1205,18 @@ class Consolidator:
"last_active": last_active.isoformat(), "last_active": last_active.isoformat(),
} }
session.messages = messages_to_keep # Preserve history and advance only the replay boundary.
session.last_consolidated = 0 session.last_consolidated = len(session.messages) - len(visible_suffix)
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
if messages_to_remove: logger.info(
logger.info( "Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
"Idle-session compact for {}: archived={}, kept={}, summary={}", session_key,
session_key, len(messages_to_remove),
len(messages_to_remove), len(visible_suffix),
len(messages_to_keep), len(session.messages),
bool(summary), bool(summary),
) )
return summary return summary
+3 -4
View File
@@ -5,9 +5,8 @@ from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import replace from dataclasses import replace
from pathlib import Path from pathlib import Path
from typing import Any
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
@@ -22,7 +21,7 @@ def default_selection_signature(
return (model_preset, *signature[:2]) if signature else None return (model_preset, *signature[:2]) if signature else None
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]: def configured_model_presets(config: Config) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()} return {**config.model_presets, "default": config.resolve_default_preset()}
@@ -41,7 +40,7 @@ def load_model_preset_catalog(
def make_preset_snapshot_loader( def make_preset_snapshot_loader(
config: Any, config: Config,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None, provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
) -> PresetSnapshotLoader: ) -> PresetSnapshotLoader:
if provider_snapshot_loader is not None: if provider_snapshot_loader is not None:
+5 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from dataclasses import replace from dataclasses import replace
from types import MappingProxyType from types import MappingProxyType
from typing import cast
from nanobot.agent import model_presets as preset_helpers from nanobot.agent import model_presets as preset_helpers
from nanobot.config.schema import Config, ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
@@ -139,7 +140,7 @@ class ModelRuntimeResolver:
def select_model(self, model: str) -> LLMRuntime: def select_model(self, model: str) -> LLMRuntime:
"""Change the default model without reconstructing downstream consumers.""" """Change the default model without reconstructing downstream consumers."""
if not isinstance(model, str) or not model.strip(): if not isinstance(cast(object, model), str) or not model.strip():
raise ValueError("model must be a non-empty string") raise ValueError("model must be a non-empty string")
self._runtime = replace( self._runtime = replace(
self._runtime, self._runtime,
@@ -150,8 +151,9 @@ class ModelRuntimeResolver:
def select_context_window(self, context_window_tokens: int) -> LLMRuntime: def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
"""Change the default context limit for future admissions.""" """Change the default context limit for future admissions."""
if not isinstance(context_window_tokens, int) or isinstance( raw_context_window = cast(object, context_window_tokens)
context_window_tokens, if not isinstance(raw_context_window, int) or isinstance(
raw_context_window,
bool, bool,
): ):
raise TypeError("context_window_tokens must be an integer") raise TypeError("context_window_tokens must be an integer")
+3 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect import inspect
import json import json
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable, cast
from loguru import logger from loguru import logger
@@ -124,7 +124,7 @@ class AgentProgressHook(AgentHook):
arguments = event.get("arguments") arguments = event.get("arguments")
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
arguments = {} arguments = {}
payload = { payload: dict[str, Any] = {
"version": 1, "version": 1,
"phase": phase, "phase": phase,
"call_id": str(call_id), "call_id": str(call_id),
@@ -169,7 +169,7 @@ class AgentProgressHook(AgentHook):
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls] tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
await invoke_on_progress( await invoke_on_progress(
self._on_progress, self._on_progress,
tool_hint, cast(str, tool_hint),
tool_hint=True, tool_hint=True,
tool_events=tool_events, tool_events=tool_events,
) )
+233 -68
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
import os import os
from collections.abc import Awaitable, Callable, Iterable
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, cast
from loguru import logger from loguru import logger
@@ -18,7 +19,17 @@ from nanobot.agent.context_governance import (
) )
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, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import (
LLMProvider,
LLMResponse,
ProviderCallContext,
ProviderConversationState,
ToolCallRequest,
)
from nanobot.providers.conversation_state import (
ProviderConversationStateController,
allows_conversation_message_merge,
)
from nanobot.runtime_context import ( from nanobot.runtime_context import (
RUNTIME_CONTEXT_MESSAGE_META, RUNTIME_CONTEXT_MESSAGE_META,
detach_runtime_context, detach_runtime_context,
@@ -48,6 +59,10 @@ from nanobot.utils.runtime import (
) )
GoalContinueMessage = str | Callable[[], str | None] GoalContinueMessage = str | Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model." _DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_ARREARAGE_ERROR_MESSAGE = ( _ARREARAGE_ERROR_MESSAGE = (
@@ -90,15 +105,16 @@ class AgentRunSpec:
session_key: str | None = None session_key: str | None = None
context_block_limit: int | None = None context_block_limit: int | None = None
provider_retry_mode: str = "standard" provider_retry_mode: str = "standard"
progress_callback: Any | None = None progress_callback: ProgressCallback | None = None
stream_progress_deltas: bool = True stream_progress_deltas: bool = True
retry_wait_callback: Any | None = None retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: Any | None = None checkpoint_callback: CheckpointCallback | None = None
injection_callback: Any | None = None injection_callback: InjectionCallback | None = None
llm_timeout_s: float | None = None llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None goal_continue_message: GoalContinueMessage | None = None
finalize_on_max_iterations: bool = True finalize_on_max_iterations: bool = True
provider_state: ProviderConversationState | None = None
@dataclass(slots=True) @dataclass(slots=True)
@@ -115,6 +131,7 @@ class AgentRunResult:
had_injections: bool = False had_injections: bool = False
# Terminal tail to emit when the preceding final-content prefix was already streamed. # Terminal tail to emit when the preceding final-content prefix was already streamed.
pending_stream_content: str | None = None pending_stream_content: str | None = None
provider_state: ProviderConversationState | None = field(default=None, repr=False)
class AgentRunner: class AgentRunner:
@@ -131,8 +148,10 @@ class AgentRunner:
def _to_blocks(value: Any) -> list[dict[str, Any]]: def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list): if isinstance(value, list):
return [ return [
item if isinstance(item, dict) else {"type": "text", "text": str(item)} cast(dict[str, Any], item)
for item in value if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
] ]
if value is None: if value is None:
return [] return []
@@ -154,29 +173,42 @@ class AgentRunner:
and messages[-1].get("role") == "user" and messages[-1].get("role") == "user"
and not is_hidden_history_message(injection) and not is_hidden_history_message(injection)
and not is_hidden_history_message(messages[-1]) and not is_hidden_history_message(messages[-1])
and allows_conversation_message_merge(messages[-1])
): ):
merged = dict(messages[-1]) merged = dict(messages[-1])
left_meta = merged.get("_meta") left_meta = merged.get("_meta")
right_meta = injection.get("_meta") right_meta = injection.get("_meta")
left_meta_dict = cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
right_meta_dict = (
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
)
left_marker = ( left_marker = (
left_meta.get(RUNTIME_CONTEXT_MESSAGE_META) left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(left_meta, dict) if left_meta_dict is not None
else None else None
) )
right_marker = ( right_marker = (
right_meta.get(RUNTIME_CONTEXT_MESSAGE_META) right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(right_meta, dict) if right_meta_dict is not None
else None else None
) )
left_marker_dict = (
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
)
right_marker_dict = (
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
)
empty_sources: list[str] = []
empty_blocks: list[dict[str, Any]] = []
detached_left = ( detached_left = (
detach_runtime_context(merged.get("content"), left_marker) detach_runtime_context(merged.get("content"), left_marker_dict)
if isinstance(left_marker, dict) if left_marker_dict is not None
else (merged.get("content"), [], []) else (merged.get("content"), empty_sources, empty_blocks)
) )
detached_right = ( detached_right = (
detach_runtime_context(injection.get("content"), right_marker) detach_runtime_context(injection.get("content"), right_marker_dict)
if isinstance(right_marker, dict) if right_marker_dict is not None
else (injection.get("content"), [], []) else (injection.get("content"), empty_sources, empty_blocks)
) )
if detached_left is not None and detached_right is not None: if detached_left is not None and detached_right is not None:
left_content, left_sources, left_blocks = detached_left left_content, left_sources, left_blocks = detached_left
@@ -189,9 +221,9 @@ class AgentRunner:
[*left_sources, *right_sources], [*left_sources, *right_sources],
context_blocks, context_blocks,
) )
internal_meta = dict(left_meta) if isinstance(left_meta, dict) else {} internal_meta = dict(left_meta_dict) if left_meta_dict is not None else {}
if isinstance(right_meta, dict): if right_meta_dict is not None:
for key, value in right_meta.items(): for key, value in right_meta_dict.items():
internal_meta.setdefault(key, value) internal_meta.setdefault(key, value)
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
merged["_meta"] = internal_meta merged["_meta"] = internal_meta
@@ -212,6 +244,7 @@ class AgentRunner:
assistant_message: dict[str, Any] | None, assistant_message: dict[str, Any] | None,
injection_cycles: int, injection_cycles: int,
*, *,
conversation_state: ProviderConversationStateController | None = None,
phase: str = "after error", phase: str = "after error",
iteration: int | None = None, iteration: int | None = None,
allow_goal_continue: bool = False, allow_goal_continue: bool = False,
@@ -239,16 +272,21 @@ class AgentRunner:
if assistant_message is not None: if assistant_message is not None:
messages.append(assistant_message) messages.append(assistant_message)
if iteration is not None: if iteration is not None:
checkpoint: dict[str, Any] = {
"phase": "final_response",
"iteration": iteration,
"model": spec.runtime.model,
"assistant_message": assistant_message,
"completed_tool_results": [],
"pending_tool_calls": [],
}
if conversation_state is not None:
checkpoint["provider_state"] = conversation_state.checkpoint(
messages
)
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
{ checkpoint,
"phase": "final_response",
"iteration": iteration,
"model": spec.runtime.model,
"assistant_message": assistant_message,
"completed_tool_results": [],
"pending_tool_calls": [],
},
) )
self._append_injected_messages(messages, injections) self._append_injected_messages(messages, injections)
if real_injection: if real_injection:
@@ -302,11 +340,11 @@ class AgentRunner:
for item in items: for item in items:
if item is None: if item is None:
continue continue
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
if self._has_injection_content(item.get("content")):
injected_messages.append(item)
continue
if isinstance(item, dict): if isinstance(item, dict):
message_item = cast(dict[str, Any], item)
if message_item.get("role") == "user" and "content" in message_item:
if self._has_injection_content(message_item.get("content")):
injected_messages.append(message_item)
continue continue
content = getattr(item, "content") if hasattr(item, "content") else str(item) content = getattr(item, "content") if hasattr(item, "content") else str(item)
if self._has_injection_content(content): if self._has_injection_content(content):
@@ -327,7 +365,7 @@ class AgentRunner:
if isinstance(content, str): if isinstance(content, str):
return bool(content.strip()) return bool(content.strip())
if isinstance(content, list): if isinstance(content, list):
return bool(content) return bool(cast(list[Any], content))
return True return True
async def run(self, spec: AgentRunSpec) -> AgentRunResult: async def run(self, spec: AgentRunSpec) -> AgentRunResult:
@@ -401,6 +439,12 @@ class AgentRunner:
injection_cycles = 0 injection_cycles = 0
compacted_tool_call_ids: set[str] = set() compacted_tool_call_ids: set[str] = set()
pending_stream_content: str | None = None pending_stream_content: str | None = None
conversation_state = ProviderConversationStateController(
provider=spec.runtime.provider,
model=spec.runtime.model,
messages=messages,
state=spec.provider_state,
)
governance_config = ContextGovernanceConfig( governance_config = ContextGovernanceConfig(
provider=spec.runtime.provider, provider=spec.runtime.provider,
model=spec.runtime.model, model=spec.runtime.model,
@@ -431,7 +475,20 @@ class AgentRunner:
session_key=spec.session_key, session_key=spec.session_key,
) )
await hook.before_iteration(context) await hook.before_iteration(context)
response = await self._request_model(spec, messages_for_model, hook, context) provider_context = conversation_state.prepare_request(
messages,
context_window_tokens=spec.runtime.context_window_tokens,
model_messages=messages_for_model,
)
response = await self._request_model(
spec,
messages_for_model,
hook,
context,
conversation_state=conversation_state,
provider_context=provider_context,
)
conversation_state.observe_response(response, messages)
context.response = response context.response = response
context.tool_calls = list(response.tool_calls) context.tool_calls = list(response.tool_calls)
@@ -461,6 +518,10 @@ class AgentRunner:
reasoning_content=response.reasoning_content, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks, thinking_blocks=response.thinking_blocks,
) )
assistant_message = conversation_state.project_response_message(
assistant_message,
response,
)
messages.append(assistant_message) messages.append(assistant_message)
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
@@ -525,6 +586,15 @@ class AgentRunner:
length_recovery_parts.clear() length_recovery_parts.clear()
continue continue
break break
checkpoint_model_messages = (
self.context_governor.prepare_for_model(
governance_config,
messages,
compacted_tool_call_ids,
)
if response.provider_state is not None
else None
)
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
{ {
@@ -534,6 +604,10 @@ class AgentRunner:
"assistant_message": assistant_message, "assistant_message": assistant_message,
"completed_tool_results": completed_tool_results, "completed_tool_results": completed_tool_results,
"pending_tool_calls": [], "pending_tool_calls": [],
"provider_state": conversation_state.checkpoint(
messages,
model_messages=checkpoint_model_messages,
),
}, },
) )
empty_content_retries = 0 empty_content_retries = 0
@@ -556,7 +630,11 @@ class AgentRunner:
) )
clean = hook.finalize_content(context, response.content) clean = hook.finalize_content(context, response.content)
if response.finish_reason != "error" and is_blank_text(clean): if (
response.finish_reason
not in {"error", "length", "refusal", "content_filter"}
and is_blank_text(clean)
):
empty_content_retries += 1 empty_content_retries += 1
if empty_content_retries < _MAX_EMPTY_RETRIES: if empty_content_retries < _MAX_EMPTY_RETRIES:
logger.warning( logger.warning(
@@ -579,7 +657,12 @@ class AgentRunner:
if hook.wants_streaming(): if hook.wants_streaming():
await hook.on_stream_end(context, resuming=False) await hook.on_stream_end(context, resuming=False)
retry_messages = self._finalization_retry_messages(messages_for_model) retry_messages = self._finalization_retry_messages(messages_for_model)
response = await self._request_finalization_retry(spec, messages_for_model) response = await self._request_finalization_retry(
spec,
messages_for_model,
transcript=messages,
conversation_state=conversation_state,
)
retry_usage = self._usage_or_estimate(spec, retry_messages, response) retry_usage = self._usage_or_estimate(spec, retry_messages, response)
self._accumulate_usage(usage, retry_usage) self._accumulate_usage(usage, retry_usage)
raw_usage = self._merge_usage(raw_usage, retry_usage) raw_usage = self._merge_usage(raw_usage, retry_usage)
@@ -589,10 +672,10 @@ class AgentRunner:
original_content = response.content original_content = response.content
clean = hook.finalize_content(context, response.content) clean = hook.finalize_content(context, response.content)
if response.finish_reason == "length" and not is_blank_text(clean): if response.finish_reason == "length":
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES: if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
length_recovery_parts.append( length_recovery_parts.append(
_restore_outer_whitespace(clean, original_content) _restore_outer_whitespace(clean or "", original_content)
) )
logger.info( logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing", "Output truncated on turn {} for {} ({}/{}); continuing",
@@ -604,12 +687,15 @@ class AgentRunner:
if hook.wants_streaming(): if hook.wants_streaming():
context.stream_continues_current_message = True context.stream_continues_current_message = True
await hook.on_stream_end(context, resuming=True) await hook.on_stream_end(context, resuming=True)
messages.append(build_assistant_message( messages.append(conversation_state.project_response_message(
clean, build_assistant_message(
reasoning_content=response.reasoning_content, clean,
thinking_blocks=response.thinking_blocks, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
),
response,
)) ))
messages.append(build_length_recovery_message(clean)) messages.append(build_length_recovery_message(clean or ""))
await hook.after_iteration(context) await hook.after_iteration(context)
continue continue
@@ -626,7 +712,7 @@ class AgentRunner:
): ):
await hook.on_stream( await hook.on_stream(
context, context,
_restore_outer_whitespace(clean, original_content), _restore_outer_whitespace(clean or "", original_content),
) )
context.streamed_content = True context.streamed_content = True
@@ -637,15 +723,22 @@ class AgentRunner:
reasoning_content=response.reasoning_content, reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks, thinking_blocks=response.thinking_blocks,
) )
assistant_message = conversation_state.project_response_message(
assistant_message,
response,
)
# Check for mid-turn injections BEFORE signaling stream end. # Check for mid-turn injections BEFORE signaling stream end.
# If injections are found we keep the stream alive (resuming=True) # If injections are found we keep the stream alive (resuming=True)
# so streaming channels don't prematurely finalize the card. # so streaming channels don't prematurely finalize the card.
should_continue, injection_cycles = await self._try_drain_injections( should_continue, injection_cycles = await self._try_drain_injections(
spec, messages, assistant_message, injection_cycles, spec, messages, assistant_message, injection_cycles,
conversation_state=conversation_state,
phase="after final response", phase="after final response",
iteration=iteration, iteration=iteration,
allow_goal_continue=True, allow_goal_continue=(
response.finish_reason not in {"refusal", "content_filter"}
),
) )
if should_continue: if should_continue:
had_injections = True had_injections = True
@@ -698,11 +791,17 @@ class AgentRunner:
continue continue
break break
messages.append(assistant_message or build_assistant_message( messages.append(
clean, assistant_message
reasoning_content=response.reasoning_content, or conversation_state.project_response_message(
thinking_blocks=response.thinking_blocks, build_assistant_message(
)) clean,
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
),
response,
)
)
await self._emit_checkpoint( await self._emit_checkpoint(
spec, spec,
{ {
@@ -712,12 +811,13 @@ class AgentRunner:
"assistant_message": messages[-1], "assistant_message": messages[-1],
"completed_tool_results": [], "completed_tool_results": [],
"pending_tool_calls": [], "pending_tool_calls": [],
"provider_state": conversation_state.checkpoint(messages),
}, },
) )
if length_recovery_parts: if length_recovery_parts:
final_content = ( final_content = (
"".join(length_recovery_parts) "".join(length_recovery_parts)
+ _restore_outer_whitespace(clean, original_content) + _restore_outer_whitespace(clean or "", original_content)
).strip() ).strip()
else: else:
final_content = clean final_content = clean
@@ -745,6 +845,7 @@ class AgentRunner:
hook, hook,
messages, messages,
usage, usage,
conversation_state,
) )
if terminal_content is None: if terminal_content is None:
terminal_content = self._max_iterations_fallback(spec) terminal_content = self._max_iterations_fallback(spec)
@@ -768,6 +869,7 @@ class AgentRunner:
tool_events=tool_events, tool_events=tool_events,
had_injections=had_injections, had_injections=had_injections,
pending_stream_content=pending_stream_content, pending_stream_content=pending_stream_content,
provider_state=conversation_state.finish(messages),
) )
def _build_request_kwargs( def _build_request_kwargs(
@@ -798,7 +900,9 @@ class AgentRunner:
context: AgentHookContext, context: AgentHookContext,
*, *,
malformed_retry: bool = False, malformed_retry: bool = False,
): conversation_state: ProviderConversationStateController,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse:
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:
# Default to a finite timeout to avoid per-session lock starvation when an LLM # Default to a finite timeout to avoid per-session lock starvation when an LLM
@@ -809,7 +913,7 @@ class AgentRunner:
timeout_s = float(raw) timeout_s = float(raw)
except (TypeError, ValueError): except (TypeError, ValueError):
timeout_s = 300.0 timeout_s = 300.0
if timeout_s is not None and timeout_s <= 0: if timeout_s <= 0:
timeout_s = None timeout_s = None
kwargs = self._build_request_kwargs( kwargs = self._build_request_kwargs(
@@ -818,10 +922,11 @@ class AgentRunner:
tools=spec.tools.get_definitions(), tools=spec.tools.get_definitions(),
) )
wants_streaming = hook.wants_streaming() wants_streaming = hook.wants_streaming()
progress_callback = spec.progress_callback
wants_progress_streaming = ( wants_progress_streaming = (
not wants_streaming not wants_streaming
and spec.stream_progress_deltas and spec.stream_progress_deltas
and spec.progress_callback is not None and progress_callback is not None
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
) )
@@ -866,6 +971,7 @@ class AgentRunner:
coro = spec.runtime.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs, **kwargs,
provider_context=provider_context,
on_content_delta=_stream, on_content_delta=_stream,
on_thinking_delta=_thinking, on_thinking_delta=_thinking,
on_tool_call_delta=_provider_tool_event, on_tool_call_delta=_provider_tool_event,
@@ -894,15 +1000,21 @@ class AgentRunner:
await hook.emit_reasoning_end() await hook.emit_reasoning_end()
progress_state["reasoning_open"] = False progress_state["reasoning_open"] = False
context.streamed_content = True context.streamed_content = True
await spec.progress_callback(incremental) callback = progress_callback
if callback is not None:
await callback(incremental)
coro = spec.runtime.provider.chat_stream_with_retry( coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs, **kwargs,
provider_context=provider_context,
on_content_delta=_stream_progress, on_content_delta=_stream_progress,
on_tool_call_delta=_provider_tool_event, on_tool_call_delta=_provider_tool_event,
) )
else: else:
coro = spec.runtime.provider.chat_with_retry(**kwargs) coro = spec.runtime.provider.chat_with_retry(
**kwargs,
provider_context=provider_context,
)
# Streaming requests also have provider-level idle timeouts # Streaming requests also have provider-level idle timeouts
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing # (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
@@ -964,6 +1076,10 @@ class AgentRunner:
return await self._request_model( return await self._request_model(
spec, retry_messages, hook, context, spec, retry_messages, hook, context,
malformed_retry=True, malformed_retry=True,
conversation_state=conversation_state,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
) )
if ( if (
all_dropped all_dropped
@@ -976,7 +1092,13 @@ class AgentRunner:
fallback_messages = self._malformed_tool_call_retry_messages( fallback_messages = self._malformed_tool_call_retry_messages(
messages, response.content, messages, response.content,
) )
return await self._request_no_tools(spec, fallback_messages) return await self._request_no_tools(
spec,
fallback_messages,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
)
return response return response
@staticmethod @staticmethod
@@ -1009,6 +1131,10 @@ class AgentRunner:
original_finish_reason, original_finish_reason,
) )
response.tool_calls = valid response.tool_calls = valid
# The opaque candidate still contains every raw function_call item.
# Advancing it after dropping even one call would replay an unmatched
# call without a corresponding tool output on the next request.
response.provider_state = None
if not valid: if not valid:
response.finish_reason = "stop" response.finish_reason = "stop"
return (dropped, not valid, original_finish_reason) return (dropped, not valid, original_finish_reason)
@@ -1038,9 +1164,27 @@ class AgentRunner:
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
): *,
transcript: list[dict[str, Any]],
conversation_state: ProviderConversationStateController,
) -> LLMResponse:
retry_messages = self._finalization_retry_messages(messages) retry_messages = self._finalization_retry_messages(messages)
return await self._request_no_tools(spec, retry_messages) provider_context = conversation_state.prepare_request(
transcript,
context_window_tokens=spec.runtime.context_window_tokens,
supplemental_messages=[retry_messages[-1]],
)
response = await self._request_no_tools(
spec,
retry_messages,
provider_context=provider_context,
)
conversation_state.observe_response(
response,
transcript,
adopt_candidate_state=False,
)
return response
@staticmethod @staticmethod
def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
@@ -1054,10 +1198,17 @@ class AgentRunner:
hook: AgentHook, hook: AgentHook,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
usage: dict[str, int], usage: dict[str, int],
conversation_state: ProviderConversationStateController,
) -> str | None: ) -> str | None:
retry_messages = self._budget_exhausted_finalization_messages(messages) retry_messages = self._budget_exhausted_finalization_messages(messages)
try: try:
response = await self._request_no_tools(spec, retry_messages) response = await self._request_no_tools(
spec,
retry_messages,
provider_context=conversation_state.independent_request_context(
context_window_tokens=spec.runtime.context_window_tokens,
),
)
except Exception: except Exception:
logger.exception( logger.exception(
"Budget-exhausted finalization failed for {}; using fallback", "Budget-exhausted finalization failed for {}; using fallback",
@@ -1093,9 +1244,18 @@ class AgentRunner:
self, self,
spec: AgentRunSpec, spec: AgentRunSpec,
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
*,
provider_context: ProviderCallContext | None = None,
) -> LLMResponse: ) -> LLMResponse:
kwargs = self._build_request_kwargs(spec, messages, tools=None) kwargs = self._build_request_kwargs(
return await spec.runtime.provider.chat_with_retry(**kwargs) spec,
messages,
tools=None,
)
return await spec.runtime.provider.chat_with_retry(
**kwargs,
provider_context=provider_context,
)
@staticmethod @staticmethod
def _budget_exhausted_finalization_messages( def _budget_exhausted_finalization_messages(
@@ -1224,7 +1384,7 @@ class AgentRunner:
)) ))
tool_results.extend(batch_results) tool_results.extend(batch_results)
else: else:
batch_results = [] batch_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for tool_call in batch: for tool_call in batch:
result = await self._run_tool( result = await self._run_tool(
spec, spec,
@@ -1273,12 +1433,17 @@ class AgentRunner:
if spec.fail_on_tool_error: if spec.fail_on_tool_error:
return lookup_error + hint, event, RuntimeError(lookup_error) return lookup_error + hint, event, RuntimeError(lookup_error)
return lookup_error + hint, event, None return lookup_error + hint, event, None
prepare_call = getattr(spec.tools, "prepare_call", None) prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call): if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments) prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple) and len(prepared) == 3: if isinstance(prepared, tuple):
tool, params, prep_error = prepared prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error: if prep_error:
event = { event = {
"name": tool_call.name, "name": tool_call.name,
@@ -1490,7 +1655,7 @@ class AgentRunner:
batches: list[list[ToolCallRequest]] = [] batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = [] current: list[ToolCallRequest] = []
for tool_call in tool_calls: for tool_call in tool_calls:
get_tool = getattr(spec.tools, "get", None) get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe) can_batch = bool(tool and tool.concurrency_safe)
if can_batch: if can_batch:
+39 -20
View File
@@ -5,6 +5,7 @@ import os
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, cast
import yaml import yaml
@@ -16,6 +17,7 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL, re.DOTALL,
) )
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
class SkillsLoader: class SkillsLoader:
@@ -108,6 +110,21 @@ class SkillsLoader:
] ]
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def get_explicitly_invoked_skills(self, text: str) -> list[str]:
"""Resolve ``$skill-name`` references to enabled, available skills."""
if not text:
return []
available = {
entry["name"]
for entry in self.list_skills(filter_unavailable=True)
}
invoked: list[str] = []
for match in _SKILL_REFERENCE.finditer(text):
name = match.group(1)
if name in available and name not in invoked:
invoked.append(name)
return invoked
def build_skills_summary(self, exclude: set[str] | None = None) -> str: def build_skills_summary(self, exclude: set[str] | None = None) -> str:
""" """
Build a summary of all skills (name, description, path, availability). Build a summary of all skills (name, description, path, availability).
@@ -144,7 +161,7 @@ class SkillsLoader:
skill_name = entry["name"] skill_name = entry["name"]
meta = self._get_skill_meta(skill_name) meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta) available = self._check_requirements(meta)
desc = self._get_skill_description(skill_name) desc = self.get_skill_description(skill_name)
suffix = "" suffix = ""
if not available: if not available:
missing = self._get_missing_requirements(meta) missing = self._get_missing_requirements(meta)
@@ -155,18 +172,18 @@ class SkillsLoader:
return "\n\n".join(sections) return "\n\n".join(sections)
@staticmethod @staticmethod
def _requirement_lists(skill_meta: dict) -> tuple[list[str], list[str]]: def _requirement_lists(skill_meta: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes.""" """Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
requires = skill_meta.get("requires") or {} requires = cast(dict[str, Any], skill_meta.get("requires") or {})
if not isinstance(requires, dict): if not isinstance(skill_meta.get("requires") or {}, dict):
return [], [] return [], []
bins_raw = requires.get("bins") or [] bins_raw: object = requires.get("bins") or []
env_raw = requires.get("env") or [] env_raw: object = requires.get("env") or []
bins = [str(v) for v in bins_raw if isinstance(v, str) and v.strip()] if isinstance(bins_raw, list) else [] bins = [value for value in cast(list[object], bins_raw) if isinstance(value, str) and value.strip()] if isinstance(bins_raw, list) else []
env = [str(v) for v in env_raw if isinstance(v, str) and v.strip()] if isinstance(env_raw, list) else [] env = [value for value in cast(list[object], env_raw) if isinstance(value, str) and value.strip()] if isinstance(env_raw, list) else []
return bins, env return bins, env
def _get_missing_requirements(self, skill_meta: dict) -> str: def _get_missing_requirements(self, skill_meta: dict[str, Any]) -> str:
"""Get a description of missing requirements.""" """Get a description of missing requirements."""
required_bins, required_env_vars = self._requirement_lists(skill_meta) required_bins, required_env_vars = self._requirement_lists(skill_meta)
return ", ".join( return ", ".join(
@@ -190,11 +207,12 @@ class SkillsLoader:
"missing_env": [value for value in env if not os.environ.get(value)], "missing_env": [value for value in env if not os.environ.get(value)],
} }
def _get_skill_description(self, name: str) -> str: def get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter.""" """Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name) meta = self.get_skill_metadata(name)
if meta and meta.get("description"): description = meta.get("description") if meta else None
return meta["description"] if isinstance(description, str) and description:
return description
return name # Fallback to skill name return name # Fallback to skill name
def _strip_frontmatter(self, content: str) -> str: def _strip_frontmatter(self, content: str) -> str:
@@ -206,13 +224,13 @@ class SkillsLoader:
return content[match.end():].strip() return content[match.end():].strip()
return content return content
def _parse_nanobot_metadata(self, raw: object) -> dict: def _parse_nanobot_metadata(self, raw: object) -> dict[str, Any]:
"""Extract nanobot/openclaw metadata from a frontmatter field. """Extract nanobot/openclaw metadata from a frontmatter field.
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str. ``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
""" """
if isinstance(raw, dict): if isinstance(raw, dict):
data = raw data = cast(dict[str, Any], raw)
elif isinstance(raw, str): elif isinstance(raw, str):
try: try:
data = json.loads(raw) data = json.loads(raw)
@@ -222,17 +240,18 @@ class SkillsLoader:
return {} return {}
if not isinstance(data, dict): if not isinstance(data, dict):
return {} return {}
payload = data.get("nanobot", data.get("openclaw", {})) data_object = cast(dict[str, Any], data)
return payload if isinstance(payload, dict) else {} payload = data_object.get("nanobot", data_object.get("openclaw", {}))
return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
def _check_requirements(self, skill_meta: dict) -> bool: def _check_requirements(self, skill_meta: dict[str, Any]) -> bool:
"""Check if skill requirements are met (bins, env vars).""" """Check if skill requirements are met (bins, env vars)."""
required_bins, required_env_vars = self._requirement_lists(skill_meta) required_bins, required_env_vars = self._requirement_lists(skill_meta)
return all(shutil.which(cmd) for cmd in required_bins) and all( return all(shutil.which(cmd) for cmd in required_bins) and all(
os.environ.get(var) for var in required_env_vars os.environ.get(var) for var in required_env_vars
) )
def _get_skill_meta(self, name: str) -> dict: def _get_skill_meta(self, name: str) -> dict[str, Any]:
"""Get nanobot metadata for a skill (cached in frontmatter).""" """Get nanobot metadata for a skill (cached in frontmatter)."""
raw_meta = self.get_skill_metadata(name) or {} raw_meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(raw_meta.get("metadata")) return self._parse_nanobot_metadata(raw_meta.get("metadata"))
@@ -249,7 +268,7 @@ class SkillsLoader:
) )
] ]
def get_skill_metadata(self, name: str) -> dict | None: def get_skill_metadata(self, name: str) -> dict[str, object] | None:
""" """
Get metadata from a skill's frontmatter. Get metadata from a skill's frontmatter.
@@ -274,6 +293,6 @@ class SkillsLoader:
# yaml.safe_load returns native types (int, bool, list, etc.); # yaml.safe_load returns native types (int, bool, list, etc.);
# keep values as-is so downstream consumers get correct types. # keep values as-is so downstream consumers get correct types.
metadata: dict[str, object] = {} metadata: dict[str, object] = {}
for key, value in parsed.items(): for key, value in cast(dict[object, object], parsed).items():
metadata[str(key)] = value metadata[str(key)] = value
return metadata return metadata
+21 -11
View File
@@ -7,12 +7,12 @@ import uuid
import warnings import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, TypedDict
from loguru import logger from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
from nanobot.agent.tools.base import ToolResult from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.context import ( from nanobot.agent.tools.context import (
RequestContext, RequestContext,
@@ -38,6 +38,12 @@ from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
class _SubagentOrigin(TypedDict):
channel: str
chat_id: str
session_key: str | None
@dataclass(slots=True) @dataclass(slots=True)
class SubagentStatus: class SubagentStatus:
"""Real-time status of a running subagent.""" """Real-time status of a running subagent."""
@@ -48,8 +54,8 @@ class SubagentStatus:
started_at: float # time.monotonic() started_at: float # time.monotonic()
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
iteration: int = 0 iteration: int = 0
tool_events: list = field(default_factory=list) # [{name, status, detail}, ...] tool_events: list[dict[str, str]] = field(default_factory=list)
usage: dict = field(default_factory=dict) # token usage usage: dict[str, int] = field(default_factory=dict)
stop_reason: str | None = None stop_reason: str | None = None
error: str | None = None error: str | None = None
@@ -237,7 +243,11 @@ class SubagentManager:
runtime = runtime.with_generation_overrides(temperature=temperature) runtime = runtime.with_generation_overrides(temperature=temperature)
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "") display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key} origin: _SubagentOrigin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
}
status = SubagentStatus( status = SubagentStatus(
task_id=task_id, task_id=task_id,
@@ -263,7 +273,7 @@ class SubagentManager:
if session_key: if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id) self._session_tasks.setdefault(session_key, set()).add(task_id)
def _cleanup(_: asyncio.Task) -> None: def _cleanup(_: asyncio.Task[str]) -> None:
self._running_tasks.pop(task_id, None) self._running_tasks.pop(task_id, None)
self._task_statuses.pop(task_id, None) self._task_statuses.pop(task_id, None)
if session_key and (ids := self._session_tasks.get(session_key)): if session_key and (ids := self._session_tasks.get(session_key)):
@@ -296,7 +306,7 @@ class SubagentManager:
runtime = runtime.with_generation_overrides(temperature=temperature) runtime = runtime.with_generation_overrides(temperature=temperature)
task_id = str(uuid.uuid4())[:8] task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "") display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = { origin: _SubagentOrigin = {
"channel": origin_channel, "channel": origin_channel,
"chat_id": origin_chat_id, "chat_id": origin_chat_id,
"session_key": session_key, "session_key": session_key,
@@ -343,7 +353,7 @@ class SubagentManager:
task_id: str, task_id: str,
task: str, task: str,
label: str, label: str,
origin: dict[str, str], origin: _SubagentOrigin,
status: SubagentStatus, status: SubagentStatus,
runtime: LLMRuntime, runtime: LLMRuntime,
origin_message_id: str | None = None, origin_message_id: str | None = None,
@@ -354,7 +364,7 @@ class SubagentManager:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label) logger.info("Subagent [{}] starting task: {}", task_id, label)
async def _on_checkpoint(payload: dict) -> None: async def _on_checkpoint(payload: dict[str, Any]) -> None:
status.phase = payload.get("phase", status.phase) status.phase = payload.get("phase", status.phase)
status.iteration = payload.get("iteration", status.iteration) status.iteration = payload.get("iteration", status.iteration)
@@ -456,7 +466,7 @@ class SubagentManager:
label: str, label: str,
task: str, task: str,
result: str, result: str,
origin: dict[str, str], origin: _SubagentOrigin,
status: str, status: str,
origin_message_id: str | None = None, origin_message_id: str | None = None,
) -> None: ) -> None:
@@ -496,7 +506,7 @@ class SubagentManager:
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
@staticmethod @staticmethod
def _format_partial_progress(result) -> str: def _format_partial_progress(result: AgentRunResult) -> str:
completed = [e for e in result.tool_events if e["status"] == "ok"] completed = [e for e in result.tool_events if e["status"] == "ok"]
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None) failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
lines: list[str] = [] lines: list[str] = []
+9 -5
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
import difflib import difflib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from nanobot.agent.tools.base import ToolResult, tool_parameters from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool from nanobot.agent.tools.filesystem import _FsTool # pyright: ignore[reportPrivateUsage]
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
BooleanSchema, BooleanSchema,
@@ -134,7 +134,7 @@ class ApplyPatchTool(_FsTool):
async def execute( async def execute(
self, self,
edits: list[dict] | None = None, edits: list[object] | None = None,
dry_run: bool = False, dry_run: bool = False,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str:
@@ -145,9 +145,10 @@ class ApplyPatchTool(_FsTool):
writes: dict[Path, str] = {} writes: dict[Path, str] = {}
summaries: list[_PatchSummary] = [] summaries: list[_PatchSummary] = []
for edit in edits: for edit_value in edits:
if not isinstance(edit, dict): if not isinstance(edit_value, dict):
raise _PatchError("each edit must be an object") raise _PatchError("each edit must be an object")
edit = cast(dict[str, Any], edit_value)
raw_path = edit.get("path") raw_path = edit.get("path")
if not isinstance(raw_path, str): if not isinstance(raw_path, str):
raise _PatchError("path required for edit") raise _PatchError("path required for edit")
@@ -161,6 +162,7 @@ class ApplyPatchTool(_FsTool):
new_text = edit.get("new_text") new_text = edit.get("new_text")
if new_text is None: if new_text is None:
raise _PatchError(f"new_text required for add: {path}") raise _PatchError(f"new_text required for add: {path}")
new_text = cast(str, new_text)
pending = writes.get(source) pending = writes.get(source)
if pending is not None: if pending is not None:
@@ -204,9 +206,11 @@ class ApplyPatchTool(_FsTool):
old_text = edit.get("old_text") or "" old_text = edit.get("old_text") or ""
if not old_text: if not old_text:
raise _PatchError(f"old_text required for replace: {path}") raise _PatchError(f"old_text required for replace: {path}")
old_text = cast(str, old_text)
new_text = edit.get("new_text") new_text = edit.get("new_text")
if new_text is None: if new_text is None:
raise _PatchError(f"new_text required for replace: {path}") raise _PatchError(f"new_text required for replace: {path}")
new_text = cast(str, new_text)
pending = writes.get(source) pending = writes.get(source)
if pending is not None: if pending is not None:
+31 -20
View File
@@ -5,7 +5,7 @@ import typing
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable
from copy import deepcopy from copy import deepcopy
from typing import Any, TypeVar from typing import Any, TypeVar, cast
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from pydantic import BaseModel from pydantic import BaseModel
@@ -38,8 +38,9 @@ class Schema(ABC):
def resolve_json_schema_type(t: Any) -> str | None: def resolve_json_schema_type(t: Any) -> str | None:
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``).""" """Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
if isinstance(t, list): if isinstance(t, list):
return next((x for x in t if x != "null"), None) types = cast(list[Any], t)
return t # type: ignore[return-value] return cast(str | None, next((x for x in types if x != "null"), None))
return cast(str | None, t)
@staticmethod @staticmethod
def subpath(path: str, key: str) -> str: def subpath(path: str, key: str) -> str:
@@ -76,33 +77,41 @@ class Schema(ABC):
if "maximum" in schema and val > schema["maximum"]: if "maximum" in schema and val > schema["maximum"]:
errors.append(f"{label} must be <= {schema['maximum']}") errors.append(f"{label} must be <= {schema['maximum']}")
if t == "string": if t == "string":
if "minLength" in schema and len(val) < schema["minLength"]: string_value = cast(str, val)
if "minLength" in schema and len(string_value) < schema["minLength"]:
errors.append(f"{label} must be at least {schema['minLength']} chars") errors.append(f"{label} must be at least {schema['minLength']} chars")
if "maxLength" in schema and len(val) > schema["maxLength"]: if "maxLength" in schema and len(string_value) > schema["maxLength"]:
errors.append(f"{label} must be at most {schema['maxLength']} chars") errors.append(f"{label} must be at most {schema['maxLength']} chars")
if t == "object": if t == "object":
props = schema.get("properties", {}) object_value = cast(dict[str, Any], val)
for k in schema.get("required", []): props = cast(dict[str, Any], schema.get("properties", {}))
if k not in val: required = cast(list[Any], schema.get("required", []))
for k in required:
if k not in object_value:
errors.append(f"missing required {Schema.subpath(path, k)}") errors.append(f"missing required {Schema.subpath(path, k)}")
additional = schema.get("additionalProperties", True) additional = schema.get("additionalProperties", True)
for k, v in val.items(): for k, v in object_value.items():
if k in props: if k in props:
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k))) errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
elif additional is False: elif additional is False:
errors.append(f"unexpected parameter {Schema.subpath(path, k)}") errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
elif isinstance(additional, dict): elif isinstance(additional, dict):
errors.extend( errors.extend(
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k)) Schema.validate_json_schema_value(
v,
cast(dict[str, Any], additional),
Schema.subpath(path, k),
)
) )
if t == "array": if t == "array":
if "minItems" in schema and len(val) < schema["minItems"]: array_value = cast(list[Any], val)
if "minItems" in schema and len(array_value) < schema["minItems"]:
errors.append(f"{label} must have at least {schema['minItems']} items") errors.append(f"{label} must have at least {schema['minItems']} items")
if "maxItems" in schema and len(val) > schema["maxItems"]: if "maxItems" in schema and len(array_value) > schema["maxItems"]:
errors.append(f"{label} must be at most {schema['maxItems']} items") errors.append(f"{label} must be at most {schema['maxItems']} items")
if "items" in schema: if "items" in schema:
prefix = f"{path}[{{}}]" if path else "[{}]" prefix = f"{path}[{{}}]" if path else "[{}]"
for i, item in enumerate(val): for i, item in enumerate(array_value):
errors.extend( errors.extend(
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i)) Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
) )
@@ -114,9 +123,9 @@ class Schema(ABC):
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema # Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
to_js = getattr(value, "to_json_schema", None) to_js = getattr(value, "to_json_schema", None)
if callable(to_js): if callable(to_js):
return to_js() return cast(dict[str, Any], to_js())
if isinstance(value, dict): if isinstance(value, dict):
return value return cast(dict[str, Any], value)
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}") raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
@abstractmethod @abstractmethod
@@ -223,14 +232,15 @@ class Tool(ABC):
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
props = schema.get("properties", {}) props = cast(dict[str, Any], schema.get("properties", {}))
additional = schema.get("additionalProperties") additional = schema.get("additionalProperties")
casted: dict[str, Any] = {} casted: dict[str, Any] = {}
for k, v in obj.items(): object_value = cast(dict[str, Any], obj)
for k, v in object_value.items():
if k in props: if k in props:
casted[k] = self._cast_value(v, props[k]) casted[k] = self._cast_value(v, props[k])
elif isinstance(additional, dict): elif isinstance(additional, dict):
casted[k] = self._cast_value(v, additional) casted[k] = self._cast_value(v, cast(dict[str, Any], additional))
else: else:
casted[k] = v casted[k] = v
return casted return casted
@@ -273,7 +283,8 @@ class Tool(ABC):
if t == "array" and isinstance(val, list): if t == "array" and isinstance(val, list):
items = schema.get("items") items = schema.get("items")
return [self._cast_value(x, items) for x in val] if items else val array_value = cast(list[Any], val)
return [self._cast_value(x, items) for x in array_value] if items else array_value
if t == "object" and isinstance(val, dict): if t == "object" and isinstance(val, dict):
return self._cast_object(val, schema) return self._cast_object(val, schema)
@@ -282,7 +293,7 @@ class Tool(ABC):
def validate_params(self, params: dict[str, Any]) -> list[str]: def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate against JSON schema; empty list means valid.""" """Validate against JSON schema; empty list means valid."""
if not isinstance(params, dict): if not isinstance(cast(object, params), dict):
return [f"parameters must be an object, got {type(params).__name__}"] return [f"parameters must be an object, got {type(params).__name__}"]
schema = self.parameters or {} schema = self.parameters or {}
if schema.get("type", "object") != "object": if schema.get("type", "object") != "object":
+5 -4
View File
@@ -1,14 +1,15 @@
"""Controlled runner for installed CLI Apps.""" """Controlled runner for installed CLI Apps."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
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, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext from nanobot.agent.tools.context import RequestContext, ToolContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
ArraySchema, ArraySchema,
BooleanSchema, BooleanSchema,
@@ -66,11 +67,11 @@ class CliAppsTool(Tool):
return CliAppsToolConfig return CliAppsToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.cli_apps.enable return ctx.config.cli_apps.enable
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
cfg = ctx.config.cli_apps cfg = ctx.config.cli_apps
return cls( return cls(
workspace=Path(ctx.workspace), workspace=Path(ctx.workspace),
+21 -11
View File
@@ -8,6 +8,16 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStates
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.config.schema import ProviderConfig, ToolsConfig
from nanobot.cron.service import CronService
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import WorkspaceSandboxStatus
from nanobot.session.manager import SessionManager
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar( _CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
@@ -67,16 +77,16 @@ def current_request_session_key() -> str | None:
@dataclass @dataclass
class ToolContext: class ToolContext:
config: Any config: ToolsConfig
workspace: str workspace: str
bus: Any | None = None bus: MessageBus | None = None
subagent_manager: Any | None = None subagent_manager: SubagentManager | None = None
cron_service: Any | None = None cron_service: CronService | None = None
exec_session_manager: Any | None = None exec_session_manager: ExecSessionManager | None = None
sessions: Any | None = None sessions: SessionManager | None = None
file_state_store: Any = field(default=None) file_state_store: FileStates | None = None
provider_snapshot_loader: Callable[[], Any] | None = None provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None
image_generation_provider_configs: dict[str, Any] | None = None image_generation_provider_configs: dict[str, ProviderConfig] | None = None
timezone: str = "UTC" timezone: str = "UTC"
workspace_sandbox: Any | None = None workspace_sandbox: WorkspaceSandboxStatus | None = None
runtime_events: Any | None = None runtime_events: RuntimeEventBus | None = None
+13 -8
View File
@@ -1,13 +1,15 @@
"""Cron tool for scheduling reminders and tasks.""" """Cron tool for scheduling reminders and tasks."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar from contextvars import ContextVar, Token
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, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
IntegerSchema, IntegerSchema,
StringSchema, StringSchema,
@@ -60,12 +62,15 @@ class CronTool(Tool):
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False) self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.cron_service is not None return ctx.cron_service is not None
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone) cron_service = ctx.cron_service
if cron_service is None:
raise RuntimeError("CronTool requires an initialized cron service")
return cls(cron_service=cron_service, default_timezone=ctx.timezone)
@staticmethod @staticmethod
def _request_route() -> tuple[str, str, str, dict[str, Any]]: def _request_route() -> tuple[str, str, str, dict[str, Any]]:
@@ -79,11 +84,11 @@ class CronTool(Tool):
) )
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {}) return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
def set_cron_context(self, active: bool): def set_cron_context(self, active: bool) -> Token[bool]:
"""Mark whether the tool is executing inside a cron job callback.""" """Mark whether the tool is executing inside a cron job callback."""
return self._in_cron_context.set(active) return self._in_cron_context.set(active)
def reset_cron_context(self, token) -> None: def reset_cron_context(self, token: Token[bool]) -> None:
"""Restore previous cron context.""" """Restore previous cron context."""
self._in_cron_context.reset(token) self._in_cron_context.reset(token)
@@ -257,7 +262,7 @@ class CronTool(Tool):
jobs = self._cron.list_jobs() jobs = self._cron.list_jobs()
if not jobs: if not jobs:
return "No scheduled jobs." return "No scheduled jobs."
lines = [] lines: list[str] = []
for j in jobs: for j in jobs:
timing = self._format_timing(j.schedule) timing = self._format_timing(j.schedule)
parts = [f"- {j.name} (id: {j.id}, {timing})"] parts = [f"- {j.name} (id: {j.id}, {timing})"]
+114 -49
View File
@@ -5,12 +5,13 @@ from __future__ import annotations
import asyncio import asyncio
import time import time
import uuid import uuid
from collections import deque
from contextlib import suppress 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, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
@@ -51,6 +52,66 @@ class ExecSessionInfo:
owner_session_key: str | None = None owner_session_key: str | None = None
class _BoundedOutputBuffer:
"""Keep the first and most recent characters within a fixed budget."""
def __init__(self, max_chars: int) -> None:
self.max_chars = max_chars
self._content = ""
self._tail: deque[str] = deque()
self._tail_chars = 0
self._total_chars = 0
self._truncated = False
@property
def has_output(self) -> bool:
return self._total_chars > 0
@property
def retained_chars(self) -> int:
return len(self._content) + self._tail_chars
def append(self, text: str) -> None:
if not text:
return
self._total_chars += len(text)
if not self._truncated:
combined = self._content + text
if len(combined) <= self.max_chars:
self._content = combined
return
head_chars = self.max_chars // 2
tail_chars = self.max_chars - head_chars
self._content = combined[:head_chars]
self._tail.append(combined[-tail_chars:])
self._tail_chars = tail_chars
self._truncated = True
return
tail_chars = self.max_chars - len(self._content)
self._tail.append(text)
self._tail_chars += len(text)
while self._tail_chars > tail_chars:
excess = self._tail_chars - tail_chars
first = self._tail[0]
if len(first) <= excess:
self._tail.popleft()
self._tail_chars -= len(first)
else:
self._tail[0] = first[excess:]
self._tail_chars -= excess
def drain(self) -> tuple[str, int]:
output = self._content + "".join(self._tail)
truncated_chars = self._total_chars - len(output)
self._content = ""
self._tail.clear()
self._tail_chars = 0
self._total_chars = 0
self._truncated = False
return output, truncated_chars
class _ExecSession: class _ExecSession:
def __init__( def __init__(
self, self,
@@ -73,30 +134,27 @@ class _ExecSession:
# timeout None/0 means no limit; an infinite deadline is never reached. # timeout None/0 means no limit; an infinite deadline is never reached.
self.deadline = time.monotonic() + timeout if timeout else float("inf") self.deadline = time.monotonic() + timeout if timeout else float("inf")
self.last_access = time.monotonic() self.last_access = time.monotonic()
self._chunks: list[str] = [] self._stdout = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
self._stderr = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._timed_out = False self._timed_out = False
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, "")) self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, self._stdout))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n")) self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, self._stderr))
async def _read_stream( async def _read_stream(
self, self,
stream: asyncio.StreamReader | None, stream: asyncio.StreamReader | None,
prefix: str, buffer: _BoundedOutputBuffer,
) -> None: ) -> None:
if stream is None: if stream is None:
return return
first = True
while True: while True:
chunk = await stream.read(4096) chunk = await stream.read(4096)
if not chunk: if not chunk:
break break
text = chunk.decode("utf-8", errors="replace") text = chunk.decode("utf-8", errors="replace")
if prefix and first:
text = prefix + text
first = False
async with self._lock: async with self._lock:
self._chunks.append(text) buffer.append(text)
async def write(self, chars: str) -> str | None: async def write(self, chars: str) -> str | None:
if self.process.returncode is not None: if self.process.returncode is not None:
@@ -151,16 +209,20 @@ class _ExecSession:
timeout=2.0, timeout=2.0,
) )
# Safety-net reap after normal exit. # Safety-net reap after normal exit.
from nanobot.agent.tools.shell import _reap_pid from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
_reap_pid(self.process.pid) _reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
elif yield_time_ms > 0: elif yield_time_ms > 0:
await self._wait_for_buffered_output() await self._wait_for_buffered_output()
async with self._lock: async with self._lock:
output = "".join(self._chunks) stdout, stdout_truncated = self._stdout.drain()
self._chunks.clear() stderr, stderr_truncated = self._stderr.drain()
output, truncated = _truncate_output(output, max_output_chars) output_parts = [stdout] if stdout else []
if stderr:
output_parts.append(f"STDERR:\n{stderr}")
output = "\n".join(output_parts)
output, response_truncated = _truncate_output(output, max_output_chars)
return _SessionPoll( return _SessionPoll(
output=output, output=output,
done=self.process.returncode is not None, done=self.process.returncode is not None,
@@ -169,7 +231,7 @@ class _ExecSession:
timed_out=self._timed_out, timed_out=self._timed_out,
terminated=terminated, terminated=terminated,
stdin_closed=stdin_closed, stdin_closed=stdin_closed,
truncated_chars=truncated, truncated_chars=stdout_truncated + stderr_truncated + response_truncated,
) )
async def kill(self) -> None: async def kill(self) -> None:
@@ -177,9 +239,9 @@ class _ExecSession:
try: try:
if self._process_tree: if self._process_tree:
await ExecTool._kill_process_tree(self.process) await ExecTool._kill_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
else: else:
await ExecTool._kill_process(self.process) await ExecTool._kill_process(self.process) # pyright: ignore[reportPrivateUsage]
finally: finally:
with suppress(asyncio.TimeoutError): with suppress(asyncio.TimeoutError):
await asyncio.wait_for( await asyncio.wait_for(
@@ -195,7 +257,7 @@ class _ExecSession:
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
while time.monotonic() < deadline: while time.monotonic() < deadline:
async with self._lock: async with self._lock:
if self._chunks: if self._stdout.has_output or self._stderr.has_output:
return return
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
@@ -311,13 +373,13 @@ class ExecSessionManager:
"""Terminate and remove all active sessions during shutdown.""" """Terminate and remove all active sessions during shutdown."""
async with self._lock: async with self._lock:
self._closed = True self._closed = True
sessions = list(self._sessions.values()) sessions: list[_ExecSession] = list(self._sessions.values())
self._sessions.clear() self._sessions.clear()
results = await asyncio.gather( results: list[None | BaseException] = list(await asyncio.gather(
*(session.kill() for session in sessions), *(session.kill() for session in sessions),
return_exceptions=True, return_exceptions=True,
) ))
failures = [ failures: list[tuple[_ExecSession, BaseException]] = [
(session, result) (session, result)
for session, result in zip(sessions, results, strict=True) for session, result in zip(sessions, results, strict=True)
if isinstance(result, BaseException) if isinstance(result, BaseException)
@@ -337,15 +399,15 @@ class ExecSessionManager:
async def terminate_by_owner(self, owner_session_key: str) -> int: async def terminate_by_owner(self, owner_session_key: str) -> int:
"""Terminate all sessions owned by owner_session_key. Returns count.""" """Terminate all sessions owned by owner_session_key. Returns count."""
async with self._lock: async with self._lock:
victims = [] victims: list[_ExecSession] = []
for sid, s in list(self._sessions.items()): for sid, s in list(self._sessions.items()):
if s.owner_session_key == owner_session_key: if s.owner_session_key == owner_session_key:
victims.append(self._sessions.pop(sid)) victims.append(self._sessions.pop(sid))
results = await asyncio.gather( results: list[None | BaseException] = list(await asyncio.gather(
*(s.kill() for s in victims), *(s.kill() for s in victims),
return_exceptions=True, return_exceptions=True,
) ))
failures = [ failures: list[tuple[_ExecSession, BaseException]] = [
(session, result) (session, result)
for session, result in zip(victims, results, strict=True) for session, result in zip(victims, results, strict=True)
if isinstance(result, BaseException) if isinstance(result, BaseException)
@@ -384,7 +446,7 @@ class ExecSessionManager:
) -> asyncio.subprocess.Process: ) -> asyncio.subprocess.Process:
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
return await ExecTool._spawn( return await ExecTool._spawn( # pyright: ignore[reportPrivateUsage]
command, cwd, env, shell_program, login, command, cwd, env, shell_program, login,
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
process_tree=True, process_tree=True,
@@ -403,20 +465,16 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]: def _truncate_output(output: str, max_output_chars: int) -> 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 head_chars = max_output_chars // 2
tail_chars = max_output_chars - head_chars
omitted = len(output) - max_output_chars omitted = len(output) - max_output_chars
return ( return output[:head_chars] + output[-tail_chars:], omitted
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
def format_session_poll(session_id: str, poll: _SessionPoll) -> str: def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts = [poll.output] if poll.output else [] parts = [poll.output] if poll.output else []
if poll.truncated_chars: if poll.truncated_chars:
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)") parts.append(f"({poll.truncated_chars:,} chars truncated from output)")
if poll.timed_out: if poll.timed_out:
parts.append("Error: Command timed out; session was terminated.") parts.append("Error: Command timed out; session was terminated.")
if poll.terminated and not poll.timed_out: if poll.terminated and not poll.timed_out:
@@ -489,7 +547,7 @@ class WriteStdinTool(Tool):
return ExecToolConfig return ExecToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable return ctx.config.exec.enable
def __init__( def __init__(
@@ -500,8 +558,8 @@ class WriteStdinTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None)) return cls(manager=ctx.exec_session_manager)
@property @property
def exclusive(self) -> bool: def exclusive(self) -> bool:
@@ -522,7 +580,7 @@ class WriteStdinTool(Tool):
"Do not use this to start new commands; start them with exec." "Do not use this to start new commands; start them with exec."
) )
async def execute( async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
self, self,
session_id: str, session_id: str,
chars: str | None = None, chars: str | None = None,
@@ -587,7 +645,9 @@ class WriteStdinTool(Tool):
max_output_chars: int, max_output_chars: int,
) -> str: ) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000) deadline = time.monotonic() + (wait_timeout_ms / 1000)
aggregate: list[str] = [] aggregate = _BoundedOutputBuffer(max_output_chars)
upstream_truncated = 0
search_overlap = ""
first = True first = True
poll: _SessionPoll | None = None poll: _SessionPoll | None = None
@@ -604,15 +664,20 @@ class WriteStdinTool(Tool):
owner_session_key=current_request_session_key(), owner_session_key=current_request_session_key(),
) )
first = False first = False
upstream_truncated += poll.truncated_chars
if poll.output: if poll.output:
aggregate.append(poll.output) aggregate.append(poll.output)
joined = "".join(aggregate) searchable = search_overlap + poll.output
if wait_for in joined: if wait_for in searchable:
poll.output = joined poll.output, aggregate_truncated = aggregate.drain()
poll.truncated_chars = upstream_truncated + aggregate_truncated
result = format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
return ToolResult.error(result) if poll.timed_out else result return ToolResult.error(result) if poll.timed_out else result
overlap_chars = max(0, len(wait_for) - 1)
search_overlap = searchable[-overlap_chars:] if overlap_chars else ""
if poll.done or remaining_ms <= 0: if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate) poll.output, aggregate_truncated = aggregate.drain()
poll.truncated_chars = upstream_truncated + aggregate_truncated
result = format_session_poll(session_id, poll) result = format_session_poll(session_id, poll)
if wait_for not in poll.output: if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}" result += f"\nWait target not observed: {wait_for!r}"
@@ -633,7 +698,7 @@ class ListExecSessionsTool(Tool):
return ExecToolConfig return ExecToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable return ctx.config.exec.enable
def __init__( def __init__(
@@ -644,8 +709,8 @@ class ListExecSessionsTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None)) return cls(manager=ctx.exec_session_manager)
@property @property
def name(self) -> str: def name(self) -> str:
@@ -671,7 +736,7 @@ class ListExecSessionsTool(Tool):
) )
if not sessions: if not sessions:
return "No active exec sessions." return "No active exec sessions."
lines = [] lines: list[str] = []
for info in sessions: for info in sessions:
command = " ".join(info.command.split()) command = " ".join(info.command.split())
if len(command) > 120: if len(command) > 120:
+5 -1
View File
@@ -125,6 +125,10 @@ class FileStates:
"""Return the raw ReadState entry for a path, or None.""" """Return the raw ReadState entry for a path, or None."""
return self._state.get(str(Path(path).resolve())) return self._state.get(str(Path(path).resolve()))
def raw_state(self) -> dict[str, ReadState]:
"""Return the mutable backing map for legacy compatibility."""
return self._state
def clear(self) -> None: def clear(self) -> None:
"""Clear all tracked state (useful for testing).""" """Clear all tracked state (useful for testing)."""
self._state.clear() self._state.clear()
@@ -201,5 +205,5 @@ def clear() -> None:
# so existing imports keep working. # so existing imports keep working.
def __getattr__(name: str): def __getattr__(name: str):
if name == "_state": if name == "_state":
return _default._state return _default.raw_state()
raise AttributeError(name) raise AttributeError(name)
+7 -3
View File
@@ -1,5 +1,7 @@
"""File system tools: read, write, edit, list.""" """File system tools: read, write, edit, list."""
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
import difflib import difflib
import mimetypes import mimetypes
import os import os
@@ -8,6 +10,7 @@ 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, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
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 (
@@ -37,7 +40,7 @@ class _FsTool(Tool):
return FileToolsConfig return FileToolsConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.file.enable return ctx.config.file.enable
def __init__( def __init__(
@@ -77,7 +80,7 @@ class _FsTool(Tool):
self._fallback_file_states = FileStates() self._fallback_file_states = FileStates()
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.skills import BUILTIN_SKILLS_DIR
agent_workspace = Path(ctx.workspace) agent_workspace = Path(ctx.workspace)
@@ -408,7 +411,8 @@ class ReadFileTool(_FsTool):
result = "\n".join(numbered) result = "\n".join(numbered)
if len(result) > self._MAX_CHARS: if len(result) > self._MAX_CHARS:
trimmed, chars = [], 0 trimmed: list[str] = []
chars = 0
for line in numbered: for line in numbered:
chars += len(line) + 1 chars += len(line) + 1
if chars > self._MAX_CHARS: if chars > self._MAX_CHARS:
+25 -19
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
@@ -23,6 +23,7 @@ from nanobot.bus.events import (
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD, RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
InboundMessage, InboundMessage,
) )
from nanobot.bus.queue import MessageBus
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.providers.image_generation import ( from nanobot.providers.image_generation import (
@@ -41,6 +42,7 @@ from nanobot.utils.artifacts import (
from nanobot.utils.helpers import detect_image_mime from nanobot.utils.helpers import detect_image_mime
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.context import ToolContext
from nanobot.config.schema import ProviderConfig from nanobot.config.schema import ProviderConfig
@@ -89,11 +91,11 @@ class ImageGenerationTool(Tool):
return ImageGenerationToolConfig return ImageGenerationToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.image_generation.enabled return ctx.config.image_generation.enabled
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls( return cls(
workspace=ctx.workspace, workspace=ctx.workspace,
config=ctx.config.image_generation, config=ctx.config.image_generation,
@@ -134,12 +136,14 @@ class ImageGenerationTool(Tool):
cls = get_image_gen_provider(self.config.provider) cls = get_image_gen_provider(self.config.provider)
if cls is None: if cls is None:
return None return None
kwargs = { kwargs: dict[str, Any] = {
"api_key": provider.api_key if provider else None, "api_key": provider.api_key if provider and isinstance(provider.api_key, str) else None,
"api_base": provider.api_base if provider else None, "api_base": provider.api_base if provider and isinstance(provider.api_base, str) else None,
"extra_headers": provider.extra_headers if provider else None, "extra_headers": provider.extra_headers
"extra_body": provider.extra_body if provider else None, if provider and isinstance(provider.extra_headers, dict) else None,
"proxy": provider.proxy if provider else None, "extra_body": provider.extra_body
if provider and isinstance(provider.extra_body, dict) else None,
"proxy": provider.proxy if provider and isinstance(provider.proxy, str) else None,
} }
return cls(**kwargs) return cls(**kwargs)
@@ -172,7 +176,7 @@ class ImageGenerationTool(Tool):
return [] return []
return [self._resolve_reference_image(value) for value in values if value] return [self._resolve_reference_image(value) for value in values if value]
async def execute( async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
self, self,
prompt: str, prompt: str,
reference_images: list[str] | None = None, reference_images: list[str] | None = None,
@@ -238,7 +242,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
} }
next_tool = ( next_tool = (
ImageGenerationTool( ImageGenerationTool( # pyright: ignore[reportAbstractUsage]
workspace=state.workspace, workspace=state.workspace,
config=tool_config, config=tool_config,
provider_configs=provider_configs, provider_configs=provider_configs,
@@ -271,7 +275,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
async def request_image_generation_reload( async def request_image_generation_reload(
bus: Any, bus: MessageBus,
*, *,
timeout: float = 5.0, timeout: float = 5.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -298,11 +302,13 @@ async def request_image_generation_reload(
"message": "Image generation hot reload timed out.", "message": "Image generation hot reload timed out.",
"requires_restart": True, "requires_restart": True,
} }
return result if isinstance(result, dict) else { if not isinstance(cast(object, result), dict):
"ok": False, return {
"message": "Image generation hot reload returned an unexpected response.", "ok": False,
"requires_restart": True, "message": "Image generation hot reload returned an unexpected response.",
} "requires_restart": True,
}
return result
async def handle_runtime_control( async def handle_runtime_control(
@@ -311,7 +317,7 @@ async def handle_runtime_control(
registry: ToolRegistry, registry: ToolRegistry,
) -> bool: ) -> bool:
"""Handle an in-process image generation reload request.""" """Handle an in-process image generation reload request."""
metadata = msg.metadata if isinstance(msg.metadata, dict) else {} metadata = msg.metadata
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD: if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
return False return False
@@ -327,5 +333,5 @@ async def handle_runtime_control(
"error": str(exc), "error": str(exc),
} }
if isinstance(ack, asyncio.Future) and not ack.done(): if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result) cast(asyncio.Future[Any], ack).set_result(result)
return True return True
+9 -3
View File
@@ -1,16 +1,22 @@
"""Tool discovery and registration via package scanning.""" """Tool discovery and registration via package scanning."""
# pyright: reportIncompatibleVariableOverride=false
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import pkgutil import pkgutil
from importlib.metadata import entry_points from importlib.metadata import entry_points
from typing import Any 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, ToolResult
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
if TYPE_CHECKING:
from nanobot.agent.tools.context import RequestContext, ToolContext
_SKIP_MODULES = frozenset({ _SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config", "base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state", "file_state", "sandbox", "mcp", "__init__", "runtime_state",
@@ -83,7 +89,7 @@ class ToolLoader:
self._plugins = plugins self._plugins = plugins
return plugins return plugins
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]: def load(self, ctx: ToolContext, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
registered: list[str] = [] registered: list[str] = []
builtin_names: set[str] = set() builtin_names: set[str] = set()
sources = [(self.discover(), False), (self._discover_plugins().values(), True)] sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
@@ -157,7 +163,7 @@ class _LegacyErrorPrefixTool(Tool):
def config_key(self) -> str: def config_key(self) -> str:
return getattr(self._wrapped, "config_key", "") return getattr(self._wrapped, "config_key", "")
def set_context(self, ctx: Any) -> None: def set_context(self, ctx: RequestContext) -> None:
set_context = getattr(self._wrapped, "set_context", None) set_context = getattr(self._wrapped, "set_context", None)
if callable(set_context): if callable(set_context):
set_context(ctx) set_context(ctx)
+19 -15
View File
@@ -1,5 +1,7 @@
"""Sustained-goal tools with explicit user opt-in at the execution boundary.""" """Sustained-goal tools with explicit user opt-in at the execution boundary."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
from copy import deepcopy from copy import deepcopy
@@ -11,7 +13,7 @@ from nanobot.agent.goal_permission import (
revoke_goal_mutation_permission, revoke_goal_mutation_permission,
) )
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext, current_request_context from nanobot.agent.tools.context import RequestContext, ToolContext, current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
@@ -132,23 +134,24 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
def __init__( def __init__(
self, self,
sessions: Any, sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None, runtime_events: RuntimeEventBus | None = None,
) -> None: ) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events) _GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
sess = getattr(ctx, "sessions", None) sess = ctx.sessions
assert sess is not None if sess is None:
raise RuntimeError("CreateGoalTool requires an initialized session manager")
return cls( return cls(
sessions=sess, sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None), runtime_events=ctx.runtime_events,
) )
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return getattr(ctx, "sessions", None) is not None return ctx.sessions is not None
@property @property
def name(self) -> str: def name(self) -> str:
@@ -262,23 +265,24 @@ class UpdateGoalTool(Tool, _GoalToolsMixin):
def __init__( def __init__(
self, self,
sessions: Any, sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None, runtime_events: RuntimeEventBus | None = None,
) -> None: ) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events) _GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
sess = getattr(ctx, "sessions", None) sess = ctx.sessions
assert sess is not None if sess is None:
raise RuntimeError("UpdateGoalTool requires an initialized session manager")
return cls( return cls(
sessions=sess, sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None), runtime_events=ctx.runtime_events,
) )
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return getattr(ctx, "sessions", None) is not None return ctx.sessions is not None
@property @property
def name(self) -> str: def name(self) -> str:
+99 -46
View File
@@ -7,9 +7,9 @@ import os
import re import re
import shutil import shutil
import urllib.parse import urllib.parse
from collections.abc import Awaitable, Callable from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress from contextlib import AsyncExitStack, suppress
from typing import Any, Mapping, Protocol from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from weakref import WeakKeyDictionary from weakref import WeakKeyDictionary
import httpx import httpx
@@ -23,6 +23,7 @@ from nanobot.bus.events import (
RUNTIME_CONTROL_MCP_RELOAD, RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage, InboundMessage,
) )
from nanobot.bus.queue import MessageBus
from nanobot.security.network import ( from nanobot.security.network import (
PinnedDNSAsyncTransport, PinnedDNSAsyncTransport,
env_proxy_applies_to_url, env_proxy_applies_to_url,
@@ -32,6 +33,13 @@ from nanobot.security.network import (
) )
from nanobot.utils.cancellation import task_is_cancelling from nanobot.utils.cancellation import task_is_cancelling
if TYPE_CHECKING:
from mcp import ClientSession
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry. # Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network # These typically happen when an MCP server restarts or a network
# connection is interrupted between calls. # connection is interrupted between calls.
@@ -92,7 +100,7 @@ def _mcp_jsonrpc_payload(message: Any) -> Any:
def _payload_value(payload: Any, key: str) -> Any: def _payload_value(payload: Any, key: str) -> Any:
if isinstance(payload, Mapping): if isinstance(payload, Mapping):
return payload.get(key) return cast(Mapping[str, Any], payload).get(key)
return getattr(payload, key, None) return getattr(payload, key, None)
@@ -106,7 +114,7 @@ class _MalformedProgressNotificationFilter:
def __init__(self, read_stream: Any, server_name: str) -> None: def __init__(self, read_stream: Any, server_name: str) -> None:
self._read_stream = read_stream self._read_stream = read_stream
self._server_name = server_name self._server_name = server_name
self._iterator: Any | None = None self._iterator: AsyncIterator[Any] | None = None
async def __aenter__(self) -> "_MalformedProgressNotificationFilter": async def __aenter__(self) -> "_MalformedProgressNotificationFilter":
await self._read_stream.__aenter__() await self._read_stream.__aenter__()
@@ -120,11 +128,13 @@ class _MalformedProgressNotificationFilter:
return self return self
async def __anext__(self) -> Any: async def __anext__(self) -> Any:
if self._iterator is None: iterator = self._iterator
self._iterator = self._read_stream.__aiter__() if iterator is None:
iterator = self._read_stream.__aiter__()
self._iterator = iterator
while True: while True:
message = await self._iterator.__anext__() message = await anext(iterator)
if _is_malformed_mcp_progress_notification(message): if _is_malformed_mcp_progress_notification(message):
logger.debug( logger.debug(
"MCP server '{}': dropped progress notification without progressToken", "MCP server '{}': dropped progress notification without progressToken",
@@ -241,8 +251,8 @@ def _redact_url(url: str) -> str:
return "<redacted-url>" return "<redacted-url>"
def _pinned_transport_kwargs() -> dict[str, object]: def _pinned_transport_kwargs() -> dict[str, Any]:
kwargs: dict[str, object] = {"transport": PinnedDNSAsyncTransport()} kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
mounts = httpx_env_proxy_mounts() mounts = httpx_env_proxy_mounts()
if mounts: if mounts:
kwargs["mounts"] = mounts kwargs["mounts"] = mounts
@@ -302,13 +312,14 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None
non_null: list[dict[str, Any]] = [] non_null: list[dict[str, Any]] = []
saw_null = False saw_null = False
for option in options: for option in cast(list[object], options):
if not isinstance(option, dict): if not isinstance(option, dict):
return None return None
if option.get("type") == "null": option_schema = cast(dict[str, Any], option)
if option_schema.get("type") == "null":
saw_null = True saw_null = True
continue continue
non_null.append(option) non_null.append(option_schema)
if saw_null and len(non_null) == 1: if saw_null and len(non_null) == 1:
return non_null[0], True return non_null[0], True
@@ -330,9 +341,9 @@ def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any:
for raw_part in pointer[1:].split("/"): for raw_part in pointer[1:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~") part = raw_part.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict): if isinstance(current, dict):
current = current[part] current = cast(dict[str, Any], current)[part]
elif isinstance(current, list): elif isinstance(current, list):
current = current[int(part)] current = cast(list[Any], current)[int(part)]
else: else:
raise KeyError(part) raise KeyError(part)
return current return current
@@ -345,14 +356,15 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
def rewrite(value: Any) -> Any: def rewrite(value: Any) -> Any:
if isinstance(value, list): if isinstance(value, list):
return [rewrite(item) for item in value] return [rewrite(item) for item in cast(list[Any], value)]
if not isinstance(value, dict): if not isinstance(value, dict):
return value return value
rewritten = dict(value) rewritten = dict(cast(dict[str, Any], value))
ref = rewritten.get("$ref") raw_ref = rewritten.get("$ref")
ref = raw_ref if isinstance(raw_ref, str) else None
is_rewritable_ref = False is_rewritable_ref = False
if isinstance(ref, str) and not ref.startswith("#/$defs/"): if ref is not None and not ref.startswith("#/$defs/"):
try: try:
pointer = urllib.parse.unquote(ref[1:], errors="strict") pointer = urllib.parse.unquote(ref[1:], errors="strict")
except (UnicodeDecodeError, ValueError): except (UnicodeDecodeError, ValueError):
@@ -362,6 +374,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
not pointer or pointer.startswith("/") not pointer or pointer.startswith("/")
) )
if is_rewritable_ref: if is_rewritable_ref:
assert ref is not None
name = rewritten_refs.get(ref) name = rewritten_refs.get(ref)
if name is None: if name is None:
try: try:
@@ -369,7 +382,6 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError): except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError):
logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref) logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref)
else: else:
assert isinstance(ref, str)
name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}" name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}"
existing_defs = schema.get("$defs") existing_defs = schema.get("$defs")
while isinstance(existing_defs, dict) and name in existing_defs: while isinstance(existing_defs, dict) and name in existing_defs:
@@ -383,7 +395,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
return {key: rewrite(item) for key, item in rewritten.items()} return {key: rewrite(item) for key, item in rewritten.items()}
result = rewrite(schema) result = cast(dict[str, Any], rewrite(schema))
if generated_defs: if generated_defs:
existing_defs = result.get("$defs") existing_defs = result.get("$defs")
result["$defs"] = { result["$defs"] = {
@@ -398,8 +410,9 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized = dict(schema) normalized = dict(schema)
raw_type = normalized.get("type") raw_type = normalized.get("type")
if isinstance(raw_type, list): if isinstance(raw_type, list):
non_null = [item for item in raw_type if item != "null"] type_values = cast(list[Any], raw_type)
if "null" in raw_type and len(non_null) == 1: non_null = [item for item in type_values if item != "null"]
if "null" in type_values and len(non_null) == 1:
normalized["type"] = non_null[0] normalized["type"] = non_null[0]
normalized["nullable"] = True normalized["nullable"] = True
@@ -413,19 +426,28 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized["nullable"] = True normalized["nullable"] = True
break break
if isinstance(normalized.get("properties"), dict): properties = normalized.get("properties")
if isinstance(properties, dict):
property_schemas = cast(dict[str, Any], properties)
normalized["properties"] = { normalized["properties"] = {
name: _normalize_nullable_schema(prop) if isinstance(prop, dict) else prop name: (
for name, prop in normalized["properties"].items() _normalize_nullable_schema(cast(dict[str, Any], prop))
if isinstance(prop, dict)
else prop
)
for name, prop in property_schemas.items()
} }
if isinstance(normalized.get("items"), dict): items = normalized.get("items")
normalized["items"] = _normalize_nullable_schema(normalized["items"]) if isinstance(items, dict):
if isinstance(normalized.get("$defs"), dict): normalized["items"] = _normalize_nullable_schema(cast(dict[str, Any], items))
definitions = normalized.get("$defs")
if isinstance(definitions, dict):
definition_schemas = cast(dict[str, Any], definitions)
normalized["$defs"] = { normalized["$defs"] = {
name: _normalize_nullable_schema(definition) name: _normalize_nullable_schema(cast(dict[str, Any], definition))
if isinstance(definition, dict) if isinstance(definition, dict)
else definition else definition
for name, definition in normalized["$defs"].items() for name, definition in definition_schemas.items()
} }
if normalized.get("type") == "object": if normalized.get("type") == "object":
@@ -438,15 +460,19 @@ def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
"""Normalize MCP JSON Schema patterns for tool definitions.""" """Normalize MCP JSON Schema patterns for tool definitions."""
if not isinstance(schema, dict): if not isinstance(schema, dict):
return {"type": "object", "properties": {}} return {"type": "object", "properties": {}}
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema)) schema_mapping = cast(dict[str, Any], schema)
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema_mapping))
class _MCPWrapperBase(Tool): class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session.""" """Common reconnect handling for wrappers bound to one MCP server session."""
_plugin_discoverable = False _plugin_discoverable = False
_session: "ClientSession"
_server_name: str
_name: str
def _set_mcp_connection(self, session: Any, server_name: str) -> None: def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None:
self._session = session self._session = session
self._server_name = server_name self._server_name = server_name
self._reconnect: _ReconnectCallback | None = None self._reconnect: _ReconnectCallback | None = None
@@ -500,9 +526,10 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
if embedded_cls is not None and isinstance(block, embedded_cls): if embedded_cls is not None and isinstance(block, embedded_cls):
resource = getattr(block, "resource", None) resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls): if blob_cls is not None and isinstance(resource, blob_cls):
mime = getattr(resource, "mimeType", None) or "" blob_resource = cast(Any, resource)
mime = getattr(blob_resource, "mimeType", None) or ""
if isinstance(mime, str) and mime.startswith("image/"): if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{resource.blob}" return f"data:{mime};base64,{blob_resource.blob}"
return None return None
@@ -533,7 +560,13 @@ class MCPToolWrapper(_MCPWrapperBase):
_plugin_discoverable = False _plugin_discoverable = False
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30): def __init__(
self,
session: "ClientSession",
server_name: str,
tool_def: "MCPToolDefinition",
tool_timeout: int = 30,
):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
self._original_name = tool_def.name self._original_name = tool_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}") self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
@@ -689,7 +722,13 @@ class MCPResourceWrapper(_MCPWrapperBase):
_plugin_discoverable = False _plugin_discoverable = False
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30): def __init__(
self,
session: "ClientSession",
server_name: str,
resource_def: "Resource",
resource_timeout: int = 30,
):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
self._uri = resource_def.uri self._uri = resource_def.uri
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}") self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
@@ -775,7 +814,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
for block in result.contents: for block in result.contents:
if isinstance(block, types.TextResourceContents): if isinstance(block, types.TextResourceContents):
parts.append(block.text) parts.append(block.text)
elif isinstance(block, types.BlobResourceContents): elif isinstance(cast(object, block), types.BlobResourceContents):
parts.append(f"[Binary resource: {len(block.blob)} bytes]") parts.append(f"[Binary resource: {len(block.blob)} bytes]")
else: else:
parts.append(str(block)) parts.append(str(block))
@@ -787,7 +826,13 @@ class MCPPromptWrapper(_MCPWrapperBase):
_plugin_discoverable = False _plugin_discoverable = False
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30): def __init__(
self,
session: "ClientSession",
server_name: str,
prompt_def: "Prompt",
prompt_timeout: int = 30,
):
self._set_mcp_connection(session, server_name) self._set_mcp_connection(session, server_name)
self._prompt_name = prompt_def.name self._prompt_name = prompt_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}") self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
@@ -916,7 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers( async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
) -> dict[str, MCPConnection]: ) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts. """Connect to configured MCP servers and register their tools, resources, prompts.
@@ -929,7 +974,9 @@ async def connect_mcp_servers(
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
async def open_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]: async def open_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack() server_stack = AsyncExitStack()
await server_stack.__aenter__() await server_stack.__aenter__()
@@ -1148,7 +1195,9 @@ async def connect_mcp_servers(
await server_stack.aclose() await server_stack.aclose()
return name, None return name, None
async def connect_single_server(name: str, cfg) -> tuple[str, MCPConnection | None]: async def connect_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, MCPConnection | None]:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
ready: asyncio.Future[bool] = loop.create_future() ready: asyncio.Future[bool] = loop.create_future()
close_requested = asyncio.Event() close_requested = asyncio.Event()
@@ -1192,7 +1241,7 @@ async def connect_mcp_servers(
except Exception as e: except Exception as e:
logger.exception("MCP server '{}' connection failed: {}", name, e) logger.exception("MCP server '{}' connection failed: {}", name, e)
continue continue
if result is not None and result[1] is not None: if result[1] is not None:
server_stacks[result[0]] = result[1] server_stacks[result[0]] = result[1]
return server_stacks return server_stacks
@@ -1335,7 +1384,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
} }
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]: async def request_mcp_reload(
bus: MessageBus,
*,
timeout: float = 15.0,
) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections.""" """Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future() ack: asyncio.Future[dict[str, Any]] = loop.create_future()
@@ -1359,7 +1412,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.", "message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True, "requires_restart": True,
} }
return result if isinstance(result, dict) else { return result if isinstance(cast(object, result), dict) else {
"ok": False, "ok": False,
"message": "MCP hot reload returned an unexpected response.", "message": "MCP hot reload returned an unexpected response.",
"requires_restart": True, "requires_restart": True,
@@ -1367,7 +1420,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool: async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(msg.metadata, dict) else {} metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL) control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD: if control != RUNTIME_CONTROL_MCP_RELOAD:
return False return False
@@ -1384,7 +1437,7 @@ async def handle_runtime_control(state: Any, msg: InboundMessage, registry: Tool
"error": str(exc), "error": str(exc),
} }
if isinstance(ack, asyncio.Future) and not ack.done(): if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result) cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
return True return True
+23 -13
View File
@@ -1,13 +1,15 @@
"""Message tool for sending messages to users.""" """Message tool for sending messages to users."""
from contextvars import ContextVar # pyright: reportIncompatibleMethodOverride=false
from contextvars import ContextVar, Token
from pathlib import Path from pathlib import Path
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable, cast
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, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context from nanobot.agent.tools.context import ToolContext, current_request_context
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
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -73,7 +75,7 @@ class MessageTool(Tool):
) )
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
send_callback = ctx.bus.publish_outbound if ctx.bus else None send_callback = ctx.bus.publish_outbound if ctx.bus else None
return cls( return cls(
send_callback=send_callback, send_callback=send_callback,
@@ -89,11 +91,11 @@ class MessageTool(Tool):
"""Reset per-turn send tracking.""" """Reset per-turn send tracking."""
self._sent_in_turn = False self._sent_in_turn = False
def set_suppress_delivery(self, active: bool): def set_suppress_delivery(self, active: bool) -> Token[bool]:
"""Acknowledge but don't deliver tool sends (heartbeat internal check).""" """Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active) return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token) -> None: def reset_suppress_delivery(self, token: Token[bool]) -> None:
"""Restore previous delivery-suppression state.""" """Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token) self._suppress_delivery_var.reset(token)
@@ -148,19 +150,23 @@ class MessageTool(Tool):
chat_id: str | None = None, chat_id: str | None = None,
message_id: str | None = None, message_id: str | None = None,
media: list[str] | None = None, media: list[str] | None = None,
buttons: list[list[str]] | None = None, buttons: Any = None,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
from nanobot.utils.helpers import strip_think from nanobot.utils.helpers import strip_think
content = strip_think(content) content = strip_think(content)
button_rows: list[list[str]] | None = None
if buttons is not None: if buttons is not None:
if not isinstance(buttons, list) or any( raw_buttons = cast(list[Any], buttons) if isinstance(buttons, list) else None
not isinstance(row, list) or any(not isinstance(label, str) for label in row) if raw_buttons is None or any(
for row in buttons not isinstance(row, list)
or any(not isinstance(label, str) for label in cast(list[Any], row))
for row in raw_buttons
): ):
return ToolResult.error("Error: buttons must be a list of list of strings") return ToolResult.error("Error: buttons must be a list of list of strings")
button_rows = cast(list[list[str]], raw_buttons)
request_ctx = current_request_context() request_ctx = current_request_context()
default_channel = ( default_channel = (
request_ctx.channel if request_ctx is not None else self._fallback_channel request_ctx.channel if request_ctx is not None else self._fallback_channel
@@ -228,7 +234,7 @@ class MessageTool(Tool):
chat_id=chat_id, chat_id=chat_id,
content=content, content=content,
media=media or [], media=media or [],
buttons=buttons or [], buttons=button_rows or [],
metadata=metadata, metadata=metadata,
) )
@@ -241,7 +247,11 @@ class MessageTool(Tool):
if channel == default_channel and chat_id == default_chat_id: if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else "" media_info = f" with {len(media)} attachments" if media else ""
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 button_rows)} button(s)"
if button_rows
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 ToolResult.error(f"Error sending message: {str(e)}")
+9 -6
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from nanobot.agent.tools.base import Tool, ToolResult from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, current_request_context from nanobot.agent.tools.context import ContextAware, current_request_context
@@ -77,7 +77,7 @@ class ToolRegistry:
"""Extract a normalized tool name from either OpenAI or flat schemas.""" """Extract a normalized tool name from either OpenAI or flat schemas."""
fn = schema.get("function") fn = schema.get("function")
if isinstance(fn, dict): if isinstance(fn, dict):
name = fn.get("name") name = cast(dict[str, Any], fn).get("name")
if isinstance(name, str): if isinstance(name, str):
return name return name
name = schema.get("name") name = schema.get("name")
@@ -140,7 +140,7 @@ class ToolRegistry:
) )
) )
cast_params = tool.cast_params(params) cast_params = tool.cast_params(cast(dict[str, Any], 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, (
@@ -176,12 +176,15 @@ class ToolRegistry:
@classmethod @classmethod
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any: def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
if not isinstance(params, dict) or set(params) != {"arguments"}: if not isinstance(params, dict):
return params return params
arguments_payload = cast(dict[str, Any], params)
if set(arguments_payload) != {"arguments"}:
return arguments_payload
properties = (tool.parameters or {}).get("properties", {}) properties = (tool.parameters or {}).get("properties", {})
if isinstance(properties, dict) and "arguments" in properties: if isinstance(properties, dict) and "arguments" in properties:
return params return arguments_payload
return cls._coerce_argument_value(params.get("arguments")) return cls._coerce_argument_value(arguments_payload.get("arguments"))
async def execute(self, name: str, params: Any) -> Any: async def execute(self, name: str, params: Any) -> Any:
"""Execute a tool by name with given parameters.""" """Execute a tool by name with given parameters."""
+18 -12
View File
@@ -1,6 +1,15 @@
"""RuntimeState protocol: agent loop state exposed to MyTool.""" """RuntimeState protocol: agent loop state exposed to MyTool."""
from typing import Any, Protocol from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
class RuntimeState(Protocol): class RuntimeState(Protocol):
@@ -25,7 +34,7 @@ class RuntimeState(Protocol):
def tool_names(self) -> list[str]: ... def tool_names(self) -> list[str]: ...
@property @property
def workspace(self) -> str: ... def workspace(self) -> Path: ...
@property @property
def provider_retry_mode(self) -> str: ... def provider_retry_mode(self) -> str: ...
@@ -37,34 +46,31 @@ class RuntimeState(Protocol):
def context_window_tokens(self) -> int: ... def context_window_tokens(self) -> int: ...
@property @property
def web_config(self) -> Any: ... def web_config(self) -> WebToolsConfig: ...
@property @property
def exec_config(self) -> Any: ... def exec_config(self) -> ExecToolConfig: ...
@property @property
def workspace_sandbox(self) -> Any: ... def subagents(self) -> SubagentManager: ...
@property
def subagents(self) -> Any: ...
@property @property
def _runtime_vars(self) -> dict[str, Any]: ... def _runtime_vars(self) -> dict[str, Any]: ...
@property @property
def _last_usage(self) -> Any: ... def _last_usage(self) -> dict[str, int]: ...
def _sync_subagent_runtime_limits(self) -> None: ... def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> Any: ... def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ... def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset( def set_session_model_preset(
self, self,
session_key: str, session_key: str,
name: str, name: str,
) -> Any: ... ) -> LLMRuntime: ...
@property @property
def model_preset(self) -> str | None: ... def model_preset(self) -> str | None: ...
+2
View File
@@ -1,5 +1,7 @@
"""Search tools: file discovery and grep.""" """Search tools: file discovery and grep."""
# pyright: reportIncompatibleMethodOverride=false, reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import fnmatch import fnmatch
+53 -30
View File
@@ -1,10 +1,14 @@
"""MyTool: runtime state inspection and configuration for the agent loop.""" """MyTool: runtime state inspection and configuration for the agent loop."""
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, TypeGuard, cast
from loguru import logger from loguru import logger
@@ -15,6 +19,7 @@ from nanobot.config_base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.context import ToolContext
class MyToolConfig(Base): class MyToolConfig(Base):
@@ -36,7 +41,7 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False return False
def _is_subagent_status(value: Any) -> bool: def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
from nanobot.agent.subagent import SubagentStatus from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus) return isinstance(value, SubagentStatus)
@@ -53,7 +58,7 @@ class MyTool(Tool):
return MyToolConfig return MyToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.my.enable return ctx.config.my.enable
BLOCKED = frozenset({ BLOCKED = frozenset({
@@ -205,7 +210,7 @@ class MyTool(Tool):
def _resolve_path(self, path: str) -> tuple[Any, str | None]: def _resolve_path(self, path: str) -> tuple[Any, str | None]:
parts = path.split(".") parts = path.split(".")
obj = self._runtime_state obj: Any = self._runtime_state
for part in parts: for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"): if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible" return None, f"'{part}' is not accessible"
@@ -215,8 +220,9 @@ class MyTool(Tool):
return None, f"'{part}' is not accessible" return None, f"'{part}' is not accessible"
try: try:
if isinstance(obj, Mapping): if isinstance(obj, Mapping):
if part in obj: mapping = cast(Mapping[str, Any], obj)
obj = obj[part] if part in mapping:
obj = mapping[part]
else: else:
return None, f"'{part}' not found in mapping" return None, f"'{part}' not found in mapping"
else: else:
@@ -259,28 +265,40 @@ class MyTool(Tool):
detail = MyTool._format_status(val, " ") detail = MyTool._format_status(val, " ")
return f"{header}\n task: {val.task_description}\n{detail}" return f"{header}\n task: {val.task_description}\n{detail}"
# SubagentManager: delegate to its _task_statuses dict # SubagentManager: delegate to its _task_statuses dict
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict): task_statuses = getattr(val, "_task_statuses", None)
return MyTool._format_value(val._task_statuses, key) if isinstance(task_statuses, dict):
if isinstance(val, Mapping) and val and _is_subagent_status(next(iter(val.values()))): return MyTool._format_value(task_statuses, key)
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if (
mapping
and _is_subagent_status(next(iter(mapping.values())))
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
prefix = f"{key}: " if key else "" prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"] lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in val.items(): for tid, st in status_mapping.items():
detail = MyTool._format_status(st, " ") detail = MyTool._format_status(st, " ")
lines.append(f" [{tid}] '{st.label}'\n{detail}") lines.append(f" [{tid}] '{st.label}'\n{detail}")
return "\n".join(lines) return "\n".join(lines)
if hasattr(val, "tool_names"): dynamic_value = cast(Any, val)
return f"tools: {len(val.tool_names)} registered — {val.tool_names}" if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {tool_names}"
# Scalar types — repr is fine # Scalar types — repr is fine
if isinstance(val, (str, int, float, bool, type(None))): if isinstance(val, (str, int, float, bool, type(None))):
r = repr(val) r = repr(val)
return f"{key}: {r}" if key else r return f"{key}: {r}" if key else r
# Mapping — small: show content; large: show keys for dot-path navigation # Mapping — small: show content; large: show keys for dot-path navigation
if isinstance(val, Mapping): if isinstance(val, Mapping):
ks = list(val.keys()) value_mapping = cast(Mapping[object, object], val)
ks = list(value_mapping.keys())
if not ks: if not ks:
return f"{key}: {{}}" if key else "{}" return f"{key}: {{}}" if key else "{}"
if len(ks) <= 5: if len(ks) <= 5:
r = repr(val) r = repr(value_mapping)
if len(r) <= 200: if len(r) <= 200:
return f"{key}: {r}" if key else r return f"{key}: {r}" if key else r
preview = ", ".join(str(k) for k in ks[:15]) preview = ", ".join(str(k) for k in ks[:15])
@@ -288,18 +306,20 @@ class MyTool(Tool):
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}" return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
# List/tuple — count for large, repr for small # List/tuple — count for large, repr for small
if isinstance(val, (list, tuple)): if isinstance(val, (list, tuple)):
if len(val) > 20: sequence = cast(list[object] | tuple[object, ...], val)
return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]" if len(sequence) > 20:
r = repr(val) return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
return f"{key}: {r}" if key else r return f"{key}: {r}" if key else r
# Complex object — small Pydantic models: show values; others: show field names for navigation # Complex object — small Pydantic models: show values; others: show field names for navigation
cls_name = type(val).__name__ value_type = type(cast(object, val))
model_fields = getattr(type(val), "model_fields", None) cls_name = value_type.__name__
if model_fields: model_fields = cast(object, getattr(value_type, "model_fields", None))
fields = list(model_fields.keys()) if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
if len(fields) <= 8: if len(fields) <= 8:
# Small config objects: show field=value pairs # Small config objects: show field=value pairs
pairs = [] pairs: list[str] = []
for f in fields: for f in fields:
fv = getattr(val, f, "?") fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f): if MyTool._is_sensitive_field_name(f):
@@ -311,7 +331,8 @@ class MyTool(Tool):
preview = ", ".join(pairs) preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview return f"{key}: {preview}" if key else preview
else: else:
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")] attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
if fields: if fields:
preview = ", ".join(str(f) for f in fields[:20]) preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else "" suffix = ", ..." if len(fields) > 20 else ""
@@ -417,6 +438,7 @@ class MyTool(Tool):
def _modify(self, key: str | None, value: Any) -> str: def _modify(self, key: str | None, value: Any) -> str:
if err := self._validate_key(key): if err := self._validate_key(key):
return err return err
key = cast(str, key)
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}")
@@ -478,7 +500,7 @@ class MyTool(Tool):
def _modify_restricted(self, key: str, value: Any) -> str: def _modify_restricted(self, key: str, value: Any) -> str:
spec = self.RESTRICTED[key] spec = self.RESTRICTED[key]
expected = spec["type"] expected = cast(type[Any], 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 ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
if not isinstance(value, expected): if not isinstance(value, expected):
@@ -499,9 +521,9 @@ class MyTool(Tool):
"during an active session; use a configured model_preset" "during an active session; use a configured model_preset"
) )
if key == "model": if key == "model":
self._runtime_state.set_runtime_model(value) self._runtime_state.set_runtime_model(cast(str, value))
elif key == "context_window_tokens": elif key == "context_window_tokens":
self._runtime_state.set_runtime_context_window(value) self._runtime_state.set_runtime_context_window(cast(int, value))
else: else:
setattr(self._runtime_state, key, value) setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr( if key == "max_iterations" and hasattr(
@@ -516,7 +538,8 @@ class MyTool(Tool):
if _has_real_attr(self._runtime_state, key): if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key) old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)): if isinstance(old, (str, int, float, bool)):
old_t, new_t = type(old), type(value) old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int: if old_t is float and new_t is int:
pass # int → float coercion allowed pass # int → float coercion allowed
elif old_t is not new_t: elif old_t is not new_t:
@@ -555,12 +578,12 @@ class MyTool(Tool):
if isinstance(value, (str, int, float, bool, type(None))): if isinstance(value, (str, int, float, bool, type(None))):
return None return None
if isinstance(value, list): if isinstance(value, list):
for i, item in enumerate(value): for i, item in enumerate(cast(list[Any], value)):
if err := cls._validate_json_safe(item, depth + 1): if err := cls._validate_json_safe(item, depth + 1):
return f"list[{i}] contains {err}" return f"list[{i}] contains {err}"
return None return None
if isinstance(value, dict): if isinstance(value, dict):
for k, v in value.items(): for k, v in cast(dict[Any, Any], value).items():
if not isinstance(k, str): if not isinstance(k, str):
return f"dict key must be str, got {type(k).__name__}" return f"dict key must be str, got {type(k).__name__}"
if err := cls._validate_json_safe(v, depth + 1): if err := cls._validate_json_safe(v, depth + 1):
+21 -10
View File
@@ -18,13 +18,14 @@ 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, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key from nanobot.agent.tools.context import ToolContext, 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,
DEFAULT_MAX_OUTPUT_CHARS, DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS, DEFAULT_YIELD_MS,
MAX_OUTPUT_CHARS, MAX_OUTPUT_CHARS,
MAX_YIELD_MS, MAX_YIELD_MS,
ExecSessionManager,
clamp_session_int, clamp_session_int,
format_session_poll, format_session_poll,
) )
@@ -174,11 +175,11 @@ class ExecTool(Tool):
return ExecToolConfig return ExecToolConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable return ctx.config.exec.enable
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
cfg = ctx.config.exec cfg = ctx.config.exec
return cls( return cls(
working_dir=ctx.workspace, working_dir=ctx.workspace,
@@ -193,7 +194,7 @@ class ExecTool(Tool):
allowed_env_keys=cfg.allowed_env_keys, allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns, allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns, deny_patterns=cfg.deny_patterns,
session_manager=getattr(ctx, "exec_session_manager", None), session_manager=ctx.exec_session_manager,
) )
def __init__( def __init__(
@@ -211,7 +212,7 @@ class ExecTool(Tool):
sandbox_ro_binds: list[str] | None = None, sandbox_ro_binds: list[str] | None = None,
sandbox_rw_binds: list[str] | None = None, sandbox_rw_binds: list[str] | None = None,
allowed_env_keys: list[str] | None = None, allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None, session_manager: ExecSessionManager | None = None,
): ):
self.timeout = timeout self.timeout = timeout
self.working_dir = working_dir self.working_dir = working_dir
@@ -344,7 +345,7 @@ class ExecTool(Tool):
# misses it, leaving a zombie. # misses it, leaving a zombie.
_reap_pid(process.pid) _reap_pid(process.pid)
output_parts = [] output_parts: list[str] = []
if stdout: if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace")) output_parts.append(stdout.decode("utf-8", errors="replace"))
@@ -504,7 +505,7 @@ class ExecTool(Tool):
) )
def _compose_path(self, current_path: str) -> str: def _compose_path(self, current_path: str) -> str:
parts = [] parts: list[str] = []
if self.path_prepend: if self.path_prepend:
parts.append(self.path_prepend) parts.append(self.path_prepend)
if current_path: if current_path:
@@ -514,7 +515,7 @@ class ExecTool(Tool):
return os.pathsep.join(parts) return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str: def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments = [] segments: list[str] = []
if self.path_prepend: if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND") segments.append("$NANOBOT_PATH_PREPEND")
@@ -555,6 +556,7 @@ class ExecTool(Tool):
command = ExecTool._normalize_powershell_command(command) command = ExecTool._normalize_powershell_command(command)
command = ( command = (
"[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n" "[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)\n"
"if ($PSVersionTable.PSVersion.Major -lt 6) { $OutputEncoding = [Console]::OutputEncoding }\n"
"$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n" "$PSDefaultParameterValues['Out-File:Encoding'] = 'utf8'\n"
f"{command}\n" f"{command}\n"
"if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }" "if ($LASTEXITCODE -ne $null) { exit $LASTEXITCODE }"
@@ -568,11 +570,21 @@ class ExecTool(Tool):
env=env, env=env,
) )
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: list[str] = [shell_program]
shell_name = Path(shell_program).name.lower() shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}: if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l") args.append("-l")
args.extend(["-c", command]) args.extend(["-c", command])
if process_tree:
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
start_new_session=True,
)
return await asyncio.create_subprocess_exec( return await asyncio.create_subprocess_exec(
*args, *args,
stdin=stdin, stdin=stdin,
@@ -580,7 +592,6 @@ class ExecTool(Tool):
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
cwd=cwd, cwd=cwd,
env=env, env=env,
**({"start_new_session": True} if process_tree else {}),
) )
@staticmethod @staticmethod
+8 -2
View File
@@ -1,5 +1,7 @@
"""Spawn tool for creating background subagents.""" """Spawn tool for creating background subagents."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -16,6 +18,7 @@ from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import ToolContext
@tool_parameters( @tool_parameters(
@@ -49,8 +52,11 @@ class SpawnTool(Tool):
self._manager = manager self._manager = manager
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=ctx.subagent_manager) manager = ctx.subagent_manager
if manager is None:
raise RuntimeError("SpawnTool requires an initialized subagent manager")
return cls(manager=manager)
@property @property
def name(self) -> str: def name(self) -> str:
+100 -55
View File
@@ -1,5 +1,7 @@
"""Web tools: web_search and web_fetch.""" """Web tools: web_search and web_fetch."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -7,7 +9,8 @@ import html
import json import json
import os import os
import re import re
from typing import Any, Callable from collections.abc import Callable
from typing import Any, cast
from urllib.parse import quote, urljoin, urlparse from urllib.parse import quote, urljoin, urlparse
import httpx import httpx
@@ -15,6 +18,7 @@ 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, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
@@ -291,8 +295,8 @@ class WebSearchTool(Tool):
"""Search the web using configured provider.""" """Search the web using configured provider."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
name = "web_search" name = "web_search" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Search the web. Returns titles, URLs, and snippets. " "Search the web. Returns titles, URLs, and snippets. "
"count defaults to 5 (max 10). " "count defaults to 5 (max 10). "
"Some providers support timeRange, authLevel, and queryRewrite. " "Some providers support timeRange, authLevel, and queryRewrite. "
@@ -302,20 +306,21 @@ class WebSearchTool(Tool):
config_key = "web" config_key = "web"
@classmethod @classmethod
def config_cls(cls): def config_cls(cls) -> type[WebToolsConfig]:
return WebToolsConfig return WebToolsConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.web.enable return ctx.config.web.enable
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
config_loader = None config_loader: Callable[[], WebSearchConfig] | None = None
if ctx.provider_snapshot_loader is not None: if ctx.provider_snapshot_loader is not None:
def config_loader(): def _load_search_config() -> WebSearchConfig:
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_config, resolve_config_env_vars
return resolve_config_env_vars(load_config()).tools.web.search return resolve_config_env_vars(load_config()).tools.web.search
config_loader = _load_search_config
return cls( return cls(
config=ctx.config.web.search, config=ctx.config.web.search,
proxy=ctx.config.web.proxy, proxy=ctx.config.web.proxy,
@@ -404,7 +409,7 @@ class WebSearchTool(Tool):
auth_level: int | None = None, auth_level: int | None = None,
query_rewrite: bool | None = None, query_rewrite: bool | None = None,
**kwargs: Any, **kwargs: Any,
) -> str: ) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
self._refresh_config() self._refresh_config()
provider = self.config.provider.strip().lower() or "brave" provider = self.config.provider.strip().lower() or "brave"
n = min(max(count or self.config.max_results, 1), 10) n = min(max(count or self.config.max_results, 1), 10)
@@ -448,15 +453,20 @@ class WebSearchTool(Tool):
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 ( # pyright: ignore[reportMissingImports]
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
)
except ImportError: except ImportError:
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep") return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
async_olostep = cast(Any, AsyncOlostep)
olostep_base_error = cast(type[Exception], Olostep_BaseError)
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")
return await self._search_duckduckgo(query, n) return await self._search_duckduckgo(query, n)
try: try:
async with AsyncOlostep(api_key=api_key) as client: async with async_olostep(api_key=api_key) as client:
if self.proxy: if self.proxy:
transport = getattr(client, "_transport", None) transport = getattr(client, "_transport", None)
http_client = getattr(transport, "_client", None) http_client = getattr(transport, "_client", None)
@@ -472,14 +482,16 @@ class WebSearchTool(Tool):
), ),
http2=True, http2=True,
) )
result = await client.answers.create(task=query) result: Any = await client.answers.create(task=query)
sources = getattr(result, "sources", None) or [] sources = cast(list[Any], getattr(result, "sources", None) or [])
source_lines = [] source_lines: list[str] = []
for i, source in enumerate(sources[:n], 1): for i, source_value in enumerate(sources[:n], 1):
source: Any = source_value
if isinstance(source, dict): if isinstance(source, dict):
title = source.get("title", "") source_dict = cast(dict[str, Any], source)
url = source.get("url", "") title = source_dict.get("title", "")
url = source_dict.get("url", "")
else: else:
title = getattr(source, "title", "") title = getattr(source, "title", "")
url = getattr(source, "url", "") url = getattr(source, "url", "")
@@ -493,7 +505,7 @@ class WebSearchTool(Tool):
answer_text = getattr(result, "answer", "") or "" answer_text = getattr(result, "answer", "") or ""
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_base_error as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}") return ToolResult.error(f"Error: 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 ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
@@ -510,6 +522,7 @@ class WebSearchTool(Tool):
"User-Agent": self.user_agent, "User-Agent": self.user_agent,
} }
async with httpx.AsyncClient(proxy=self.proxy) as client: async with httpx.AsyncClient(proxy=self.proxy) as client:
r: httpx.Response | None = None
for attempt in range(2): for attempt in range(2):
r = await client.get( r = await client.get(
"https://api.search.brave.com/res/v1/web/search", "https://api.search.brave.com/res/v1/web/search",
@@ -522,6 +535,7 @@ class WebSearchTool(Tool):
if attempt == 0: if attempt == 0:
logger.warning("Brave search rate limited; retrying once in 1.0s") logger.warning("Brave search rate limited; retrying once in 1.0s")
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
assert r is not None
r.raise_for_status() r.raise_for_status()
items = [ items = [
{"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")} {"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")}
@@ -691,13 +705,19 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout), timeout=float(self.config.timeout),
) )
r.raise_for_status() r.raise_for_status()
items = [] data = cast(dict[str, Any], r.json())
for result in r.json().get("results", []): items: list[dict[str, Any]] = []
if not isinstance(result, dict): for result_value in cast(list[object], data.get("results", [])):
if not isinstance(result_value, dict):
continue continue
highlights = result.get("highlights") or [] result = cast(dict[str, Any], result_value)
highlights: Any = result.get("highlights") or []
if isinstance(highlights, list): if isinstance(highlights, list):
content = "\n".join(str(highlight) for highlight in highlights if highlight) content = "\n".join(
str(highlight)
for highlight in cast(list[object], highlights)
if highlight
)
else: else:
content = str(highlights) content = str(highlights)
if not content: if not content:
@@ -737,14 +757,17 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout), timeout=float(self.config.timeout),
) )
r.raise_for_status() r.raise_for_status()
items = [ data = cast(dict[str, Any], r.json())
organic = cast(list[object], data.get("organic", []))
items: list[dict[str, Any]] = [
{ {
"title": result.get("title", ""), "title": result.get("title", ""),
"url": result.get("link", ""), "url": result.get("link", ""),
"content": result.get("snippet", ""), "content": result.get("snippet", ""),
} }
for result in r.json().get("organic", []) for result_value in organic
if isinstance(result, dict) if isinstance(result_value, dict)
for result in (cast(dict[str, Any], result_value),)
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
@@ -806,7 +829,7 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout), timeout=float(self.config.timeout),
) )
r.raise_for_status() r.raise_for_status()
data = r.json() data = cast(dict[str, Any], 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 ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
@@ -814,20 +837,36 @@ class WebSearchTool(Tool):
except Exception as e: except Exception as e:
return ToolResult.error(f"Error: Volcengine search failed: {e}") return ToolResult.error(f"Error: Volcengine search failed: {e}")
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error") response_metadata = cast(
dict[str, Any],
data.get("ResponseMetadata") or {},
)
error = (
response_metadata.get("Error")
or data.get("Error")
or data.get("error")
)
if error: if error:
if isinstance(error, dict): if isinstance(error, dict):
error = cast(dict[str, Any], error)
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 ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return ToolResult.error(f"Error: Volcengine search error: {error}") return ToolResult.error(f"Error: Volcengine search error: {error}")
result = data.get("Result") or data result = cast(dict[str, Any], data.get("Result") or data)
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or [] web_results = cast(
list[object],
result.get("WebResults")
or result.get("webResults")
or result.get("results")
or [],
)
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
for item in web_results: for item_value in web_results:
if not isinstance(item, dict): if not isinstance(item_value, dict):
continue continue
item = cast(dict[str, Any], item_value)
meta_parts = [ meta_parts = [
str(part) str(part)
for part in ( for part in (
@@ -837,7 +876,7 @@ class WebSearchTool(Tool):
) )
if part if part
] ]
summary = ( summary = cast(str, (
item.get("Summary") item.get("Summary")
or item.get("summary") or item.get("summary")
or item.get("Snippet") or item.get("Snippet")
@@ -845,7 +884,7 @@ class WebSearchTool(Tool):
or item.get("Content") or item.get("Content")
or item.get("content") or item.get("content")
or "" or ""
) ))
content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part) content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part)
items.append( items.append(
{ {
@@ -861,18 +900,20 @@ class WebSearchTool(Tool):
try: try:
# Note: duckduckgo_search is synchronous and does its own requests # Note: duckduckgo_search is synchronous and does its own requests
# We run it in a thread to avoid blocking the loop # We run it in a thread to avoid blocking the loop
from ddgs import DDGS from ddgs import DDGS # pyright: ignore[reportUnknownVariableType]
ddgs = DDGS(timeout=10, proxy=self.proxy) ddgs_type = cast(Any, DDGS)
ddgs = ddgs_type(timeout=10, proxy=self.proxy)
raw = await asyncio.wait_for( raw = await asyncio.wait_for(
asyncio.to_thread(ddgs.text, query, max_results=n), asyncio.to_thread(ddgs.text, query, max_results=n),
timeout=self.config.timeout, timeout=self.config.timeout,
) )
if not raw: if not raw:
return f"No results for: {query}" return f"No results for: {query}"
items = [ raw_items = cast(list[dict[str, Any]], raw)
items: list[dict[str, Any]] = [
{"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")} {"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")}
for r in raw for r in raw_items
] ]
return _format_results(query, items, n) return _format_results(query, items, n)
except Exception as e: except Exception as e:
@@ -907,15 +948,19 @@ class WebSearchTool(Tool):
if r.status_code == 429: if r.status_code == 429:
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.") return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
r.raise_for_status() r.raise_for_status()
data = r.json() data = cast(dict[str, Any], r.json())
wrapped_data = data.get("data") if isinstance(data, dict) else None wrapped_data = data.get("data")
result_data = wrapped_data if isinstance(wrapped_data, dict) else data result_data = (
web_pages = ( cast(dict[str, Any], wrapped_data)
result_data.get("webPages", {}).get("value", []) if isinstance(wrapped_data, dict)
if isinstance(result_data, dict) else data
else []
) )
items = [ web_pages_data = cast(
dict[str, Any],
result_data.get("webPages", {}),
)
web_pages = cast(list[dict[str, Any]], web_pages_data.get("value", []))
items: list[dict[str, Any]] = [
{ {
"title": x.get("name", ""), "title": x.get("name", ""),
"url": x.get("url", ""), "url": x.get("url", ""),
@@ -946,8 +991,8 @@ class WebFetchTool(Tool):
"""Fetch and extract content from a URL.""" """Fetch and extract content from a URL."""
_scopes = {"core", "subagent"} _scopes = {"core", "subagent"}
name = "web_fetch" name = "web_fetch" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Fetch a URL and extract readable content (HTML → markdown/text). " "Fetch a URL and extract readable content (HTML → markdown/text). "
"Output is capped at maxChars (default 50 000). " "Output is capped at maxChars (default 50 000). "
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites." "Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
@@ -956,15 +1001,15 @@ class WebFetchTool(Tool):
config_key = "web" config_key = "web"
@classmethod @classmethod
def config_cls(cls): def config_cls(cls) -> type[WebToolsConfig]:
return WebToolsConfig return WebToolsConfig
@classmethod @classmethod
def enabled(cls, ctx: Any) -> bool: def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.web.enable return ctx.config.web.enable
@classmethod @classmethod
def create(cls, ctx: Any) -> Tool: def create(cls, ctx: ToolContext) -> Tool:
return cls( return cls(
config=ctx.config.web.fetch, config=ctx.config.web.fetch,
proxy=ctx.config.web.proxy, proxy=ctx.config.web.proxy,
@@ -987,10 +1032,10 @@ class WebFetchTool(Tool):
extract_mode: str = "markdown", extract_mode: str = "markdown",
max_chars: int | None = None, max_chars: int | None = None,
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any: # pyright: ignore[reportIncompatibleMethodOverride]
url = url.strip(" \t\r\n`\"'") url = url.strip(" \t\r\n`\"'")
extract_mode = kwargs.pop("extractMode", extract_mode) extract_mode = kwargs.pop("extractMode", extract_mode)
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars max_chars = cast(int, kwargs.pop("maxChars", max_chars) or self.max_chars)
is_valid, error_msg = _validate_url_safe(url) is_valid, error_msg = _validate_url_safe(url)
if not is_valid: if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False) return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
@@ -1119,10 +1164,10 @@ class WebFetchTool(Tool):
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False) return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str: def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
from readability import Document from readability import Document # pyright: ignore[reportMissingTypeStubs]
doc = Document(html_content) doc = Document(html_content)
summary = doc.summary() summary = cast(str, doc.summary())
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary) content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
return f"# {doc.title()}\n\n{content}" if doc.title() else content return f"# {doc.title()}\n\n{content}" if doc.title() else content
+6 -3
View File
@@ -6,7 +6,7 @@ import dataclasses
import time import time
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import TYPE_CHECKING, Any, cast
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
@@ -20,6 +20,9 @@ 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 RuntimeEventBus, RuntimeEventPublisher from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@dataclass(frozen=True) @dataclass(frozen=True)
class TurnRoute: class TurnRoute:
@@ -62,7 +65,7 @@ class TurnDeliveryFactory:
route = self._default_route(msg, session_key) route = self._default_route(msg, session_key)
if self.route_policy is not None: if self.route_policy is not None:
route = self.route_policy(msg, session_key, route) route = self.route_policy(msg, session_key, route)
if not isinstance(route, TurnRoute): if not isinstance(cast(object, route), TurnRoute):
raise TypeError("turn route policy must return TurnRoute") raise TypeError("turn route policy must return TurnRoute")
return TurnDelivery( return TurnDelivery(
bus=self.bus, bus=self.bus,
@@ -186,7 +189,7 @@ class TurnDelivery:
started_at=started_at, started_at=started_at,
) )
def record_runtime(self, runtime: Any) -> None: def record_runtime(self, runtime: LLMRuntime) -> None:
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime) self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
def record_latency(self, latency_ms: int | None) -> None: def record_latency(self, latency_ms: int | None) -> None:
+1 -1
View File
@@ -35,7 +35,7 @@ def api_runtime_paths(config_path: Path) -> ProcessRuntimePaths:
) )
class ApiRuntime(ManagedProcessRuntime): class ApiRuntime(ManagedProcessRuntime[ApiStartOptions]):
"""Manage a WebUI-controlled OpenAI-compatible API process.""" """Manage a WebUI-controlled OpenAI-compatible API process."""
service_name = "api" service_name = "api"
+64 -18
View File
@@ -12,7 +12,7 @@ import hmac
import json as _json import json as _json
import time import time
import uuid import uuid
from typing import Any from typing import TYPE_CHECKING, Any, Awaitable, Callable, cast
from aiohttp import web from aiohttp import web
from loguru import logger from loguru import logger
@@ -30,6 +30,9 @@ from nanobot.utils.media_decode import (
) )
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
__all__ = ( __all__ = (
"MAX_FILE_SIZE", "MAX_FILE_SIZE",
"_FileSizeExceeded", "_FileSizeExceeded",
@@ -44,7 +47,7 @@ API_CHAT_ID = "default"
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop") _AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name") _MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout") _REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks") _SESSION_LOCKS_KEY = web.AppKey[dict[str, asyncio.Lock]]("session_locks")
_MISSING = object() _MISSING = object()
@@ -111,6 +114,26 @@ def _response_text(value: Any) -> str:
return str(getattr(value, "content") or "") return str(getattr(value, "content") or "")
return str(value) return str(value)
def _as_str(value: object) -> str:
"""Return *value* when it is text, otherwise an empty string."""
return value if isinstance(value, str) else ""
def _require_json_object(value: object, field: str) -> dict[str, Any]:
"""Validate an object-valued field from an untrusted JSON request."""
if not isinstance(value, dict):
raise TypeError(f"{field} must be an object")
return cast(dict[str, Any], value)
def _require_json_string(value: object, field: str) -> str:
"""Validate a string-valued field from an untrusted JSON request."""
if not isinstance(value, str):
raise TypeError(f"{field} must be a string")
return value
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SSE helpers # SSE helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -141,13 +164,19 @@ _SSE_DONE = b"data: [DONE]\n\n"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_json_content(body: dict) -> tuple[str, list[str]]: def _parse_json_content(body: dict[str, Any]) -> tuple[str, list[str]]:
"""Parse JSON request body. Returns (text, media_paths).""" """Parse JSON request body. Returns (text, media_paths)."""
messages = body.get("messages") messages_value = cast(object, body.get("messages"))
if not isinstance(messages, list) or len(messages) != 1: if not isinstance(messages_value, list):
raise ValueError("Only a single user message is supported") raise ValueError("Only a single user message is supported")
message = messages[0] messages = cast(list[object], messages_value)
if not isinstance(message, dict) or message.get("role") != "user": if len(messages) != 1:
raise ValueError("Only a single user message is supported")
message_value: object = messages[0]
if not isinstance(message_value, dict):
raise ValueError("Only a single user message is supported")
message = cast(dict[str, Any], message_value)
if message.get("role") != "user":
raise ValueError("Only a single user message is supported") raise ValueError("Only a single user message is supported")
user_content = message.get("content", "") user_content = message.get("content", "")
@@ -156,13 +185,26 @@ def _parse_json_content(body: dict) -> tuple[str, list[str]]:
if isinstance(user_content, list): if isinstance(user_content, list):
text_parts: list[str] = [] text_parts: list[str] = []
for part in user_content: for part_value in cast(list[object], user_content):
if not isinstance(part, dict): if not isinstance(part_value, dict):
continue continue
part = cast(dict[str, Any], part_value)
if part.get("type") == "text": if part.get("type") == "text":
text_parts.append(part.get("text", "")) text_parts.append(
_require_json_string(
cast(object, part.get("text", "")),
"messages[0].content[].text",
)
)
elif part.get("type") == "image_url": elif part.get("type") == "image_url":
url = part.get("image_url", {}).get("url", "") image_url = _require_json_object(
cast(object, part.get("image_url", {})),
"messages[0].content[].image_url",
)
url = _require_json_string(
cast(object, image_url.get("url", "")),
"messages[0].content[].image_url.url",
)
if url.startswith("data:"): if url.startswith("data:"):
saved = _save_base64_data_url(url, media_dir) saved = _save_base64_data_url(url, media_dir)
if saved: if saved:
@@ -191,7 +233,7 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
media_paths: list[str] = [] media_paths: list[str] = []
while True: while True:
part = await reader.next() part: Any = await reader.next()
if part is None: if part is None:
break break
if part.name == "message": if part.name == "message":
@@ -223,11 +265,9 @@ async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str |
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def handle_chat_completions(request: web.Request) -> web.Response: async def handle_chat_completions(request: web.Request) -> web.Response | web.StreamResponse:
"""POST /v1/chat/completions — supports JSON and multipart/form-data.""" """POST /v1/chat/completions — supports JSON and multipart/form-data."""
content_type = request.content_type or "" content_type = _as_str(cast(object, request.content_type or ""))
if not isinstance(content_type, str):
content_type = ""
agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop") agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
timeout_s: float = _app_value( timeout_s: float = _app_value(
@@ -247,6 +287,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
body = await request.json() body = await request.json()
except Exception: except Exception:
return _error_json(400, "Invalid JSON body") return _error_json(400, "Invalid JSON body")
if not isinstance(body, dict):
return _error_json(400, "Invalid JSON body")
body = cast(dict[str, Any], body)
stream = body.get("stream", False) stream = body.get("stream", False)
requested_model = body.get("model") requested_model = body.get("model")
text, media_paths = _parse_json_content(body) text, media_paths = _parse_json_content(body)
@@ -405,7 +448,7 @@ async def handle_health(request: web.Request) -> web.Response:
def create_app( def create_app(
agent_loop, agent_loop: "AgentLoop",
model_name: str = "nanobot", model_name: str = "nanobot",
request_timeout: float = 120.0, request_timeout: float = 120.0,
api_key: str = "", api_key: str = "",
@@ -425,7 +468,10 @@ def create_app(
app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
@web.middleware @web.middleware
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse: async def auth_middleware(
request: web.Request,
handler: Callable[[web.Request], Awaitable[web.StreamResponse]],
) -> web.StreamResponse:
# Allow unauthenticated health checks. # Allow unauthenticated health checks.
if request.path == "/health": if request.path == "/health":
return await handler(request) return await handler(request)
+35 -23
View File
@@ -10,10 +10,11 @@ import shutil
import subprocess import subprocess
import sys import sys
import time import time
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from importlib import metadata as importlib_metadata from importlib import metadata as importlib_metadata
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -204,6 +205,11 @@ def _now() -> float:
return time.time() return time.time()
def _as_object_dict(value: object) -> dict[str, Any] | None:
"""Narrow a JSON-like object to the string-keyed mapping used by this module."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _safe_skill_name(name: str) -> str: def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-") clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}" return f"cli-app-{clean or 'app'}"
@@ -277,10 +283,11 @@ def _console_script_distribution(entry_point: str) -> str | None:
if item.group != "console_scripts" or item.name != entry_point: if item.group != "console_scripts" or item.name != entry_point:
continue continue
try: try:
name = distribution.metadata.get("Name") name: object = cast(Any, distribution.metadata).get("Name")
except Exception: except Exception:
name = None name = None
return str(name or getattr(distribution, "name", "") or "").strip() or None fallback_name = cast(object, getattr(distribution, "name", ""))
return str(name or fallback_name or "").strip() or None
return None return None
@@ -335,10 +342,10 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
def _read_json(path: Path) -> dict[str, Any] | None: def _read_json(path: Path) -> dict[str, Any] | None:
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data: object = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return None return None
return data if isinstance(data, dict) else None return _as_object_dict(data)
def _write_json(path: Path, data: dict[str, Any]) -> None: def _write_json(path: Path, data: dict[str, Any]) -> None:
@@ -414,8 +421,8 @@ class CliAppManager:
cached = _read_json(cache_path) cached = _read_json(cache_path)
if not cached: if not cached:
return None, 0.0 return None, 0.0
data = cached.get("data") data = _as_object_dict(cached.get("data"))
if not isinstance(data, dict): if data is None:
return None, 0.0 return None, 0.0
try: try:
cached_at = float(cached.get("_cached_at", 0)) cached_at = float(cached.get("_cached_at", 0))
@@ -425,8 +432,8 @@ class CliAppManager:
def _load_installed(self) -> dict[str, Any]: def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {} data = _read_json(self.installed_path) or {}
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data apps = _as_object_dict(data.get("apps"))
return apps if isinstance(apps, dict) else {} return apps if apps is not None else data
def _save_installed(self, installed: dict[str, Any]) -> None: def _save_installed(self, installed: dict[str, Any]) -> None:
_write_json(self.installed_path, {"schema_version": 1, "apps": installed}) _write_json(self.installed_path, {"schema_version": 1, "apps": installed})
@@ -453,8 +460,8 @@ class CliAppManager:
try: try:
response = httpx.get(url, timeout=15.0, follow_redirects=True) response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status() response.raise_for_status()
fetched = response.json() fetched = _as_object_dict(response.json())
if not isinstance(fetched, dict): if fetched is None:
raise ValueError("registry response must be an object") raise ValueError("registry response must be an object")
except Exception: except Exception:
if data is not None: if data is not None:
@@ -483,8 +490,8 @@ class CliAppManager:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
fetched = response.json() fetched = _as_object_dict(response.json())
if not isinstance(fetched, dict): if fetched is None:
raise ValueError("registry response must be an object") raise ValueError("registry response must be an object")
except Exception: except Exception:
if data is not None: if data is not None:
@@ -534,13 +541,14 @@ class CliAppManager:
apps_by_name: dict[str, dict[str, Any]] = {} apps_by_name: dict[str, dict[str, Any]] = {}
updated_values: list[str] = [] updated_values: list[str] = []
for source, raw_base, registry in registries: for source, raw_base, registry in registries:
meta = registry.get("meta") meta = _as_object_dict(registry.get("meta"))
if isinstance(meta, dict) and isinstance(meta.get("updated"), str): if meta is not None and isinstance(meta.get("updated"), str):
updated_values.append(meta["updated"]) updated_values.append(meta["updated"])
for row in registry.get("clis", []): for row in cast(Iterable[object], registry.get("clis", [])):
if not isinstance(row, dict) or not row.get("name"): entry = _as_object_dict(row)
if entry is None or not entry.get("name"):
continue continue
entry = dict(row) entry = dict(entry)
entry["_source"] = source entry["_source"] = source
entry["_raw_base"] = raw_base entry["_raw_base"] = raw_base
key = str(entry["name"]).lower() key = str(entry["name"]).lower()
@@ -588,7 +596,7 @@ class CliAppManager:
if not installed: if not installed:
return [] return []
installed_by_name = { installed_by_name = {
str(name).lower(): (str(name), data if isinstance(data, dict) else {}) str(name).lower(): (str(name), _as_object_dict(data) or {})
for name, data in installed.items() for name, data in installed.items()
} }
seen: set[str] = set() seen: set[str] = set()
@@ -769,12 +777,14 @@ class CliAppManager:
for app in cached_apps for app in cached_apps
if app.get("name") if app.get("name")
} }
rows = [] rows: list[dict[str, Any]] = []
for name, raw_entry in sorted(installed.items()): for name, raw_entry in sorted(installed.items()):
entry = raw_entry if isinstance(raw_entry, dict) else {} entry = _as_object_dict(raw_entry)
if entry is None:
entry = {}
strategy = str(entry.get("strategy") or "bundled") strategy = str(entry.get("strategy") or "bundled")
cached_app = cached_by_name.get(str(name).lower(), {}) cached_app = cached_by_name.get(str(name).lower(), {})
app = { app: dict[str, Any] = {
"name": str(name), "name": str(name),
"display_name": str( "display_name": str(
cached_app.get("display_name") or entry.get("display_name") or name cached_app.get("display_name") or entry.get("display_name") or name
@@ -1165,7 +1175,9 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
if str(app["name"]) not in installed: if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed") raise CliAppError("CLI app is not installed")
raw_installed_entry = installed.get(str(app["name"])) raw_installed_entry = installed.get(str(app["name"]))
installed_entry = raw_installed_entry if isinstance(raw_installed_entry, dict) else {} installed_entry = _as_object_dict(raw_installed_entry)
if installed_entry is None:
installed_entry = {}
strategy = self._strategy(app) strategy = self._strategy(app)
entry_point = str(app.get("entry_point") or "").strip() entry_point = str(app.get("entry_point") or "").strip()
managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip() managed_entry_path = str(installed_entry.get("entry_point_path") or "").strip()
+9 -4
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Mapping from typing import Any, Mapping, cast
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -29,9 +29,11 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot.""" """Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list): if isinstance(structured, list):
structured_items = cast(list[Any], structured)
mentions = [ mentions = [
item for item in structured cast(Mapping[str, Any], item) for item in structured_items
if isinstance(item, Mapping) and isinstance(item.get("name"), str) if isinstance(item, Mapping)
and isinstance(cast(Mapping[str, Any], item).get("name"), str)
] ]
if mentions: if mentions:
return [ return [
@@ -49,7 +51,10 @@ def runtime_lines_for_request(
try: try:
from nanobot.apps.cli import CliAppManager from nanobot.apps.cli import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text) mentions = cast(
list[dict[str, Any]],
CliAppManager(workspace=workspace).mentioned_installed_apps(text),
)
except Exception: except Exception:
return [] return []
return [ return [
+14 -6
View File
@@ -22,6 +22,7 @@ from nanobot.audio.transcription_registry import (
) )
from nanobot.config.loader import resolve_env_refs from nanobot.config.loader import resolve_env_refs
from nanobot.config.paths import get_media_dir from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Config, ProviderConfig
from nanobot.providers.registry import find_by_name from nanobot.providers.registry import find_by_name
from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url from nanobot.utils.media_decode import FileSizeExceeded, save_base64_data_url
@@ -73,8 +74,9 @@ def _as_provider(value: Any) -> TranscriptionProviderName | None:
return spec.name if spec else None return spec.name if spec else None
def _provider_config(config: Any, provider: str) -> Any: def _provider_config(config: Config, provider: str) -> ProviderConfig | None:
return getattr(getattr(config, "providers", None), provider, None) value = getattr(config.providers, provider, None)
return value if isinstance(value, ProviderConfig) else None
def _provider_default_api_base(provider: str) -> str | None: def _provider_default_api_base(provider: str) -> str | None:
@@ -82,7 +84,10 @@ def _provider_default_api_base(provider: str) -> str | None:
return spec.default_api_base if spec else None return spec.default_api_base if spec else None
def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str: def _resolve_transcription_api_key(
provider: str,
provider_cfg: ProviderConfig | None,
) -> str:
api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else "" api_key = resolve_env_refs(getattr(provider_cfg, "api_key", None) or "") if provider_cfg else ""
if api_key: if api_key:
return api_key return api_key
@@ -94,10 +99,13 @@ def _resolve_transcription_api_key(provider: str, provider_cfg: Any) -> str:
return env_key return env_key
env_key = spec.env_key if spec else "" env_key = spec.env_key if spec else ""
return os.environ.get(env_key) if env_key else "" return os.environ.get(env_key, "") if env_key else ""
def _resolve_transcription_api_base(provider: str, provider_cfg: Any) -> str: def _resolve_transcription_api_base(
provider: str,
provider_cfg: ProviderConfig | None,
) -> str:
api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else "" api_base = resolve_env_refs(getattr(provider_cfg, "api_base", None) or "") if provider_cfg else ""
if api_base: if api_base:
return api_base return api_base
@@ -111,7 +119,7 @@ def _extract_data_url_mime(url: str) -> str | None:
return header[5:].split(";", 1)[0].strip().lower() or None return header[5:].split(";", 1)[0].strip().lower() or None
def resolve_transcription_config(config: Any) -> EffectiveTranscriptionConfig: def resolve_transcription_config(config: Config) -> EffectiveTranscriptionConfig:
"""Resolve top-level transcription settings with legacy channel fallback.""" """Resolve top-level transcription settings with legacy channel fallback."""
top = getattr(config, "transcription", None) top = getattr(config, "transcription", None)
channels = getattr(config, "channels", None) channels = getattr(config, "channels", None)
+13 -5
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import Any from typing import Any, cast
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -153,7 +153,11 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
) )
if meta.get("_goal_state_sync"): if meta.get("_goal_state_sync"):
goal_state = meta.get("goal_state") goal_state = meta.get("goal_state")
return GoalStateSyncEvent(goal_state if isinstance(goal_state, dict) else {"active": False}) return GoalStateSyncEvent(
cast(dict[str, Any], goal_state)
if isinstance(goal_state, dict)
else {"active": False}
)
if meta.get("_goal_status"): if meta.get("_goal_status"):
status = meta.get("goal_status") status = meta.get("goal_status")
if not isinstance(status, str) or not status: if not isinstance(status, str) or not status:
@@ -166,7 +170,7 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
goal_state = meta.get("goal_state") goal_state = meta.get("goal_state")
return TurnEndEvent( return TurnEndEvent(
latency_ms=_metadata_int(meta, "latency_ms"), latency_ms=_metadata_int(meta, "latency_ms"),
goal_state=goal_state if isinstance(goal_state, dict) else None, goal_state=cast(dict[str, Any], goal_state) if isinstance(goal_state, dict) else None,
) )
if meta.get("_session_updated"): if meta.get("_session_updated"):
return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope")) return SessionUpdatedEvent(scope=_metadata_str(meta, "_session_update_scope"))
@@ -203,8 +207,12 @@ def _legacy_event_from_metadata(msg: OutboundMessage) -> OutboundEvent | None:
reasoning_delta=bool(meta.get("_reasoning_delta")), reasoning_delta=bool(meta.get("_reasoning_delta")),
reasoning_end=bool(meta.get("_reasoning_end")), reasoning_end=bool(meta.get("_reasoning_end")),
stream_id=_metadata_str(meta, "_stream_id"), stream_id=_metadata_str(meta, "_stream_id"),
tool_events=tool_events if isinstance(tool_events, list) else None, tool_events=cast(list[dict[str, Any]], tool_events)
file_edit_events=file_edit_events if isinstance(file_edit_events, list) else None, if isinstance(tool_events, list)
else None,
file_edit_events=cast(list[dict[str, Any]], file_edit_events)
if isinstance(file_edit_events, list)
else None,
) )
return None return None
+7 -4
View File
@@ -12,12 +12,15 @@ import contextlib
import inspect import inspect
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import TYPE_CHECKING, Any
from loguru import logger from loguru import logger
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@dataclass(frozen=True) @dataclass(frozen=True)
class RuntimeEventContext: class RuntimeEventContext:
@@ -52,7 +55,7 @@ class TurnCompleted:
context: RuntimeEventContext context: RuntimeEventContext
latency_ms: int | None = None latency_ms: int | None = None
runtime: Any | None = None runtime: LLMRuntime | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -155,7 +158,7 @@ class RuntimeEventPublisher:
def __init__(self, bus: RuntimeEventBus | None = None) -> None: def __init__(self, bus: RuntimeEventBus | None = None) -> None:
self.bus = bus or RuntimeEventBus() self.bus = bus or RuntimeEventBus()
self._turn_latency_ms: dict[str, int] = {} self._turn_latency_ms: dict[str, int] = {}
self._turn_runtime: dict[str, Any] = {} self._turn_runtime: dict[str, LLMRuntime] = {}
@staticmethod @staticmethod
def _context( def _context(
@@ -174,7 +177,7 @@ class RuntimeEventPublisher:
attributes=dict(attributes or {}), attributes=dict(attributes or {}),
) )
def record_turn_runtime(self, session_key: str, runtime: Any) -> None: def record_turn_runtime(self, session_key: str, runtime: LLMRuntime) -> None:
self._turn_runtime[session_key] = runtime self._turn_runtime[session_key] = runtime
def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None: def record_turn_latency(self, session_key: str, latency_ms: int | None) -> None:
+20 -4
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from loguru import logger from loguru import logger
@@ -201,13 +201,21 @@ class BaseChannel(ABC):
def supports_streaming(self) -> bool: def supports_streaming(self) -> bool:
"""True when config enables streaming AND this subclass implements send_delta.""" """True when config enables streaming AND this subclass implements send_delta."""
cfg = self.config cfg = self.config
streaming = cfg.get("streaming", False) if isinstance(cfg, dict) else getattr(cfg, "streaming", False) config_mapping = cast(dict[str, Any], cfg) if isinstance(cfg, dict) else None
streaming: Any = (
config_mapping.get("streaming", False)
if config_mapping is not None
else getattr(cast(Any, cfg), "streaming", False)
)
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
def is_allowed(self, sender_id: str) -> bool: def is_allowed(self, sender_id: str) -> bool:
"""Check sender permission: star > allowlist > pairing store > deny.""" """Check sender permission: star > allowlist > pairing store > deny."""
if isinstance(self.config, dict): if isinstance(self.config, dict):
allow_list = self.config.get("allow_from") or self.config.get("allowFrom") or [] config_mapping = cast(dict[str, Any], self.config)
allow_list: Any = (
config_mapping.get("allow_from") or config_mapping.get("allowFrom") or []
)
else: else:
allow_list = getattr(self.config, "allow_from", None) or [] allow_list = getattr(self.config, "allow_from", None) or []
if "*" in allow_list: if "*" in allow_list:
@@ -240,7 +248,15 @@ class BaseChannel(ABC):
permission_id = authorization_id if authorization_id is not None else sender_id permission_id = authorization_id if authorization_id is not None else sender_id
if not self.is_allowed(permission_id): if not self.is_allowed(permission_id):
if is_dm: if is_dm:
code = generate_code(self.name, str(sender_id)) try:
code = generate_code(self.name, str(sender_id))
except OSError:
# Transient pairing-store I/O failure: skip the pairing
# reply for this message rather than crash the handler.
self.logger.warning(
"Pairing store unavailable; dropping DM from {}", sender_id
)
return
await self.send( await self.send(
OutboundMessage( OutboundMessage(
channel=self.name, channel=self.name,
+62 -33
View File
@@ -6,7 +6,7 @@ from collections.abc import Iterable
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal from typing import TYPE_CHECKING, Any, Callable, Literal, TypeGuard, cast
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.channels.plugin import ChannelPlugin from nanobot.channels.plugin import ChannelPlugin
@@ -22,6 +22,8 @@ class ChannelValidationContext:
allow_local_service_access: bool = False allow_local_service_access: bool = False
# Keep callback contracts precise for static consumers. The public adapters below
# still validate third-party implementations at runtime.
SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]] SetupValidator = Callable[[dict[str, Any], ChannelValidationContext], dict[str, Any]]
DefaultConfigFactory = Callable[[], dict[str, Any]] DefaultConfigFactory = Callable[[], dict[str, Any]]
InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]] InstanceSpecsFactory = Callable[..., Iterable["ChannelInstanceSpec"]]
@@ -87,7 +89,7 @@ class ChannelActivation:
instances = ( instances = (
tuple( tuple(
cls.from_config(item, include_instances=True) cls.from_config(item, include_instances=True)
for item in raw_instances for item in cast(list[Any], raw_instances)
if _config_mapping(item) is not None if _config_mapping(item) is not None
) )
if isinstance(raw_instances, list) if isinstance(raw_instances, list)
@@ -193,7 +195,7 @@ class ChannelSetupSpec:
def to_public_dict(self, channel_name: str) -> dict[str, Any]: def to_public_dict(self, channel_name: str) -> dict[str, Any]:
"""Serialize the writable setup contract for generic WebUI consumers.""" """Serialize the writable setup contract for generic WebUI consumers."""
simple_required = set(self.simple_required_fields) simple_required = set(self.simple_required_fields)
fields = [] fields: list[dict[str, Any]] = []
for name, field in self.fields.items(): for name, field in self.fields.items():
if not field.writable: if not field.writable:
continue continue
@@ -268,35 +270,37 @@ def channel_default_config(plugin: ChannelPlugin) -> dict[str, Any]:
defaults: dict[str, Any] = {"enabled": plugin.default_enabled} defaults: dict[str, Any] = {"enabled": plugin.default_enabled}
if plugin.setup is not None: if plugin.setup is not None:
for name, field in plugin.setup.fields.items(): for name, field in plugin.setup.fields.items():
value = field.default value: Any = field.default
if value is None: if value is None:
value = { fallback_defaults: dict[str, Any] = {
"string": "", "string": "",
"secret": "", "secret": "",
"list": [], "list": [],
"bool": False, "bool": False,
}.get(field.kind, _MISSING) }
value = fallback_defaults.get(field.kind, _MISSING)
if value is not _MISSING: if value is not _MISSING:
_assign_channel_field(defaults, name, deepcopy(value)) _assign_channel_field(defaults, name, deepcopy(value))
factory = plugin.management.default_config factory = plugin.management.default_config
if factory is None: if factory is None:
return defaults return defaults
values = factory() values_raw = cast(object, factory())
if not isinstance(values, dict): if not isinstance(values_raw, dict):
raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict") raise TypeError(f"ChannelPlugin.management.default_config for '{plugin.name}' must return a dict")
return merge_missing_defaults(values, defaults) values = cast(dict[str, Any], values_raw)
return cast(dict[str, Any], merge_missing_defaults(values, defaults))
def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None: def _assign_channel_field(values: dict[str, Any], field: str, value: Any) -> None:
target = values target = values
parts = field.split(".") parts = field.split(".")
for part in parts[:-1]: for part in parts[:-1]:
nested = target.get(part) nested: object = target.get(part)
if not isinstance(nested, dict): if not isinstance(nested, dict):
nested = {} nested = {}
target[part] = nested target[part] = nested
target = nested target = cast(dict[str, Any], nested)
target[parts[-1]] = value target[parts[-1]] = value
@@ -327,27 +331,28 @@ def channel_instance_specs(
factory = plugin.management.instance_specs factory = plugin.management.instance_specs
if factory is None: if factory is None:
activation = ChannelActivation.from_config(section) activation = ChannelActivation.from_config(section)
raw_specs: Iterable[ChannelInstanceSpec] = ( raw_specs: object = (
[] []
if enabled_only and not activation.resolve(default=plugin.default_enabled) if enabled_only and not activation.resolve(default=plugin.default_enabled)
else [ChannelInstanceSpec(instance_id="default", config=section)] else [ChannelInstanceSpec(instance_id="default", config=section)]
) )
else: else:
raw_specs = factory(section, enabled_only=enabled_only) raw_specs = cast(object, factory(section, enabled_only=enabled_only))
if not isinstance(raw_specs, Iterable): if not isinstance(raw_specs, Iterable):
raise TypeError( raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable" f"ChannelPlugin.management.instance_specs for '{plugin.name}' must return an iterable"
) )
specs = list(raw_specs) specs = list(cast(Iterable[object], raw_specs))
if not _all_channel_instance_specs(specs):
raise TypeError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
instance_ids: set[str] = set() instance_ids: set[str] = set()
runtime_names: set[str] = set() runtime_names: set[str] = set()
for spec in specs: for spec in specs:
if not isinstance(spec, ChannelInstanceSpec): instance_id = cast(object, spec.instance_id)
raise TypeError( if not isinstance(instance_id, str) or not instance_id.strip():
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an invalid item"
)
if not isinstance(spec.instance_id, str) or not spec.instance_id.strip():
raise ValueError( raise ValueError(
f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id" f"ChannelPlugin.management.instance_specs for '{plugin.name}' returned an empty instance id"
) )
@@ -367,6 +372,12 @@ def channel_instance_specs(
return specs return specs
def _all_channel_instance_specs(
values: list[object],
) -> TypeGuard[list[ChannelInstanceSpec]]:
return all(isinstance(value, ChannelInstanceSpec) for value in values)
def resolve_channel_action_target( def resolve_channel_action_target(
requested_instance_id: str | None, requested_instance_id: str | None,
) -> str: ) -> str:
@@ -393,8 +404,17 @@ def channel_instance_config(
return {} return {}
config = selected.config config = selected.config
if hasattr(config, "model_dump"): if hasattr(config, "model_dump"):
return dict(config.model_dump(mode="json", by_alias=True)) dumped: dict[str, Any] = config.model_dump(mode="json", by_alias=True)
return dict(config) if isinstance(config, dict) else {} copied: dict[str, Any] = {}
for key in dumped:
copied[key] = dumped[key]
return copied
if not isinstance(config, dict):
return {}
copied_config: dict[str, Any] = {}
for key, value in cast(dict[object, Any], config).items():
copied_config[cast(str, key)] = value
return copied_config
def channel_update_instance_config( def channel_update_instance_config(
@@ -409,7 +429,10 @@ def channel_update_instance_config(
if instance_id not in {"", "default"}: if instance_id not in {"", "default"}:
raise ValueError(f"{plugin.name} does not support multiple instances") raise ValueError(f"{plugin.name} does not support multiple instances")
return values return values
return updater(section, values, instance_id=instance_id) updated = cast(object, updater(section, values, instance_id=instance_id))
if not isinstance(updated, dict):
raise TypeError(f"ChannelPlugin.management.update_instance_config for '{plugin.name}' must return a dict")
return cast(dict[str, Any], updated)
def channel_set_config_enabled( def channel_set_config_enabled(
@@ -423,7 +446,7 @@ def channel_set_config_enabled(
from nanobot.config.loader import merge_missing_defaults from nanobot.config.loader import merge_missing_defaults
values = channel_instance_config(plugin, section, instance_id=instance_id) values = channel_instance_config(plugin, section, instance_id=instance_id)
values = merge_missing_defaults(values, channel_default_config(plugin)) values = cast(dict[str, Any], merge_missing_defaults(values, channel_default_config(plugin)))
values["enabled"] = enabled values["enabled"] = enabled
return channel_update_instance_config( return channel_update_instance_config(
plugin, plugin,
@@ -440,12 +463,16 @@ def channel_feature_instances(
setup_spec: ChannelSetupSpec | None = None, setup_spec: ChannelSetupSpec | None = None,
) -> list[dict[str, Any]] | None: ) -> list[dict[str, Any]] | None:
factory = plugin.management.feature_instances factory = plugin.management.feature_instances
overrides = factory(section, setup_spec=setup_spec) if factory is not None else None overrides = (
cast(object, factory(section, setup_spec=setup_spec))
if factory is not None
else None
)
if overrides is None and not plugin.management.multi_instance: if overrides is None and not plugin.management.multi_instance:
return None return None
if overrides is not None and ( if overrides is not None and (
not isinstance(overrides, list) not isinstance(overrides, list)
or any(not isinstance(instance, dict) for instance in overrides) or any(not isinstance(instance, dict) for instance in cast(list[object], overrides))
): ):
raise TypeError( raise TypeError(
f"ChannelPlugin.management.feature_instances for '{plugin.name}' " f"ChannelPlugin.management.feature_instances for '{plugin.name}' "
@@ -470,7 +497,8 @@ def channel_feature_instances(
by_id = {instance["id"]: instance for instance in instances} by_id = {instance["id"]: instance for instance in instances}
seen: set[str] = set() seen: set[str] = set()
for override in overrides: for override_value in cast(list[object], overrides):
override = cast(dict[str, Any], override_value)
instance_id = override.get("id") instance_id = override.get("id")
if not isinstance(instance_id, str) or instance_id not in by_id: if not isinstance(instance_id, str) or instance_id not in by_id:
raise ValueError( raise ValueError(
@@ -514,20 +542,21 @@ def _validate_runtime_name(plugin: ChannelPlugin, runtime_name: Any) -> None:
def channel_field_value(values: Any, field_path: str) -> Any: def channel_field_value(values: Any, field_path: str) -> Any:
current = values current: Any = values
for part in field_path.split("."): for part in field_path.split("."):
candidates = (part, _camel_to_snake(part)) candidates = (part, _camel_to_snake(part))
if isinstance(current, dict): if isinstance(current, dict):
for candidate in candidates: for candidate in candidates:
if candidate in current: if candidate in current:
current = current[candidate] current = cast(Any, current)[candidate]
break break
else: else:
return None return None
continue continue
for candidate in candidates: for candidate in candidates:
if hasattr(current, candidate): current_value = current
current = getattr(current, candidate) if hasattr(current_value, candidate):
current = getattr(current_value, candidate)
break break
else: else:
return None return None
@@ -542,7 +571,7 @@ def stringify_channel_value(value: Any) -> str:
if isinstance(value, bool): if isinstance(value, bool):
return "true" if value else "false" return "true" if value else "false"
if isinstance(value, list): if isinstance(value, list):
return ", ".join(str(item) for item in value) return ", ".join(str(item) for item in cast(list[Any], value))
return str(value) return str(value)
@@ -586,8 +615,8 @@ def _channel_feature_instance(
def _config_mapping(value: Any) -> dict[str, Any] | None: def _config_mapping(value: Any) -> dict[str, Any] | None:
if hasattr(value, "model_dump"): if hasattr(value, "model_dump"):
dumped = value.model_dump(mode="json", by_alias=True) dumped = value.model_dump(mode="json", by_alias=True)
return dumped if isinstance(dumped, dict) else None return cast(dict[str, Any], dumped) if isinstance(dumped, dict) else None
return value if isinstance(value, dict) else None return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _camel_to_snake(value: str) -> str: def _camel_to_snake(value: str) -> str:
+68 -34
View File
@@ -1,3 +1,4 @@
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false
"""DingTalk/DingDing channel implementation using Stream Mode.""" """DingTalk/DingDing channel implementation using Stream Mode."""
import asyncio import asyncio
@@ -10,7 +11,7 @@ from contextlib import suppress
from inspect import isawaitable from inspect import isawaitable
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from urllib.parse import unquote, urljoin, urlparse from urllib.parse import unquote, urljoin, urlparse
import httpx import httpx
@@ -36,11 +37,17 @@ def _escape_markdown_sender_name(value: str) -> str:
for char in normalized for char in normalized
) )
DINGTALK_AVAILABLE = False
AckMessage: Any = None
CallbackHandler: Any = object
Credential: Any = None
DingTalkStreamClient: Any = None
ChatbotMessage: Any = None
try: try:
from dingtalk_stream import ( from dingtalk_stream import (
AckMessage, AckMessage,
CallbackHandler, CallbackHandler,
CallbackMessage,
Credential, Credential,
DingTalkStreamClient, DingTalkStreamClient,
) )
@@ -48,41 +55,41 @@ try:
DINGTALK_AVAILABLE = True DINGTALK_AVAILABLE = True
except ImportError: except ImportError:
DINGTALK_AVAILABLE = False pass
# Fallback so class definitions don't crash at module level
CallbackHandler = object # type: ignore[assignment,misc]
CallbackMessage = None # type: ignore[assignment,misc]
AckMessage = None # type: ignore[assignment,misc]
ChatbotMessage = None # type: ignore[assignment,misc]
class NanobotDingTalkHandler(CallbackHandler): _CallbackHandlerBase = CallbackHandler
class NanobotDingTalkHandler(_CallbackHandlerBase):
""" """
Standard DingTalk Stream SDK Callback Handler. Standard DingTalk Stream SDK Callback Handler.
Parses incoming messages and forwards them to the Nanobot channel. Parses incoming messages and forwards them to the Nanobot channel.
""" """
def __init__(self, channel: "DingTalkChannel"): def __init__(self, channel: "DingTalkChannel"):
super().__init__() super().__init__() # pyright: ignore[reportUnknownMemberType]
self.channel = channel self.channel = channel
async def process(self, message: CallbackMessage): async def process(self, message: Any) -> tuple[Any, str]:
"""Process incoming stream message.""" """Process incoming stream message."""
try: try:
# Parse using SDK's ChatbotMessage for robust handling # Parse using SDK's ChatbotMessage for robust handling
chatbot_msg = ChatbotMessage.from_dict(message.data) chatbot_msg: Any = ChatbotMessage.from_dict(message.data)
message_data = cast(dict[str, Any], message.data)
# Extract text content; fall back to raw dict if SDK object is empty # Extract text content; fall back to raw dict if SDK object is empty
content = "" content = ""
if chatbot_msg.text: if chatbot_msg.text:
content = chatbot_msg.text.content.strip() content = cast(str, chatbot_msg.text.content).strip()
elif chatbot_msg.extensions.get("content", {}).get("recognition"): elif chatbot_msg.extensions.get("content", {}).get("recognition"):
content = chatbot_msg.extensions["content"]["recognition"].strip() content = cast(str, chatbot_msg.extensions["content"]["recognition"]).strip()
if not content: if not content:
content = message.data.get("text", {}).get("content", "").strip() text_data = cast(dict[str, Any], message_data.get("text", {}))
content = cast(str, text_data.get("content", "")).strip()
# Handle file/image messages # Handle file/image messages
file_paths = [] file_paths: list[str] = []
if chatbot_msg.message_type == "picture" and chatbot_msg.image_content: if chatbot_msg.message_type == "picture" and chatbot_msg.image_content:
download_code = chatbot_msg.image_content.download_code download_code = chatbot_msg.image_content.download_code
if download_code: if download_code:
@@ -93,8 +100,18 @@ class NanobotDingTalkHandler(CallbackHandler):
content = content or "[Image]" content = content or "[Image]"
elif chatbot_msg.message_type == "file": elif chatbot_msg.message_type == "file":
download_code = message.data.get("content", {}).get("downloadCode") or message.data.get("downloadCode") message_content = cast(dict[str, Any], message_data.get("content", {}))
fname = message.data.get("content", {}).get("fileName") or message.data.get("fileName") or "file" download_code = cast(
str,
message_content.get("downloadCode")
or message_data.get("downloadCode"),
)
fname = cast(
str,
message_content.get("fileName")
or message_data.get("fileName")
or "file",
)
if download_code: if download_code:
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown" sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid) fp = await self.channel._download_dingtalk_file(download_code, fname, sender_uid)
@@ -103,13 +120,17 @@ class NanobotDingTalkHandler(CallbackHandler):
content = content or "[File]" content = content or "[File]"
elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content: elif chatbot_msg.message_type == "richText" and chatbot_msg.rich_text_content:
rich_list = chatbot_msg.rich_text_content.rich_text_list or [] rich_list = cast(
for item in rich_list: list[object],
if not isinstance(item, dict): chatbot_msg.rich_text_content.rich_text_list or [],
)
for item_value in rich_list:
if not isinstance(item_value, dict):
continue continue
item = cast(dict[str, Any], item_value)
# A rich-text item may carry text and/or a downloadCode; the # A rich-text item may carry text and/or a downloadCode; the
# DingTalk SDK treats them independently, so handle both. # DingTalk SDK treats them independently, so handle both.
t = item.get("text", "").strip() t = cast(str, item.get("text", "")).strip()
if t: if t:
fmt = item.get("type", "") fmt = item.get("type", "")
if fmt == "bold": if fmt == "bold":
@@ -124,8 +145,8 @@ class NanobotDingTalkHandler(CallbackHandler):
formatted = t formatted = t
content = (content + " " + formatted).strip() if content else formatted content = (content + " " + formatted).strip() if content else formatted
if item.get("downloadCode"): if item.get("downloadCode"):
dc = item["downloadCode"] dc = cast(str, item["downloadCode"])
fname = item.get("fileName") or "file" fname = cast(str, item.get("fileName") or "file")
sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown" sender_uid = chatbot_msg.sender_staff_id or chatbot_msg.sender_id or "unknown"
fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid) fp = await self.channel._download_dingtalk_file(dc, fname, sender_uid)
if fp: if fp:
@@ -143,13 +164,22 @@ class NanobotDingTalkHandler(CallbackHandler):
) )
return AckMessage.STATUS_OK, "OK" return AckMessage.STATUS_OK, "OK"
sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_id = cast(
sender_name = chatbot_msg.sender_nick or "Unknown" str | None,
chatbot_msg.sender_staff_id or chatbot_msg.sender_id,
)
sender_name = cast(str, chatbot_msg.sender_nick or "Unknown")
conversation_type = message.data.get("conversationType") conversation_type = cast(
str | None,
message_data.get("conversationType"),
)
conversation_id = ( conversation_id = (
message.data.get("conversationId") cast(
or message.data.get("openConversationId") str | None,
message_data.get("conversationId")
or message_data.get("openConversationId"),
)
) )
self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content) self.channel.logger.info("Received message from {} ({}): {}", sender_name, sender_id, content)
@@ -218,14 +248,14 @@ class DingTalkChannel(BaseChannel):
self.config: DingTalkConfig = config self.config: DingTalkConfig = config
self._client: Any = None self._client: Any = None
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._start_task: asyncio.Task | None = None self._start_task: asyncio.Task[Any] | None = None
# Access Token management for sending messages # Access Token management for sending messages
self._access_token: str | None = None self._access_token: str | None = None
self._token_expiry: float = 0 self._token_expiry: float = 0
# Hold references to background tasks to prevent GC # Hold references to background tasks to prevent GC
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task[None]] = set()
async def start(self) -> None: async def start(self) -> None:
"""Start the DingTalk bot with Stream Mode.""" """Start the DingTalk bot with Stream Mode."""
@@ -575,7 +605,11 @@ class DingTalkChannel(BaseChannel):
try: try:
resp = await self._http.post(url, files=files) resp = await self._http.post(url, files=files)
text = resp.text text = resp.text
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} result = (
cast(dict[str, Any], resp.json())
if resp.headers.get("content-type", "").startswith("application/json")
else {}
)
if resp.status_code >= 400: if resp.status_code >= 400:
self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500]) self.logger.error("media upload failed status={} type={} body={}", resp.status_code, media_type, text[:500])
return None return None
@@ -583,7 +617,7 @@ class DingTalkChannel(BaseChannel):
if errcode != 0: if errcode != 0:
self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500]) self.logger.error("media upload api error type={} errcode={} body={}", media_type, errcode, text[:500])
return None return None
sub = result.get("result") or {} sub = cast(dict[str, Any], result.get("result") or {})
media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId") media_id = result.get("media_id") or result.get("mediaId") or sub.get("media_id") or sub.get("mediaId")
if not media_id: if not media_id:
self.logger.error("media upload missing media_id body={}", text[:500]) self.logger.error("media upload missing media_id body={}", text[:500])
@@ -634,7 +668,7 @@ class DingTalkChannel(BaseChannel):
self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500]) self.logger.error("send failed msgKey={} status={} body={}", msg_key, resp.status_code, body[:500])
return False return False
try: try:
result = resp.json() result = cast(dict[str, Any], resp.json())
except Exception: except Exception:
result = {} result = {}
errcode = result.get("errcode") errcode = result.get("errcode")
+19 -16
View File
@@ -1,4 +1,5 @@
"""Discord channel implementation using discord.py.""" """Discord channel implementation using discord.py."""
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
from __future__ import annotations from __future__ import annotations
@@ -8,7 +9,7 @@ import time
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING, Any, Literal, cast
from pydantic import Field from pydantic import Field
@@ -43,7 +44,7 @@ class _StreamBuf:
"""Per-chat streaming accumulator for progressive Discord message edits.""" """Per-chat streaming accumulator for progressive Discord message edits."""
text: str = "" text: str = ""
message: Any | None = None message: discord.Message | None = None
last_edit: float = 0.0 last_edit: float = 0.0
stream_id: str | None = None stream_id: str | None = None
@@ -266,13 +267,14 @@ if DISCORD_AVAILABLE:
self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e) self._channel.logger.warning("channel {} unavailable: {}", msg.chat_id, e)
raise raise
reference, mention_settings = self._build_reply_context(channel, msg.reply_to) messageable_channel = cast(Messageable, channel)
reference, mention_settings = self._build_reply_context(messageable_channel, msg.reply_to)
sent_media = False sent_media = False
failed_media: list[str] = [] failed_media: list[str] = []
for index, media_path in enumerate(msg.media or []): for index, media_path in enumerate(msg.media or []):
if await self._send_file( if await self._send_file(
channel, messageable_channel,
media_path, media_path,
reference=reference if index == 0 else None, reference=reference if index == 0 else None,
mention_settings=mention_settings, mention_settings=mention_settings,
@@ -288,7 +290,7 @@ if DISCORD_AVAILABLE:
if index == 0 and reference is not None and not sent_media: if index == 0 and reference is not None and not sent_media:
kwargs["reference"] = reference kwargs["reference"] = reference
kwargs["allowed_mentions"] = mention_settings kwargs["allowed_mentions"] = mention_settings
await channel.send(**kwargs) await messageable_channel.send(**kwargs)
async def _send_file( async def _send_file(
self, self,
@@ -344,7 +346,7 @@ if DISCORD_AVAILABLE:
self._channel.logger.warning("Invalid reply target: {}", reply_to) self._channel.logger.warning("Invalid reply target: {}", reply_to)
return None, mention_settings return None, mention_settings
return channel.get_partial_message(message_id), mention_settings return cast(Any, channel).get_partial_message(message_id), mention_settings
class DiscordChannel(BaseChannel): class DiscordChannel(BaseChannel):
@@ -423,8 +425,8 @@ class DiscordChannel(BaseChannel):
import aiohttp import aiohttp
proxy_auth = aiohttp.BasicAuth( proxy_auth = aiohttp.BasicAuth(
login=self.config.proxy_username, login=cast(str, self.config.proxy_username),
password=self.config.proxy_password, password=cast(str, self.config.proxy_password),
) )
elif has_user != has_pass: elif has_user != has_pass:
self.logger.warning( self.logger.warning(
@@ -507,7 +509,7 @@ class DiscordChannel(BaseChannel):
return return
if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id: if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
return return
await self._finalize_stream(chat_id, buf) await self._finalize_stream(chat_id, buf, buf.message)
return return
buf = self._stream_bufs.get(chat_id) buf = self._stream_bufs.get(chat_id)
@@ -635,7 +637,12 @@ class DiscordChannel(BaseChannel):
self.logger.warning("channel {} unavailable: {}", chat_id, e) self.logger.warning("channel {} unavailable: {}", chat_id, e)
return None return None
async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None: async def _finalize_stream(
self,
chat_id: str,
buf: _StreamBuf,
message: discord.Message,
) -> None:
"""Commit the final streamed content and flush overflow chunks.""" """Commit the final streamed content and flush overflow chunks."""
chunks = DiscordBotClient._build_chunks(buf.text, [], False) chunks = DiscordBotClient._build_chunks(buf.text, [], False)
if not chunks: if not chunks:
@@ -643,16 +650,12 @@ class DiscordChannel(BaseChannel):
return return
try: try:
await buf.message.edit(content=chunks[0]) await message.edit(content=chunks[0])
except Exception as e: except Exception as e:
self.logger.warning("final stream edit failed: {}", e) self.logger.warning("final stream edit failed: {}", e)
raise raise
target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id) target = message.channel
if target is None:
self.logger.warning("stream follow-up target {} unavailable", chat_id)
self._stream_bufs.pop(chat_id, None)
return
for extra_chunk in chunks[1:]: for extra_chunk in chunks[1:]:
await target.send(content=extra_chunk) await target.send(content=extra_chunk)
+12 -8
View File
@@ -17,7 +17,7 @@ from email.parser import BytesParser
from email.utils import parseaddr from email.utils import parseaddr
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Literal, cast
from loguru import logger from loguru import logger
from pydantic import Field from pydantic import Field
@@ -188,7 +188,9 @@ class EmailChannel(BaseChannel):
self.logger.exception("Error delivering email from {}", sender) self.logger.exception("Error delivering email from {}", sender)
continue continue
uid = str((item.get("metadata") or {}).get("uid") or "") metadata = item.get("metadata")
metadata_data = cast(dict[str, Any], metadata) if isinstance(metadata, dict) else {}
uid = str(metadata_data.get("uid") or "")
if uid and should_apply_post_action: if uid and should_apply_post_action:
post_actions_uids.add(uid) post_actions_uids.add(uid)
@@ -312,7 +314,7 @@ class EmailChannel(BaseChannel):
raise raise
def _validate_config(self) -> bool: def _validate_config(self) -> bool:
missing = [] missing: list[str] = []
if not self.config.imap_host: if not self.config.imap_host:
missing.append("imap_host") missing.append("imap_host")
if not self.config.imap_username: if not self.config.imap_username:
@@ -427,7 +429,7 @@ class EmailChannel(BaseChannel):
messages: list[dict[str, Any]], messages: list[dict[str, Any]],
skipped_uids: set[str], skipped_uids: set[str],
cycle_uids: set[str], cycle_uids: set[str],
) -> None: ) -> list[dict[str, Any]] | None:
"""Fetch messages by arbitrary IMAP search criteria.""" """Fetch messages by arbitrary IMAP search criteria."""
mailbox = self.config.imap_mailbox or "INBOX" mailbox = self.config.imap_mailbox or "INBOX"
@@ -765,8 +767,10 @@ class EmailChannel(BaseChannel):
@staticmethod @staticmethod
def _extract_message_bytes(fetched: list[Any]) -> bytes | None: def _extract_message_bytes(fetched: list[Any]) -> bytes | None:
for item in fetched: for item in fetched:
if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)): if isinstance(item, tuple):
return bytes(item[1]) fetched_item = cast(tuple[Any, ...], item)
if len(fetched_item) >= 2 and isinstance(fetched_item[1], (bytes, bytearray)):
return bytes(fetched_item[1])
return None return None
@staticmethod @staticmethod
@@ -837,8 +841,8 @@ class EmailChannel(BaseChannel):
""" """
spf_pass = False spf_pass = False
dkim_pass = False dkim_pass = False
for ar_header in parsed_msg.get_all("Authentication-Results") or []: for ar_header in cast(list[Any], parsed_msg.get_all("Authentication-Results") or []):
ar_lower = ar_header.lower() ar_lower = str(ar_header).lower()
if re.search(r"\bspf\s*=\s*pass\b", ar_lower): if re.search(r"\bspf\s*=\s*pass\b", ar_lower):
spf_pass = True spf_pass = True
if re.search(r"\bdkim\s*=\s*pass\b", ar_lower): if re.search(r"\bdkim\s*=\s*pass\b", ar_lower):
+2
View File
@@ -1,5 +1,7 @@
"""Short-lived WebUI channel connection sessions.""" """Short-lived WebUI channel connection sessions."""
# pyright: reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
+13 -12
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
from typing import Any from typing import Any, cast
from loguru import logger from loguru import logger
@@ -46,7 +46,7 @@ def update_managed_feishu_instance(
*, *,
instance_id: str = DEFAULT_INSTANCE_ID, instance_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]: ) -> dict[str, Any]:
existing = section if isinstance(section, dict) else {} existing = cast(dict[str, Any], section) if isinstance(section, dict) else {}
return upsert_feishu_instance( return upsert_feishu_instance(
existing, existing,
feishu_default_config(), feishu_default_config(),
@@ -69,8 +69,8 @@ def _normalize_feishu_instance(
inherited: dict[str, Any] | None = None, inherited: dict[str, Any] | None = None,
fallback_id: str = DEFAULT_INSTANCE_ID, fallback_id: str = DEFAULT_INSTANCE_ID,
) -> dict[str, Any]: ) -> dict[str, Any]:
config = merge_missing_defaults(inherited or {}, defaults) config = cast(dict[str, Any], merge_missing_defaults(inherited or {}, defaults))
config = merge_missing_defaults(raw, config) config = cast(dict[str, Any], merge_missing_defaults(raw, config))
raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id raw_id = raw.get("id") or raw.get("instanceId") or raw.get("instance_id") or fallback_id
instance_id = validate_instance_id(str(raw_id)) instance_id = validate_instance_id(str(raw_id))
@@ -97,12 +97,13 @@ def _feishu_instance_inputs(
section = section.model_dump(mode="json", by_alias=True) section = section.model_dump(mode="json", by_alias=True)
if not isinstance(section, dict): if not isinstance(section, dict):
section = {} section = {}
section_data = cast(dict[str, Any], section)
instances = section.get("instances") instances = section_data.get("instances")
if isinstance(instances, list): if isinstance(instances, list):
inherited = {key: value for key, value in section.items() if key != "instances"} inherited = {key: value for key, value in section_data.items() if key != "instances"}
return list(instances), inherited return list(cast(list[Any], instances)), inherited
return ([section] if section else [_base_feishu_instance_config(defaults)]), None return ([section_data] if section_data else [_base_feishu_instance_config(defaults)]), None
def feishu_instance_specs( def feishu_instance_specs(
@@ -124,7 +125,7 @@ def feishu_instance_specs(
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}" fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try: try:
config = _normalize_feishu_instance( config = _normalize_feishu_instance(
raw, cast(dict[str, Any], raw),
defaults, defaults,
inherited=inherited, inherited=inherited,
fallback_id=fallback_id, fallback_id=fallback_id,
@@ -179,7 +180,7 @@ def canonical_feishu_section(section: Any, defaults: dict[str, Any]) -> dict[str
fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}" fallback_id = DEFAULT_INSTANCE_ID if index == 0 else f"assistant-{index + 1}"
try: try:
config = _normalize_feishu_instance( config = _normalize_feishu_instance(
raw, cast(dict[str, Any], raw),
defaults, defaults,
inherited=inherited, inherited=inherited,
fallback_id=fallback_id, fallback_id=fallback_id,
@@ -238,9 +239,9 @@ def update_feishu_instance_preserving_shape(
if ( if (
instance_id == DEFAULT_INSTANCE_ID instance_id == DEFAULT_INSTANCE_ID
and isinstance(section, dict) and isinstance(section, dict)
and not isinstance(section.get("instances"), list) and not isinstance(cast(dict[str, Any], section).get("instances"), list)
): ):
return {**section, **values} return {**cast(dict[str, Any], section), **values}
return upsert_feishu_instance(section, defaults, instance_id, values) return upsert_feishu_instance(section, defaults, instance_id, values)
+218 -132
View File
@@ -1,4 +1,5 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection.""" """Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
# pyright: reportMissingModuleSource=false, reportMissingTypeStubs=false
from __future__ import annotations from __future__ import annotations
@@ -14,8 +15,9 @@ from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from functools import partial
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, TypedDict, cast
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
@@ -44,7 +46,10 @@ from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging from nanobot.utils.logging_bridge import redirect_lib_logging
if TYPE_CHECKING: if TYPE_CHECKING:
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1 from lark_oapi.api.im.v1.model import ( # pyright: ignore[reportMissingTypeStubs]
MentionEvent,
P2ImMessageReceiveV1,
)
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
_LOGIN_CONSOLE = Console() _LOGIN_CONSOLE = Console()
@@ -55,6 +60,20 @@ def _identity_timestamp() -> str:
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z") return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
def _as_json_object(value: Any) -> dict[str, Any] | None:
"""Narrow untyped SDK/JSON objects at the channel boundary."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _as_json_list(value: Any) -> list[Any] | None:
"""Narrow untyped SDK/JSON arrays at the channel boundary."""
return cast(list[Any], value) if isinstance(value, list) else None
def _ignore_event(_: Any) -> None:
"""Consume SDK events that intentionally have no channel action."""
def _load_lark_runtime() -> tuple[Any, str, str]: def _load_lark_runtime() -> tuple[Any, str, str]:
"""Import the heavy Feishu SDK lazily. """Import the heavy Feishu SDK lazily.
@@ -69,9 +88,12 @@ def _load_lark_runtime() -> tuple[Any, str, str]:
# close the same loop. # close the same loop.
with _LARK_RUNTIME_LOCK: with _LARK_RUNTIME_LOCK:
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
import lark_oapi as lark import lark_oapi as lark # pyright: ignore[reportMissingTypeStubs]
import lark_oapi.ws.client as lark_ws_client import lark_oapi.ws.client as lark_ws_client # pyright: ignore[reportMissingTypeStubs]
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN from lark_oapi.core.const import ( # pyright: ignore[reportMissingTypeStubs]
FEISHU_DOMAIN,
LARK_DOMAIN,
)
if ( if (
not ws_client_already_imported not ws_client_already_imported
@@ -106,7 +128,7 @@ def fetch_feishu_app_identity(
try: try:
lark, feishu_domain, lark_domain = _load_lark_runtime() lark, feishu_domain, lark_domain = _load_lark_runtime()
from lark_oapi.api.application.v6.model.get_application_request import ( from lark_oapi.api.application.v6.model.get_application_request import ( # pyright: ignore[reportMissingTypeStubs]
GetApplicationRequest, GetApplicationRequest,
) )
@@ -151,9 +173,9 @@ MSG_TYPE_MAP = {
} }
def _extract_share_card_content(content_json: dict, msg_type: str) -> str: def _extract_share_card_content(content_json: dict[str, Any], msg_type: str) -> str:
"""Extract text representation from share cards and interactive messages.""" """Extract text representation from share cards and interactive messages."""
parts = [] parts: list[str] = []
if msg_type == "share_chat": if msg_type == "share_chat":
parts.append(f"[shared chat: {content_json.get('chat_id', '')}]") parts.append(f"[shared chat: {content_json.get('chat_id', '')}]")
@@ -171,9 +193,9 @@ def _extract_share_card_content(content_json: dict, msg_type: str) -> str:
return "\n".join(parts) if parts else f"[{msg_type}]" return "\n".join(parts) if parts else f"[{msg_type}]"
def _extract_interactive_content(content: dict) -> list[str]: def _extract_interactive_content(content: str | dict[str, Any]) -> list[str]:
"""Recursively extract text and links from interactive card content.""" """Recursively extract text and links from interactive card content."""
parts = [] parts: list[str] = []
if isinstance(content, str): if isinstance(content, str):
try: try:
@@ -189,8 +211,9 @@ def _extract_interactive_content(content: dict) -> list[str]:
if isinstance(user_dsl, str) and user_dsl.strip(): if isinstance(user_dsl, str) and user_dsl.strip():
try: try:
dsl = json.loads(user_dsl) dsl = json.loads(user_dsl)
if isinstance(dsl, dict): dsl_object = _as_json_object(dsl)
parts.extend(_extract_interactive_content(dsl)) if dsl_object is not None:
parts.extend(_extract_interactive_content(dsl_object))
if parts: if parts:
return parts return parts
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
@@ -198,8 +221,9 @@ def _extract_interactive_content(content: dict) -> list[str]:
if "title" in content: if "title" in content:
title = content["title"] title = content["title"]
if isinstance(title, dict): title_object = _as_json_object(title)
title_content = title.get("content", "") or title.get("text", "") if title_object is not None:
title_content = title_object.get("content", "") or title_object.get("text", "")
if title_content: if title_content:
parts.append(f"title: {title_content}") parts.append(f"title: {title_content}")
elif isinstance(title, str): elif isinstance(title, str):
@@ -207,34 +231,39 @@ def _extract_interactive_content(content: dict) -> list[str]:
# Top-level elements: flat list or nested list format # Top-level elements: flat list or nested list format
elements = content.get("elements") elements = content.get("elements")
if isinstance(elements, list): elements_list = _as_json_list(elements)
if elements and isinstance(elements[0], list): if elements_list is not None:
if elements_list and isinstance(elements_list[0], list):
# Nested list: [[{tag:"text",text:"..."}], ...] # Nested list: [[{tag:"text",text:"..."}], ...]
for row in elements: for row in elements_list:
if isinstance(row, list): row_list = _as_json_list(row)
for element in row: if row_list is not None:
for element in row_list:
parts.extend(_extract_element_content(element)) parts.extend(_extract_element_content(element))
else: else:
# Flat list: [{tag:"markdown",content:"..."}, ...] # Flat list: [{tag:"markdown",content:"..."}, ...]
for element in elements: for element in elements_list:
parts.extend(_extract_element_content(element)) parts.extend(_extract_element_content(element))
# Body elements (schema 2.0) # Body elements (schema 2.0)
body = content.get("body", {}) body = content.get("body", {})
if isinstance(body, dict): body_object = _as_json_object(body)
body_elements = body.get("elements") if body_object is not None:
if isinstance(body_elements, list): body_elements = _as_json_list(body_object.get("elements"))
if body_elements is not None:
for element in body_elements: for element in body_elements:
parts.extend(_extract_element_content(element)) parts.extend(_extract_element_content(element))
card = content.get("card", {}) card = content.get("card", {})
if card: card_object = _as_json_object(card)
parts.extend(_extract_interactive_content(card)) if card_object:
parts.extend(_extract_interactive_content(card_object))
header = content.get("header", {}) header = content.get("header", {})
if header: header_object = _as_json_object(header)
header_title = header.get("title", {}) if header_object is not None:
if isinstance(header_title, dict): header_title = _as_json_object(header_object.get("title", {}))
if header_title is not None:
header_text = header_title.get("content", "") or header_title.get("text", "") header_text = header_title.get("content", "") or header_title.get("text", "")
if header_text: if header_text:
parts.append(f"title: {header_text}") parts.append(f"title: {header_text}")
@@ -242,13 +271,16 @@ def _extract_interactive_content(content: dict) -> list[str]:
return parts return parts
def _extract_element_content(element: dict) -> list[str]: def _extract_element_content(element: Any) -> list[str]:
"""Extract content from a single card element.""" """Extract content from a single card element."""
parts = [] parts: list[str] = []
if not isinstance(element, dict): element_object = _as_json_object(element)
if element_object is None:
return parts return parts
element = element_object
tag = element.get("tag", "") tag = element.get("tag", "")
if tag in ("markdown", "lark_md"): if tag in ("markdown", "lark_md"):
@@ -263,16 +295,18 @@ def _extract_element_content(element: dict) -> list[str]:
elif tag == "div": elif tag == "div":
text = element.get("text", {}) text = element.get("text", {})
if isinstance(text, dict): text_object = _as_json_object(text)
text_content = text.get("content", "") or text.get("text", "") if text_object is not None:
text_content = text_object.get("content", "") or text_object.get("text", "")
if text_content: if text_content:
parts.append(text_content) parts.append(text_content)
elif isinstance(text, str): elif isinstance(text, str):
parts.append(text) parts.append(text)
for field in element.get("fields") or []: for field in _as_json_list(element.get("fields")) or []:
if isinstance(field, dict): field_object = _as_json_object(field)
field_text = field.get("text", {}) if field_object is not None:
if isinstance(field_text, dict): field_text = _as_json_object(field_object.get("text", {}))
if field_text is not None:
c = field_text.get("content", "") c = field_text.get("content", "")
if c: if c:
parts.append(c) parts.append(c)
@@ -287,30 +321,33 @@ def _extract_element_content(element: dict) -> list[str]:
elif tag == "button": elif tag == "button":
text = element.get("text", {}) text = element.get("text", {})
if isinstance(text, dict): text_object = _as_json_object(text)
c = text.get("content", "") if text_object is not None:
c = text_object.get("content", "")
if c: if c:
parts.append(c) parts.append(c)
multi_url = element.get("multi_url") or {} multi_url: Any = element.get("multi_url") or {}
multi_url_object = _as_json_object(multi_url)
url = element.get("url", "") or ( url = element.get("url", "") or (
multi_url.get("url", "") if isinstance(multi_url, dict) else "" multi_url_object.get("url", "") if multi_url_object is not None else ""
) )
if url: if url:
parts.append(f"link: {url}") parts.append(f"link: {url}")
elif tag == "img": elif tag == "img":
alt = element.get("alt", {}) alt = _as_json_object(element.get("alt", {}))
parts.append(alt.get("content", "[image]") if isinstance(alt, dict) else "[image]") parts.append(alt.get("content", "[image]") if alt is not None else "[image]")
elif tag == "note": elif tag == "note":
for ne in element.get("elements") or []: for ne in _as_json_list(element.get("elements")) or []:
parts.extend(_extract_element_content(ne)) parts.extend(_extract_element_content(ne))
elif tag == "column_set": elif tag == "column_set":
for col in element.get("columns") or []: for col in _as_json_list(element.get("columns")) or []:
if not isinstance(col, dict): col_object = _as_json_object(col)
if col_object is None:
continue continue
for ce in col.get("elements") or []: for ce in _as_json_list(col_object.get("elements")) or []:
parts.extend(_extract_element_content(ce)) parts.extend(_extract_element_content(ce))
elif tag == "plain_text": elif tag == "plain_text":
@@ -319,36 +356,44 @@ def _extract_element_content(element: dict) -> list[str]:
parts.append(content) parts.append(content)
elif tag == "table": elif tag == "table":
columns = [ columns: list[tuple[str, str]] = []
(column["name"], str(column.get("display_name") or column["name"])) for column in _as_json_list(element.get("columns")) or []:
for column in (element.get("columns") or []) column_object = _as_json_object(column)
if isinstance(column, dict) and column.get("name") if column_object is None:
] continue
rows = element.get("rows") or [] name = column_object.get("name")
if isinstance(name, str) and name:
columns.append((name, str(column_object.get("display_name") or name)))
rows = _as_json_list(element.get("rows")) or []
if columns: if columns:
parts.append(" | ".join(header for _, header in columns)) parts.append(" | ".join(header for _, header in columns))
if isinstance(rows, list): if rows:
for row in rows: for row in rows:
if not isinstance(row, dict): row_object = _as_json_object(row)
if row_object is None:
continue continue
values = [] values: list[str] = []
for name, _ in columns: for name, _ in columns:
value = row.get(name) value = row_object.get(name)
if isinstance(value, list): if isinstance(value, list):
value = " ".join(str(item).strip() for item in value if item is not None) value = " ".join(
str(item).strip()
for item in cast(list[Any], value)
if item is not None
)
values.append("" if value is None else str(value).strip()) values.append("" if value is None else str(value).strip())
row_text = " | ".join(values).strip() row_text = " | ".join(values).strip()
if row_text: if row_text:
parts.append(row_text) parts.append(row_text)
else: else:
for ne in element.get("elements") or []: for ne in _as_json_list(element.get("elements")) or []:
parts.extend(_extract_element_content(ne)) parts.extend(_extract_element_content(ne))
return parts return parts
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]: def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]:
"""Extract text and image keys from Feishu post (rich text) message. """Extract text and image keys from Feishu post (rich text) message.
Handles three payload shapes: Handles three payload shapes:
@@ -357,45 +402,48 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}} - Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
""" """
def _parse_block(block: dict) -> tuple[str | None, list[str]]: def _parse_block(block: dict[str, Any]) -> tuple[str | None, list[str]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list): content = _as_json_list(block.get("content"))
if content is None:
return None, [] return None, []
texts, images = [], [] texts: list[str] = []
images: list[str] = []
title = block.get("title") title = block.get("title")
if isinstance(title, str) and title: if isinstance(title, str) and title:
texts.append(title) texts.append(title)
for row in block["content"]: for row in content:
if not isinstance(row, list): row_items = _as_json_list(row)
if row_items is None:
continue continue
for el in row: for el in row_items:
if not isinstance(el, dict): element = _as_json_object(el)
if element is None:
continue continue
tag = el.get("tag") tag = element.get("tag")
if tag in ("text", "a"): if tag in ("text", "a"):
text = el.get("text", "") text = element.get("text", "")
if isinstance(text, str): if isinstance(text, str):
texts.append(text) texts.append(text)
elif tag == "at": elif tag == "at":
user = el.get("user_name", "user") user = element.get("user_name", "user")
texts.append(f"@{user if isinstance(user, str) and user else 'user'}") texts.append(f"@{user if isinstance(user, str) and user else 'user'}")
elif tag == "code_block": elif tag == "code_block":
lang = el.get("language", "") lang = element.get("language", "")
code_text = el.get("text", "") code_text = element.get("text", "")
if not isinstance(lang, str): if not isinstance(lang, str):
lang = "" lang = ""
if not isinstance(code_text, str): if not isinstance(code_text, str):
code_text = "" code_text = ""
texts.append(f"\n```{lang}\n{code_text}\n```\n") texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and (key := el.get("image_key")): elif tag == "img" and isinstance((key := element.get("image_key")), str):
images.append(key) images.append(key)
return (" ".join(texts).strip() or None), images return (" ".join(texts).strip() or None), images
# Unwrap optional {"post": ...} envelope # Unwrap optional {"post": ...} envelope
root = content_json root = content_json
if isinstance(root, dict) and isinstance(root.get("post"), dict): post = _as_json_object(root.get("post"))
root = root["post"] if post is not None:
if not isinstance(root, dict): root = post
return "", []
# Direct format # Direct format
if "content" in root: if "content" in root:
@@ -406,19 +454,23 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
# Localized: prefer known locales, then fall back to any dict child # Localized: prefer known locales, then fall back to any dict child
for key in ("zh_cn", "en_us", "ja_jp"): for key in ("zh_cn", "en_us", "ja_jp"):
if key in root: if key in root:
text, imgs = _parse_block(root[key]) block = _as_json_object(root[key])
if block is None:
continue
text, imgs = _parse_block(block)
if text or imgs: if text or imgs:
return text or "", imgs return text or "", imgs
for val in root.values(): for val in root.values():
if isinstance(val, dict): block = _as_json_object(val)
text, imgs = _parse_block(val) if block is not None:
text, imgs = _parse_block(block)
if text or imgs: if text or imgs:
return text or "", imgs return text or "", imgs
return "", [] return "", []
def _extract_post_text(content_json: dict) -> str: def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
"""Extract plain text from Feishu post (rich text) message content. """Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text. Legacy wrapper for _extract_post_content, returns only text.
@@ -442,11 +494,18 @@ _REGISTRATION_PATH = "/oauth/v1/app/registration"
_ONBOARD_REQUEST_TIMEOUT_S = 10 _ONBOARD_REQUEST_TIMEOUT_S = 10
class _RegistrationStart(TypedDict):
device_code: str
qr_url: str
interval: int
expire_in: int
def _accounts_base_url(domain: str) -> str: def _accounts_base_url(domain: str) -> str:
return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"]) return _ONBOARD_ACCOUNTS_URLS.get(domain, _ONBOARD_ACCOUNTS_URLS["feishu"])
def _post_registration(base_url: str, body: dict[str, str]) -> dict: def _post_registration(base_url: str, body: dict[str, str]) -> dict[str, Any]:
"""POST form-encoded data to the registration endpoint, return parsed JSON. """POST form-encoded data to the registration endpoint, return parsed JSON.
The registration endpoint returns JSON even on HTTP errors (e.g. poll The registration endpoint returns JSON even on HTTP errors (e.g. poll
@@ -462,7 +521,8 @@ def _post_registration(base_url: str, body: dict[str, str]) -> dict:
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
) )
try: try:
return resp.json() parsed = resp.json()
return _as_json_object(parsed) or {}
except json.JSONDecodeError: except json.JSONDecodeError:
resp.raise_for_status() resp.raise_for_status()
return {} return {}
@@ -472,7 +532,7 @@ def _init_registration(domain: str = "feishu") -> None:
"""Verify the environment supports client_secret auth. Raises RuntimeError if not.""" """Verify the environment supports client_secret auth. Raises RuntimeError if not."""
base_url = _accounts_base_url(domain) base_url = _accounts_base_url(domain)
res = _post_registration(base_url, {"action": "init"}) res = _post_registration(base_url, {"action": "init"})
methods = res.get("supported_auth_methods") or [] methods = _as_json_list(res.get("supported_auth_methods")) or []
if "client_secret" not in methods: if "client_secret" not in methods:
raise RuntimeError( raise RuntimeError(
f"Feishu / Lark registration does not support client_secret auth. " f"Feishu / Lark registration does not support client_secret auth. "
@@ -480,7 +540,7 @@ def _init_registration(domain: str = "feishu") -> None:
) )
def _begin_registration(domain: str = "feishu") -> dict: def _begin_registration(domain: str = "feishu") -> _RegistrationStart:
"""Start the device-code flow. Returns device_code, qr_url, interval, expire_in.""" """Start the device-code flow. Returns device_code, qr_url, interval, expire_in."""
base_url = _accounts_base_url(domain) base_url = _accounts_base_url(domain)
res = _post_registration(base_url, { res = _post_registration(base_url, {
@@ -490,16 +550,18 @@ def _begin_registration(domain: str = "feishu") -> dict:
"request_user_info": "open_id", "request_user_info": "open_id",
}) })
device_code = res.get("device_code") device_code = res.get("device_code")
if not device_code: if not isinstance(device_code, str) or not device_code:
raise RuntimeError("Feishu / Lark registration did not return a device_code") raise RuntimeError("Feishu / Lark registration did not return a device_code")
qr_url = res.get("verification_uri_complete", "") qr_url = res.get("verification_uri_complete", "")
if not qr_url: if not isinstance(qr_url, str) or not qr_url:
raise RuntimeError("Feishu / Lark registration did not return a login URL") raise RuntimeError("Feishu / Lark registration did not return a login URL")
interval = res.get("interval")
expire_in = res.get("expire_in")
return { return {
"device_code": device_code, "device_code": device_code,
"qr_url": qr_url, "qr_url": qr_url,
"interval": res.get("interval") or 5, "interval": interval if isinstance(interval, int) else 5,
"expire_in": res.get("expire_in") or 600, "expire_in": expire_in if isinstance(expire_in, int) else 600,
} }
@@ -509,7 +571,7 @@ def _poll_registration(
interval: int, interval: int,
expire_in: int, expire_in: int,
domain: str = "feishu", domain: str = "feishu",
) -> dict | None: ) -> dict[str, Any] | None:
"""Poll until the user scans the QR code, or timeout/denial. """Poll until the user scans the QR code, or timeout/denial.
Returns dict with app_id, app_secret, domain on success, None on failure. Returns dict with app_id, app_secret, domain on success, None on failure.
@@ -548,7 +610,7 @@ def poll_registration_once(
*, *,
device_code: str, device_code: str,
domain: str = "feishu", domain: str = "feishu",
) -> dict: ) -> dict[str, Any]:
"""Poll the Feishu/Lark device-code flow once. """Poll the Feishu/Lark device-code flow once.
This non-blocking shape is used by WebUI. The CLI keeps using This non-blocking shape is used by WebUI. The CLI keeps using
@@ -562,7 +624,7 @@ def poll_registration_once(
"tp": "ob_app", "tp": "ob_app",
}) })
user_info = res.get("user_info") or {} user_info = _as_json_object(res.get("user_info")) or {}
tenant_brand = user_info.get("tenant_brand") tenant_brand = user_info.get("tenant_brand")
if tenant_brand == "lark": if tenant_brand == "lark":
current_domain = "lark" current_domain = "lark"
@@ -641,9 +703,7 @@ def sync_saved_feishu_identity_boundary(
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
full_config = load_config() full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {} feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = feishu_default_config() defaults = feishu_default_config()
previous_identity_key = "" previous_identity_key = ""
@@ -675,7 +735,7 @@ def sync_saved_feishu_identity_boundary(
def save_registration_result( def save_registration_result(
result: dict, result: dict[str, Any],
*, *,
instance_id: str = DEFAULT_INSTANCE_ID, instance_id: str = DEFAULT_INSTANCE_ID,
name: str | None = None, name: str | None = None,
@@ -684,9 +744,7 @@ def save_registration_result(
from nanobot.config.loader import load_config, save_config from nanobot.config.loader import load_config, save_config
full_config = load_config() full_config = load_config()
feishu_cfg = getattr(full_config.channels, "feishu", None) or {} feishu_cfg = _as_json_object(getattr(full_config.channels, "feishu", None)) or {}
if not isinstance(feishu_cfg, dict):
feishu_cfg = {}
defaults = feishu_default_config() defaults = feishu_default_config()
app_id = str(result["app_id"]).strip() app_id = str(result["app_id"]).strip()
domain = str(result.get("domain", "feishu") or "feishu").strip().lower() domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
@@ -809,7 +867,7 @@ def refresh_saved_feishu_identities(
def qr_register( def qr_register(
*, *,
initial_domain: str = "feishu", initial_domain: str = "feishu",
) -> dict | None: ) -> dict[str, Any] | None:
"""Run the Feishu / Lark scan-to-create QR registration flow. """Run the Feishu / Lark scan-to-create QR registration flow.
Returns on success: Returns on success:
@@ -853,7 +911,7 @@ def _print_qr_code(url: str) -> None:
def _qr_register_inner( def _qr_register_inner(
*, *,
initial_domain: str, initial_domain: str,
) -> dict | None: ) -> dict[str, Any] | None:
"""Run init → begin → poll. Raises on network/protocol errors.""" """Run init → begin → poll. Raises on network/protocol errors."""
_LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]") _LOGIN_CONSOLE.print("[cyan]Preparing Feishu/Lark login...[/cyan]")
_init_registration(initial_domain) _init_registration(initial_domain)
@@ -935,7 +993,7 @@ class FeishuChannel(BaseChannel):
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {} self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
self._bot_open_id: str | None = None self._bot_open_id: str | None = None
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task[Any]] = set()
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1062,12 +1120,12 @@ class FeishuChannel(BaseChannel):
builder = self._register_optional_event( builder = self._register_optional_event(
builder, builder,
"register_p2_im_chat_member_bot_added_v1", "register_p2_im_chat_member_bot_added_v1",
lambda _: None, _ignore_event,
) )
builder = self._register_optional_event( builder = self._register_optional_event(
builder, builder,
"register_p2_im_chat_member_bot_deleted_v1", "register_p2_im_chat_member_bot_deleted_v1",
lambda _: None, _ignore_event,
) )
event_handler = builder.build() event_handler = builder.build()
@@ -1126,9 +1184,11 @@ class FeishuChannel(BaseChannel):
if response.success(): if response.success():
import json import json
data = json.loads(response.raw.content) data = _as_json_object(json.loads(response.raw.content)) or {}
bot = (data.get("data") or data).get("bot") or data.get("bot") or {} wrapped = _as_json_object(data.get("data")) or data
return bot.get("open_id") bot = _as_json_object(wrapped.get("bot")) or _as_json_object(data.get("bot")) or {}
open_id = bot.get("open_id")
return open_id if isinstance(open_id, str) else None
self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg) self.logger.warning("Failed to get bot info: code={}, msg={}", response.code, response.msg)
return None return None
except Exception as e: except Exception as e:
@@ -1218,7 +1278,7 @@ class FeishuChannel(BaseChannel):
if "@_all" in raw_content: if "@_all" in raw_content:
return True return True
for mention in getattr(message, "mentions", None) or []: for mention in cast(list[Any], getattr(message, "mentions", None) or []):
if self._is_bot_mention_event(mention): if self._is_bot_mention_event(mention):
return True return True
return False return False
@@ -1312,7 +1372,7 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id) await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
def _on_background_task_done(self, task: asyncio.Task) -> None: def _on_background_task_done(self, task: asyncio.Task[Any]) -> None:
"""Callback: remove from tracking set and log unhandled exceptions.""" """Callback: remove from tracking set and log unhandled exceptions."""
self._background_tasks.discard(task) self._background_tasks.discard(task)
if task.cancelled(): if task.cancelled():
@@ -1322,7 +1382,7 @@ class FeishuChannel(BaseChannel):
except Exception as exc: except Exception as exc:
self.logger.warning("Background task failed: {}", exc) self.logger.warning("Background task failed: {}", exc)
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None: def _on_reaction_added(self, message_id: str, task: asyncio.Task[Any]) -> None:
"""Callback: store reaction_id after background add-reaction completes.""" """Callback: store reaction_id after background add-reaction completes."""
if task.cancelled(): if task.cancelled():
return return
@@ -1375,7 +1435,7 @@ class FeishuChannel(BaseChannel):
return text return text
@classmethod @classmethod
def _parse_md_table(cls, table_text: str) -> dict | None: def _parse_md_table(cls, table_text: str) -> dict[str, Any] | None:
"""Parse a markdown table into a Feishu table element.""" """Parse a markdown table into a Feishu table element."""
lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()] lines = [_line.strip() for _line in table_text.strip().split("\n") if _line.strip()]
if len(lines) < 3: if len(lines) < 3:
@@ -1399,7 +1459,7 @@ class FeishuChannel(BaseChannel):
], ],
} }
def _build_card_elements(self, content: str) -> list[dict]: def _build_card_elements(self, content: str) -> list[dict[str, Any]]:
"""Split content into div/markdown + table elements for Feishu card.""" """Split content into div/markdown + table elements for Feishu card."""
protected = content protected = content
code_blocks: list[str] = [] code_blocks: list[str] = []
@@ -1407,7 +1467,8 @@ class FeishuChannel(BaseChannel):
code_blocks.append(m.group(1)) code_blocks.append(m.group(1))
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1) protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
elements, last_end = [], 0 elements: list[dict[str, Any]] = []
last_end = 0
for m in self._TABLE_RE.finditer(protected): for m in self._TABLE_RE.finditer(protected):
before = protected[last_end : m.start()] before = protected[last_end : m.start()]
if before.strip(): if before.strip():
@@ -1429,8 +1490,8 @@ class FeishuChannel(BaseChannel):
@staticmethod @staticmethod
def _split_elements_by_table_limit( def _split_elements_by_table_limit(
elements: list[dict], max_tables: int = 1 elements: list[dict[str, Any]], max_tables: int = 1
) -> list[list[dict]]: ) -> list[list[dict[str, Any]]]:
"""Split card elements into groups with at most *max_tables* table elements each. """Split card elements into groups with at most *max_tables* table elements each.
Feishu cards have a hard limit of one table per card (API error 11310). Feishu cards have a hard limit of one table per card (API error 11310).
@@ -1439,8 +1500,8 @@ class FeishuChannel(BaseChannel):
""" """
if not elements: if not elements:
return [[]] return [[]]
groups: list[list[dict]] = [] groups: list[list[dict[str, Any]]] = []
current: list[dict] = [] current: list[dict[str, Any]] = []
table_count = 0 table_count = 0
for el in elements: for el in elements:
if el.get("tag") == "table": if el.get("tag") == "table":
@@ -1457,15 +1518,15 @@ class FeishuChannel(BaseChannel):
groups.append(current) groups.append(current)
return groups or [[]] return groups or [[]]
def _split_headings(self, content: str) -> list[dict]: def _split_headings(self, content: str) -> list[dict[str, Any]]:
"""Split content by headings, converting headings to div elements.""" """Split content by headings, converting headings to div elements."""
protected = content protected = content
code_blocks = [] code_blocks: list[str] = []
for m in self._CODE_BLOCK_RE.finditer(content): for m in self._CODE_BLOCK_RE.finditer(content):
code_blocks.append(m.group(1)) code_blocks.append(m.group(1))
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1) protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks) - 1}\x00", 1)
elements = [] elements: list[dict[str, Any]] = []
last_end = 0 last_end = 0
for m in self._HEADING_RE.finditer(protected): for m in self._HEADING_RE.finditer(protected):
before = protected[last_end : m.start()].strip() before = protected[last_end : m.start()].strip()
@@ -1573,10 +1634,10 @@ class FeishuChannel(BaseChannel):
Each line becomes a paragraph (row) in the post body. Each line becomes a paragraph (row) in the post body.
""" """
lines = content.strip().split("\n") lines = content.strip().split("\n")
paragraphs: list[list[dict]] = [] paragraphs: list[list[dict[str, Any]]] = []
for line in lines: for line in lines:
elements: list[dict] = [] elements: list[dict[str, Any]] = []
last_end = 0 last_end = 0
for m in cls._MD_LINK_RE.finditer(line): for m in cls._MD_LINK_RE.finditer(line):
@@ -1768,7 +1829,7 @@ class FeishuChannel(BaseChannel):
return candidate return candidate
async def _download_and_save_media( async def _download_and_save_media(
self, msg_type: str, content_json: dict, message_id: str | None = None self, msg_type: str, content_json: dict[str, Any], message_id: str | None = None
) -> tuple[str | None, str]: ) -> tuple[str | None, str]:
""" """
Download media from Feishu and save to local disk. Download media from Feishu and save to local disk.
@@ -2306,8 +2367,11 @@ class FeishuChannel(BaseChannel):
fallback_msg_id = self._thread_reply_target(meta) fallback_msg_id = self._thread_reply_target(meta)
if fallback_msg_id: if fallback_msg_id:
await loop.run_in_executor( await loop.run_in_executor(
None, lambda: self._reply_message_sync( None, partial(
fallback_msg_id, "interactive", card, self._reply_message_sync,
fallback_msg_id,
"interactive",
card,
reply_in_thread=self._should_use_reply_in_thread(meta), reply_in_thread=self._should_use_reply_in_thread(meta),
), ),
) )
@@ -2563,6 +2627,9 @@ class FeishuChannel(BaseChannel):
return return
try: try:
event = data.event event = data.event
if event is None or event.message is None or event.sender is None:
self.logger.warning("Ignoring incomplete Feishu message event")
return
message = event.message message = event.message
sender = event.sender sender = event.sender
@@ -2579,6 +2646,20 @@ class FeishuChannel(BaseChannel):
chat_id = message.chat_id chat_id = message.chat_id
chat_type = message.chat_type chat_type = message.chat_type
msg_type = message.message_type msg_type = message.message_type
if not all(isinstance(value, str) and value for value in (
message_id,
sender_id,
chat_id,
chat_type,
msg_type,
)):
self.logger.warning("Ignoring Feishu message event with missing routing fields")
return
message_id = cast(str, message_id)
sender_id = cast(str, sender_id)
chat_id = cast(str, chat_id)
chat_type = cast(str, chat_type)
msg_type = cast(str, msg_type)
if chat_type == "group" and not self._is_group_message_for_bot(message): if chat_type == "group" and not self._is_group_message_for_bot(message):
self.logger.debug("skipping group message (not mentioned)") self.logger.debug("skipping group message (not mentioned)")
@@ -2616,17 +2697,19 @@ class FeishuChannel(BaseChannel):
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t)) task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
# Parse content # Parse content
content_parts = [] content_parts: list[str] = []
media_paths = [] media_paths: list[str] = []
try: try:
content_json = json.loads(message.content) if message.content else {} raw_content = message.content if isinstance(message.content, str) else ""
content_json = _as_json_object(json.loads(raw_content)) if raw_content else {}
except json.JSONDecodeError: except json.JSONDecodeError:
content_json = {} content_json = {}
content_json = content_json or {}
if msg_type == "text": if msg_type == "text":
text = content_json.get("text", "") text = content_json.get("text", "")
if text: if isinstance(text, str) and text:
mentions = getattr(message, "mentions", None) mentions = getattr(message, "mentions", None)
text = self._strip_leading_bot_mention(text, mentions) text = self._strip_leading_bot_mention(text, mentions)
text = self._resolve_mentions(text, mentions) text = self._resolve_mentions(text, mentions)
@@ -2676,9 +2759,12 @@ class FeishuChannel(BaseChannel):
content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")) content_parts.append(MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]"))
# Extract reply context (parent/root message IDs) # Extract reply context (parent/root message IDs)
parent_id = getattr(message, "parent_id", None) or None parent_id = getattr(message, "parent_id", None)
root_id = getattr(message, "root_id", None) or None root_id = getattr(message, "root_id", None)
thread_id = getattr(message, "thread_id", None) or None thread_id = getattr(message, "thread_id", None)
parent_id = parent_id if isinstance(parent_id, str) else None
root_id = root_id if isinstance(root_id, str) else None
thread_id = thread_id if isinstance(thread_id, str) else None
# Prepend quoted message text when the user replied to another message # Prepend quoted message text when the user replied to another message
if parent_id and self._client: if parent_id and self._client:
+7 -6
View File
@@ -1,3 +1,4 @@
# pyright: reportMissingTypeStubs=false, reportPrivateUsage=false
"""Shared Feishu/Lark WebSocket runtime. """Shared Feishu/Lark WebSocket runtime.
The official lark_oapi websocket client stores an asyncio loop in a module-level The official lark_oapi websocket client stores an asyncio loop in a module-level
@@ -148,7 +149,7 @@ class FeishuWsRunner:
async def _client_main( async def _client_main(
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
) -> None: ) -> None:
ping_task: asyncio.Task | None = None ping_task: asyncio.Task[None] | None = None
while not stop_event.is_set(): while not stop_event.is_set():
try: try:
await client._connect() await client._connect()
@@ -171,12 +172,12 @@ class FeishuWsRunner:
await client._disconnect() await client._disconnect()
_RUNNER: FeishuWsRunner | None = None _runner: FeishuWsRunner | None = None
def get_feishu_ws_runner() -> FeishuWsRunner: def get_feishu_ws_runner() -> FeishuWsRunner:
"""Return the process-wide Feishu WebSocket runner.""" """Return the process-wide Feishu WebSocket runner."""
global _RUNNER global _runner
if _RUNNER is None: if _runner is None:
_RUNNER = FeishuWsRunner() _runner = FeishuWsRunner()
return _RUNNER return _runner
+21 -13
View File
@@ -8,7 +8,7 @@ import inspect
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from loguru import logger from loguru import logger
@@ -41,7 +41,9 @@ from nanobot.utils.restart import (
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.cron.service import CronService
from nanobot.session.manager import SessionManager from nanobot.session.manager import SessionManager
from nanobot.triggers.local_store import LocalTriggerStore
def _default_webui_dist() -> Path | None: def _default_webui_dist() -> Path | None:
@@ -90,14 +92,15 @@ class ChannelManager:
bus: MessageBus, bus: MessageBus,
*, *,
session_manager: "SessionManager | None" = None, session_manager: "SessionManager | None" = None,
cron_service: Any | None = None, cron_service: CronService | None = None,
local_trigger_store: Any | None = None, local_trigger_store: LocalTriggerStore | None = None,
webui_runtime_model_name: Callable[[], str | None] | None = None, webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None, webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None, webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
webui_static_dist: bool = True, webui_static_dist: bool = True,
webui_runtime_surface: str = "browser", webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None, webui_runtime_capabilities: dict[str, Any] | None = None,
webui_skill_state_action: Callable[[set[str]], None] | None = None,
): ):
self.config = config self.config = config
self.bus = bus self.bus = bus
@@ -110,12 +113,13 @@ class ChannelManager:
self._webui_static_dist = webui_static_dist self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {}) self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
self._webui_skill_state_action = webui_skill_state_action
self.channels: dict[str, BaseChannel] = {} self.channels: dict[str, BaseChannel] = {}
self._channel_owners: dict[str, str] = {} self._channel_owners: dict[str, str] = {}
self._channel_runtime_specs: dict[str, tuple[str, str]] = {} self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
self._channel_errors: dict[str, str] = {} self._channel_errors: dict[str, str] = {}
self._channel_tasks: dict[str, asyncio.Task] = {} self._channel_tasks: dict[str, asyncio.Task[None]] = {}
self._dispatch_task: asyncio.Task | None = None self._dispatch_task: asyncio.Task[None] | None = None
self._started = False self._started = False
self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {} self._origin_reply_fingerprints: dict[tuple[str, str, str], str] = {}
@@ -176,6 +180,7 @@ class ChannelManager:
local_trigger_pending_ids=self._webui_local_trigger_pending_ids, local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
channel_feature_action=self.apply_channel_feature_action, channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status, channel_runtime_status=self.get_status,
skill_state_action=self._webui_skill_state_action,
logger=logger, logger=logger,
) )
kwargs["gateway"] = gateway kwargs["gateway"] = gateway
@@ -291,10 +296,11 @@ class ChannelManager:
for name, ch in self.channels.items(): for name, ch in self.channels.items():
cfg = ch.config cfg = ch.config
if isinstance(cfg, dict): if isinstance(cfg, dict):
if "allow_from" in cfg: config_data = cast(dict[str, Any], cfg)
allow = cfg.get("allow_from") if "allow_from" in config_data:
allow = config_data.get("allow_from")
else: else:
allow = cfg.get("allowFrom") allow = config_data.get("allowFrom")
else: else:
allow = getattr(cfg, "allow_from", None) allow = getattr(cfg, "allow_from", None)
if allow is None: if allow is None:
@@ -321,11 +327,12 @@ class ChannelManager:
Pydantic models. Pydantic models.
""" """
if isinstance(section, dict): if isinstance(section, dict):
value = section.get(key) section_data = cast(dict[str, Any], section)
value = section_data.get(key)
if value is None: if value is None:
camel = _BOOL_CAMEL_ALIASES.get(key) camel = _BOOL_CAMEL_ALIASES.get(key)
if camel: if camel:
value = section.get(camel) value = section_data.get(camel)
return value if isinstance(value, bool) else default return value if isinstance(value, bool) else default
value = getattr(section, key, None) value = getattr(section, key, None)
return value if isinstance(value, bool) else default return value if isinstance(value, bool) else default
@@ -344,7 +351,7 @@ class ChannelManager:
errors[name] = "Channel failed to start. Check gateway logs." errors[name] = "Channel failed to start. Check gateway logs."
logger.exception("Failed to start channel {}", name) logger.exception("Failed to start channel {}", name)
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task: def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
logger.info("Starting {} channel...", name) logger.info("Starting {} channel...", name)
task = asyncio.create_task(self._start_channel(name, channel)) task = asyncio.create_task(self._start_channel(name, channel))
self._channel_tasks[name] = task self._channel_tasks[name] = task
@@ -361,7 +368,8 @@ class ChannelManager:
await channel.stop() await channel.stop()
logger.info("Stopped {} channel", name) logger.info("Stopped {} channel", name)
except asyncio.CancelledError: except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling(): current_task = asyncio.current_task()
if current_task is not None and current_task.cancelling():
raise raise
logger.debug("Channel {} stop task was already cancelled", name) logger.debug("Channel {} stop task was already cancelled", name)
except Exception: except Exception:
@@ -553,7 +561,7 @@ class ChannelManager:
self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
# Start channels # Start channels
tasks = [] tasks: list[asyncio.Task[None]] = []
for name, channel in self.channels.items(): for name, channel in self.channels.items():
tasks.append(self._start_channel_task(name, channel)) tasks.append(self._start_channel_task(name, channel))
+113 -41
View File
@@ -1,5 +1,7 @@
"""Matrix (Element) channel — inbound sync + outbound message/media delivery.""" """Matrix (Element) channel — inbound sync + outbound message/media delivery."""
# pyright: reportMissingTypeStubs=false
import asyncio import asyncio
import html import html
import json import json
@@ -10,7 +12,7 @@ import time
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Literal, TypeAlias from typing import Any, Callable, Literal, Protocol, TypeAlias, cast
from urllib.parse import quote, unquote, urlparse from urllib.parse import quote, unquote, urlparse
from pydantic import Field from pydantic import Field
@@ -75,6 +77,18 @@ MATRIX_MEDIA_EVENT_FILTER = (RoomMessageMedia, RoomEncryptedMedia)
MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia MatrixMediaEvent: TypeAlias = RoomMessageMedia | RoomEncryptedMedia
class _MatrixCallbackRegistrar(Protocol):
"""Runtime callback surface whose upstream stubs reject valid filtered handlers."""
def add_event_callback(self, callback: Callable[..., Any], event_filter: Any) -> None: ...
def add_to_device_callback(
self,
callback: Callable[..., Any],
event_filter: Any,
) -> None: ...
def add_response_callback(self, callback: Callable[..., Any], response_filter: Any) -> None: ...
class _MediaTooLargeError(Exception): class _MediaTooLargeError(Exception):
"""Raised when an inbound Matrix media download exceeds the configured cap.""" """Raised when an inbound Matrix media download exceeds the configured cap."""
@@ -187,7 +201,7 @@ def _render_markdown_html(text: str) -> str | None:
"""Render markdown to sanitized HTML; returns None for plain text.""" """Render markdown to sanitized HTML; returns None for plain text."""
try: try:
masked_text = _mask_mxc_markdown_image_sources(text) masked_text = _mask_mxc_markdown_image_sources(text)
rendered = _mask_mxc_image_sources(MATRIX_MARKDOWN(masked_text)) rendered = _mask_mxc_image_sources(cast(str, MATRIX_MARKDOWN(masked_text)))
formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip()) formatted = _unmask_mxc_image_sources(MATRIX_HTML_CLEANER.clean(rendered).strip())
except Exception: except Exception:
return None return None
@@ -229,16 +243,17 @@ def _build_matrix_text_content(
content["format"] = MATRIX_HTML_FORMAT content["format"] = MATRIX_HTML_FORMAT
content["formatted_body"] = html content["formatted_body"] = html
if event_id: if event_id:
content["m.new_content"] = { new_content: dict[str, object] = {
"body": text, "body": text,
"msgtype": "m.text", "msgtype": "m.text",
} }
content["m.new_content"] = new_content
content["m.relates_to"] = { content["m.relates_to"] = {
"rel_type": "m.replace", "rel_type": "m.replace",
"event_id": event_id, "event_id": event_id,
} }
if thread_relates_to: if thread_relates_to:
content["m.new_content"]["m.relates_to"] = thread_relates_to new_content["m.relates_to"] = thread_relates_to
elif thread_relates_to: elif thread_relates_to:
content["m.relates_to"] = thread_relates_to content["m.relates_to"] = thread_relates_to
@@ -276,7 +291,7 @@ class MatrixChannel(BaseChannel):
name = "matrix" name = "matrix"
display_name = "Matrix" display_name = "Matrix"
_STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls _STREAM_EDIT_INTERVAL = 2 # min seconds between edit_message_text calls
monotonic_time = time.monotonic monotonic_time: Callable[[], float] = staticmethod(time.monotonic)
@classmethod @classmethod
def default_config(cls) -> dict[str, Any]: def default_config(cls) -> dict[str, Any]:
@@ -294,8 +309,8 @@ class MatrixChannel(BaseChannel):
config = MatrixConfig.model_validate(config) config = MatrixConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self.client: AsyncClient | None = None self.client: AsyncClient | None = None
self._sync_task: asyncio.Task | None = None self._sync_task: asyncio.Task[None] | None = None
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task[None]] = {}
self._restrict_to_workspace = bool(restrict_to_workspace) self._restrict_to_workspace = bool(restrict_to_workspace)
self._workspace = ( self._workspace = (
Path(workspace).expanduser().resolve(strict=False) if workspace is not None else None Path(workspace).expanduser().resolve(strict=False) if workspace is not None else None
@@ -325,7 +340,7 @@ class MatrixChannel(BaseChannel):
self.client = AsyncClient( self.client = AsyncClient(
homeserver=self.config.homeserver, homeserver=self.config.homeserver,
user=self.config.user_id, user=self.config.user_id,
store_path=self.store_path, store_path=str(self.store_path),
config=AsyncClientConfig( config=AsyncClientConfig(
store_sync_tokens=True, store_sync_tokens=True,
encryption_enabled=self.config.e2ee_enabled, encryption_enabled=self.config.e2ee_enabled,
@@ -386,6 +401,16 @@ class MatrixChannel(BaseChannel):
self._sync_task = asyncio.create_task(self._sync_loop()) self._sync_task = asyncio.create_task(self._sync_loop())
def _require_client(self) -> AsyncClient:
if self.client is None:
raise RuntimeError("Matrix client is not started")
return self.client
def _callback_registrar(self) -> _MatrixCallbackRegistrar:
# matrix-nio's callback annotations do not model filtered subtype or
# async handlers, although the runtime API supports both.
return cast(_MatrixCallbackRegistrar, self._require_client())
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the Matrix channel with graceful sync shutdown.""" """Stop the Matrix channel with graceful sync shutdown."""
self._running = False self._running = False
@@ -428,9 +453,10 @@ class MatrixChannel(BaseChannel):
seen: set[str] = set() seen: set[str] = set()
candidates: list[Path] = [] candidates: list[Path] = []
for raw in media: for raw in media:
if not isinstance(raw, str) or not raw.strip(): raw_value = cast(object, raw)
if not isinstance(raw_value, str) or not raw_value.strip():
continue continue
path = Path(raw.strip()).expanduser() path = Path(raw_value.strip()).expanduser()
try: try:
key = str(path.resolve(strict=False)) key = str(path.resolve(strict=False))
except OSError: except OSError:
@@ -535,8 +561,13 @@ class MatrixChannel(BaseChannel):
self.logger.error("Matrix media upload failed for %s", filename, exc_info=True) self.logger.error("Matrix media upload failed for %s", filename, exc_info=True)
return fail return fail
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result is_tuple_result = isinstance(cast(object, upload_result), tuple)
encryption_info = upload_result[1] if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict) else None upload_response = upload_result[0] if is_tuple_result else upload_result
encryption_info = (
upload_result[1]
if is_tuple_result and isinstance(cast(object, upload_result[1]), dict)
else None
)
if isinstance(upload_response, UploadError): if isinstance(upload_response, UploadError):
return fail return fail
mxc_url = getattr(upload_response, "content_uri", None) mxc_url = getattr(upload_response, "content_uri", None)
@@ -645,28 +676,31 @@ class MatrixChannel(BaseChannel):
buf.last_edit = now buf.last_edit = now
if not buf.event_id: if not buf.event_id:
# we are editing the same message all the time, so only the first time the event id needs to be set # we are editing the same message all the time, so only the first time the event id needs to be set
buf.event_id = response.event_id buf.event_id = cast(RoomSendResponse, response).event_id
except Exception: except Exception:
self.logger.error("Stream send/edit failed for chat_id=%s", chat_id, exc_info=True) self.logger.error("Stream send/edit failed for chat_id=%s", chat_id, exc_info=True)
await self._stop_typing_keepalive(chat_id, clear_typing=True) await self._stop_typing_keepalive(chat_id, clear_typing=True)
def _register_event_callbacks(self) -> None: def _register_event_callbacks(self) -> None:
self.client.add_event_callback(self._on_message, RoomMessageText) client = self._callback_registrar()
self.client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER) client.add_event_callback(self._on_message, RoomMessageText)
self.client.add_event_callback(self._on_room_invite, InviteEvent) client.add_event_callback(self._on_media_message, MATRIX_MEDIA_EVENT_FILTER)
client.add_event_callback(self._on_room_invite, InviteEvent)
def _register_to_device_callbacks(self) -> None: def _register_to_device_callbacks(self) -> None:
if self.config.e2ee_enabled and self.config.sas_verification: if self.config.e2ee_enabled and self.config.sas_verification:
self.client.add_to_device_callback( client = self._callback_registrar()
client.add_to_device_callback(
self._on_key_verification_event, self._on_key_verification_event,
(KeyVerificationEvent,), (KeyVerificationEvent,),
) )
def _register_response_callbacks(self) -> None: def _register_response_callbacks(self) -> None:
self.client.add_response_callback(self._on_sync_error, SyncError) client = self._callback_registrar()
self.client.add_response_callback(self._on_join_error, JoinError) client.add_response_callback(self._on_sync_error, SyncError)
self.client.add_response_callback(self._on_send_error, RoomSendError) client.add_response_callback(self._on_join_error, JoinError)
client.add_response_callback(self._on_send_error, RoomSendError)
def _is_sas_sender_allowed(self, sender: str) -> bool: def _is_sas_sender_allowed(self, sender: str) -> bool:
return bool(sender and self.is_allowed(sender)) return bool(sender and self.is_allowed(sender))
@@ -791,7 +825,8 @@ class MatrixChannel(BaseChannel):
backoff = 2.0 backoff = 2.0
while self._running: while self._running:
try: try:
await self.client.sync_forever(timeout=30000, full_state=True) client = self._require_client()
await client.sync_forever(timeout=30000, full_state=True)
backoff = 2.0 backoff = 2.0
except asyncio.CancelledError: except asyncio.CancelledError:
break break
@@ -803,7 +838,8 @@ class MatrixChannel(BaseChannel):
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None: async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
if self.is_allowed(event.sender): if self.is_allowed(event.sender):
await self.client.join(room.room_id) client = self._require_client()
await client.join(room.room_id)
def _is_direct_room(self, room: MatrixRoom) -> bool: def _is_direct_room(self, room: MatrixRoom) -> bool:
count = getattr(room, "member_count", None) count = getattr(room, "member_count", None)
@@ -814,13 +850,19 @@ class MatrixChannel(BaseChannel):
source = getattr(event, "source", None) source = getattr(event, "source", None)
if not isinstance(source, dict): if not isinstance(source, dict):
return False return False
mentions = (source.get("content") or {}).get("m.mentions") source_data = cast(dict[str, Any], source)
content = cast(dict[str, Any], source_data.get("content") or {})
mentions = cast(object, content.get("m.mentions"))
if not isinstance(mentions, dict): if not isinstance(mentions, dict):
return False return False
user_ids = mentions.get("user_ids") mentions_data = cast(dict[str, Any], mentions)
user_ids = cast(object, mentions_data.get("user_ids"))
if isinstance(user_ids, list) and self.config.user_id in user_ids: if isinstance(user_ids, list) and self.config.user_id in user_ids:
return True return True
return bool(self.config.allow_room_mentions and mentions.get("room") is True) return bool(
self.config.allow_room_mentions
and mentions_data.get("room") is True
)
def _is_pre_startup_event(self, event: RoomMessage) -> bool: def _is_pre_startup_event(self, event: RoomMessage) -> bool:
"""Skip events that landed in the timeline before this process started. """Skip events that landed in the timeline before this process started.
@@ -855,14 +897,21 @@ class MatrixChannel(BaseChannel):
source = getattr(event, "source", None) source = getattr(event, "source", None)
if not isinstance(source, dict): if not isinstance(source, dict):
return {} return {}
content = source.get("content") source_data = cast(dict[str, Any], source)
return content if isinstance(content, dict) else {} content = cast(object, source_data.get("content"))
return cast(dict[str, Any], content) if isinstance(content, dict) else {}
def _event_thread_root_id(self, event: RoomMessage) -> str | None: def _event_thread_root_id(self, event: RoomMessage) -> str | None:
relates_to = self._event_source_content(event).get("m.relates_to") relates_to = cast(
if not isinstance(relates_to, dict) or relates_to.get("rel_type") != "m.thread": object,
self._event_source_content(event).get("m.relates_to"),
)
if not isinstance(relates_to, dict):
return None return None
root_id = relates_to.get("event_id") relation = cast(dict[str, Any], relates_to)
if relation.get("rel_type") != "m.thread":
return None
root_id = cast(object, relation.get("event_id"))
return root_id if isinstance(root_id, str) and root_id else None return root_id if isinstance(root_id, str) and root_id else None
def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None: def _thread_metadata(self, event: RoomMessage) -> dict[str, str] | None:
@@ -888,7 +937,7 @@ class MatrixChannel(BaseChannel):
def _event_attachment_type(self, event: MatrixMediaEvent) -> str: def _event_attachment_type(self, event: MatrixMediaEvent) -> str:
msgtype = self._event_source_content(event).get("msgtype") msgtype = self._event_source_content(event).get("msgtype")
return _MSGTYPE_MAP.get(msgtype, "file") return _MSGTYPE_MAP.get(cast(str, msgtype), "file")
@staticmethod @staticmethod
def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool: def _is_encrypted_media_event(event: MatrixMediaEvent) -> bool:
@@ -897,16 +946,27 @@ class MatrixChannel(BaseChannel):
and isinstance(getattr(event, "iv", None), str)) and isinstance(getattr(event, "iv", None), str))
def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None: def _event_declared_size_bytes(self, event: MatrixMediaEvent) -> int | None:
info = self._event_source_content(event).get("info") info = cast(object, self._event_source_content(event).get("info"))
size = info.get("size") if isinstance(info, dict) else None size = (
cast(dict[str, Any], info).get("size")
if isinstance(info, dict)
else None
)
return size if type(size) is int and size >= 0 else None # noqa: E721 return size if type(size) is int and size >= 0 else None # noqa: E721
def _event_mime(self, event: MatrixMediaEvent) -> str | None: def _event_mime(self, event: MatrixMediaEvent) -> str | None:
info = self._event_source_content(event).get("info") info = cast(object, self._event_source_content(event).get("info"))
if isinstance(info, dict) and isinstance(m := info.get("mimetype"), str) and m: if (
return m isinstance(info, dict)
m = getattr(event, "mimetype", None) and isinstance(
return m if isinstance(m, str) and m else None mime := cast(dict[str, Any], info).get("mimetype"),
str,
)
and mime
):
return mime
mime = getattr(event, "mimetype", None)
return mime if isinstance(mime, str) and mime else None
def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str: def _event_filename(self, event: MatrixMediaEvent, attachment_type: str) -> str:
body = getattr(event, "body", None) body = getattr(event, "body", None)
@@ -973,9 +1033,21 @@ class MatrixChannel(BaseChannel):
def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None: def _decrypt_media_bytes(self, event: MatrixMediaEvent, ciphertext: bytes) -> bytes | None:
key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None) key_obj, hashes, iv = getattr(event, "key", None), getattr(event, "hashes", None), getattr(event, "iv", None)
key = key_obj.get("k") if isinstance(key_obj, dict) else None key = (
sha256 = hashes.get("sha256") if isinstance(hashes, dict) else None cast(dict[str, Any], key_obj).get("k")
if not all(isinstance(v, str) for v in (key, sha256, iv)): if isinstance(key_obj, dict)
else None
)
sha256 = (
cast(dict[str, Any], hashes).get("sha256")
if isinstance(hashes, dict)
else None
)
if (
not isinstance(key, str)
or not isinstance(sha256, str)
or not isinstance(iv, str)
):
return None return None
try: try:
return decrypt_attachment(ciphertext, key, sha256, iv) return decrypt_attachment(ciphertext, key, sha256, iv)
+66 -40
View File
@@ -6,7 +6,7 @@ import asyncio
import json import json
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
import httpx import httpx
from pydantic import Field from pydantic import Field
@@ -86,7 +86,7 @@ class MattermostChannel(BaseChannel):
self._server_url = config.server_url.rstrip("/") self._server_url = config.server_url.rstrip("/")
self._ws_url = _server_url_to_ws_url(self._server_url) self._ws_url = _server_url_to_ws_url(self._server_url)
self._http_client: httpx.AsyncClient | None = None self._http_client: httpx.AsyncClient | None = None
self._ws_task: asyncio.Task | None = None self._ws_task: asyncio.Task[None] | None = None
self._self_id: str | None = None self._self_id: str | None = None
self._self_username: str | None = None self._self_username: str | None = None
self._self_email: str | None = None self._self_email: str | None = None
@@ -118,7 +118,7 @@ class MattermostChannel(BaseChannel):
try: try:
resp = await self._http_client.get("/api/v4/users/me") resp = await self._http_client.get("/api/v4/users/me")
resp.raise_for_status() resp.raise_for_status()
me = resp.json() me = cast(dict[str, Any], resp.json())
self._self_id = me.get("id") self._self_id = me.get("id")
self._self_username = me.get("username") self._self_username = me.get("username")
self._self_email = me.get("email", "") self._self_email = me.get("email", "")
@@ -169,7 +169,7 @@ class MattermostChannel(BaseChannel):
self.logger.debug("websocket connected") self.logger.debug("websocket connected")
delay = MATTERMOST_WS_RECONNECT_BASE_DELAY delay = MATTERMOST_WS_RECONNECT_BASE_DELAY
async for raw in ws: async for raw in ws:
await self._handle_ws_message(json.loads(raw)) await self._handle_ws_message(cast(dict[str, Any], json.loads(raw)))
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception as e: except Exception as e:
@@ -191,12 +191,15 @@ class MattermostChannel(BaseChannel):
# Event: posted ------------------------------------------------------------ # Event: posted ------------------------------------------------------------
async def _handle_posted_event(self, msg: dict[str, Any]) -> None: async def _handle_posted_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {}) data = cast(dict[str, Any], msg.get("data", {}))
broadcast = msg.get("broadcast", {}) broadcast = cast(dict[str, Any], msg.get("broadcast", {}))
raw_post = data.get("post", "{}") raw_post = data.get("post", "{}")
try: try:
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post post = cast(
dict[str, Any],
json.loads(raw_post) if isinstance(raw_post, str) else raw_post,
)
except json.JSONDecodeError: except json.JSONDecodeError:
self.logger.warning("failed to parse post json") self.logger.warning("failed to parse post json")
return return
@@ -206,7 +209,7 @@ class MattermostChannel(BaseChannel):
message_text = post.get("message", "") message_text = post.get("message", "")
root_id = post.get("root_id", "") or "" root_id = post.get("root_id", "") or ""
post_id = post.get("id", "") post_id = post.get("id", "")
file_ids: list[str] = post.get("file_ids", []) file_ids = cast(list[str], post.get("file_ids", []))
if self._self_id and sender_id == self._self_id: if self._self_id and sender_id == self._self_id:
return return
@@ -292,11 +295,11 @@ class MattermostChannel(BaseChannel):
# Event: action ------------------------------------------------------------ # Event: action ------------------------------------------------------------
async def _handle_action_event(self, msg: dict[str, Any]) -> None: async def _handle_action_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {}) data = cast(dict[str, Any], msg.get("data", {}))
sender_id = data.get("user_id", "") sender_id = data.get("user_id", "")
channel_id = data.get("channel_id", "") channel_id = data.get("channel_id", "")
context = data.get("context", {}) or {} context = cast(dict[str, Any], data.get("context", {}) or {})
value = context.get("selected_option", "") value = cast(str, context.get("selected_option", ""))
if not sender_id or not channel_id or not value: if not sender_id or not channel_id or not value:
return return
@@ -319,10 +322,13 @@ class MattermostChannel(BaseChannel):
# Event: post_deleted ------------------------------------------------------ # Event: post_deleted ------------------------------------------------------
async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None: async def _handle_post_deleted_event(self, msg: dict[str, Any]) -> None:
data = msg.get("data", {}) data = cast(dict[str, Any], msg.get("data", {}))
raw_post = data.get("post", "{}") raw_post = data.get("post", "{}")
try: try:
post = json.loads(raw_post) if isinstance(raw_post, str) else raw_post post = cast(
dict[str, Any],
json.loads(raw_post) if isinstance(raw_post, str) else raw_post,
)
except json.JSONDecodeError: except json.JSONDecodeError:
return return
post_id = post.get("id", "") post_id = post.get("id", "")
@@ -363,15 +369,15 @@ class MattermostChannel(BaseChannel):
return chat_id in self.config.group_allow_from return chat_id in self.config.group_allow_from
return False return False
_BOT_MENTION_RE: re.Pattern | None = None _bot_mention_re: re.Pattern[str] | None = None
def _is_mentioned(self, text: str) -> bool: def _is_mentioned(self, text: str) -> bool:
if not self._self_username: if not self._self_username:
return False return False
if self._BOT_MENTION_RE is None: if self._bot_mention_re is None:
pat = r"(?<![@\w])@" + re.escape(self._self_username) + r"(?![@\w])" pat = r"(?<![@\w])@" + re.escape(self._self_username) + r"(?![@\w])"
self._BOT_MENTION_RE = re.compile(pat) self._bot_mention_re = re.compile(pat)
return bool(self._BOT_MENTION_RE.search(text)) return bool(self._bot_mention_re.search(text))
def _strip_bot_mention(self, text: str) -> str: def _strip_bot_mention(self, text: str) -> str:
if not text or not self._self_username: if not text or not self._self_username:
@@ -432,8 +438,8 @@ class MattermostChannel(BaseChannel):
self.logger.warning("thread context unavailable for {}: {}", key, e) self.logger.warning("thread context unavailable for {}: {}", key, e)
return text return text
posts = data.get("posts", {}) posts = cast(dict[str, dict[str, Any]], data.get("posts", {}))
order = data.get("order", []) order = cast(list[str], data.get("order", []))
if not order: if not order:
return text return text
@@ -467,8 +473,11 @@ class MattermostChannel(BaseChannel):
try: try:
chat_id = msg.chat_id chat_id = msg.chat_id
meta = msg.metadata or {} meta = msg.metadata or {}
mm_meta = meta.get("mattermost", {}) or {} mm_meta = cast(dict[str, Any], meta.get("mattermost", {}) or {})
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id") root_id = cast(
str | None,
mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id"),
)
file_ids: list[str] = [] file_ids: list[str] = []
for media_path in msg.media or []: for media_path in msg.media or []:
@@ -521,7 +530,7 @@ class MattermostChannel(BaseChannel):
return return
meta = metadata or {} meta = metadata or {}
stream_id = stream_id or meta.get("_stream_id") or chat_id stream_id = cast(str, stream_id or meta.get("_stream_id") or chat_id)
stream_end = stream_end or bool(meta.get("_stream_end")) stream_end = stream_end or bool(meta.get("_stream_end"))
resuming = resuming or bool(meta.get("_resuming")) resuming = resuming or bool(meta.get("_resuming"))
@@ -541,13 +550,17 @@ class MattermostChannel(BaseChannel):
return return
if final and not meta.get("_progress"): if final and not meta.get("_progress"):
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {} mm_meta = (
root_id = ( cast(dict[str, Any], meta.get("mattermost", {}) or {})
if isinstance(meta.get("mattermost"), dict)
else {}
)
root_id = cast(str | None, (
mm_meta.get("root_id") mm_meta.get("root_id")
or mm_meta.get("thread_ts") or mm_meta.get("thread_ts")
or meta.get("root_id") or meta.get("root_id")
or self._stream_root_ids.get(stream_id) or self._stream_root_ids.get(stream_id)
) ))
chunks = split_message(final, MATTERMOST_MAX_MESSAGE_LEN) chunks = split_message(final, MATTERMOST_MAX_MESSAGE_LEN)
first_post_id: str | None = None first_post_id: str | None = None
try: try:
@@ -579,8 +592,15 @@ class MattermostChannel(BaseChannel):
if not delta.strip(): if not delta.strip():
return return
mm_meta = (meta.get("mattermost", {}) or {}) if isinstance(meta.get("mattermost"), dict) else {} mm_meta = (
root_id = mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id") cast(dict[str, Any], meta.get("mattermost", {}) or {})
if isinstance(meta.get("mattermost"), dict)
else {}
)
root_id = cast(
str | None,
mm_meta.get("root_id") or mm_meta.get("thread_ts") or meta.get("root_id"),
)
if root_id: if root_id:
self._stream_root_ids[stream_id] = root_id self._stream_root_ids[stream_id] = root_id
committed = self._stream_committed.get(stream_id, "") committed = self._stream_committed.get(stream_id, "")
@@ -598,20 +618,25 @@ class MattermostChannel(BaseChannel):
# API helpers --------------------------------------------------------------- # API helpers ---------------------------------------------------------------
def _require_http_client(self) -> httpx.AsyncClient:
if self._http_client is None:
raise RuntimeError("Mattermost client is not started")
return self._http_client
async def _api_get(self, path: str) -> dict[str, Any]: async def _api_get(self, path: str) -> dict[str, Any]:
resp = await self._http_client.get(path) resp = await self._require_http_client().get(path)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]: async def _api_post(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._http_client.post(path, json=json_data) resp = await self._require_http_client().post(path, json=json_data)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]: async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._http_client.put(path, json=json_data) resp = await self._require_http_client().put(path, json=json_data)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
async def _create_post( async def _create_post(
self, self,
@@ -642,14 +667,14 @@ class MattermostChannel(BaseChannel):
try: try:
files = {"files": (path.name, path.read_bytes())} files = {"files": (path.name, path.read_bytes())}
resp = await self._http_client.post( resp = await self._require_http_client().post(
"/api/v4/files", "/api/v4/files",
data={"channel_id": channel_id}, data={"channel_id": channel_id},
files=files, files=files,
) )
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = cast(dict[str, Any], resp.json())
infos = data.get("file_infos", []) infos = cast(list[dict[str, Any]], data.get("file_infos", []))
if infos: if infos:
return infos[0].get("id") return infos[0].get("id")
except Exception as e: except Exception as e:
@@ -658,14 +683,15 @@ class MattermostChannel(BaseChannel):
async def _download_file(self, file_id: str) -> str | None: async def _download_file(self, file_id: str) -> str | None:
try: try:
info_resp = await self._http_client.get(f"/api/v4/files/{file_id}/info") client = self._require_http_client()
info_resp = await client.get(f"/api/v4/files/{file_id}/info")
info_resp.raise_for_status() info_resp.raise_for_status()
info = info_resp.json() info = cast(dict[str, Any], info_resp.json())
name = Path(info.get("name", file_id)).name name = Path(info.get("name", file_id)).name
out = Path(get_media_dir("mattermost")) / safe_filename(f"{file_id}_{name}") out = Path(get_media_dir("mattermost")) / safe_filename(f"{file_id}_{name}")
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
dl = await self._http_client.get(f"/api/v4/files/{file_id}") dl = await client.get(f"/api/v4/files/{file_id}")
dl.raise_for_status() dl.raise_for_status()
out.write_bytes(dl.content) out.write_bytes(dl.content)
return str(out) return str(out)
@@ -685,7 +711,7 @@ class MattermostChannel(BaseChannel):
async def _remove_reaction(self, post_id: str, emoji: str) -> None: async def _remove_reaction(self, post_id: str, emoji: str) -> None:
if not self._self_id or not emoji: if not self._self_id or not emoji:
return return
resp = await self._http_client.delete( resp = await self._require_http_client().delete(
f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}", f"/api/v4/users/{self._self_id}/posts/{post_id}/reactions/{emoji}",
) )
if resp.status_code >= 400: if resp.status_code >= 400:
+107 -54
View File
@@ -1,3 +1,4 @@
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false
"""Mochat channel implementation using Socket.IO with HTTP polling fallback.""" """Mochat channel implementation using Socket.IO with HTTP polling fallback."""
from __future__ import annotations from __future__ import annotations
@@ -5,10 +6,11 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from collections import deque from collections import deque
from collections.abc import Awaitable, Callable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any, cast
import httpx import httpx
from pydantic import Field from pydantic import Field
@@ -27,7 +29,7 @@ except ImportError:
SOCKETIO_AVAILABLE = False SOCKETIO_AVAILABLE = False
try: try:
import msgpack # noqa: F401 import msgpack # noqa: F401 # pyright: ignore[reportUnusedImport]
MSGPACK_AVAILABLE = True MSGPACK_AVAILABLE = True
except ImportError: except ImportError:
MSGPACK_AVAILABLE = False MSGPACK_AVAILABLE = False
@@ -57,7 +59,7 @@ class DelayState:
"""Per-target delayed message state.""" """Per-target delayed message state."""
entries: list[MochatBufferedEntry] = field(default_factory=list) entries: list[MochatBufferedEntry] = field(default_factory=list)
lock: asyncio.Lock = field(default_factory=asyncio.Lock) lock: asyncio.Lock = field(default_factory=asyncio.Lock)
timer: asyncio.Task | None = None timer: asyncio.Task[None] | None = None
@dataclass @dataclass
@@ -71,12 +73,12 @@ class MochatTarget:
# Pure helpers # Pure helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _safe_dict(value: Any) -> dict: def _safe_dict(value: Any) -> dict[str, Any]:
"""Return *value* if it's a dict, else empty dict.""" """Return *value* if it's a dict, else empty dict."""
return value if isinstance(value, dict) else {} return cast(dict[str, Any], value) if isinstance(value, dict) else {}
def _str_field(src: dict, *keys: str) -> str: def _str_field(src: dict[str, Any], *keys: str) -> str:
"""Return the first non-empty str value found for *keys*, stripped.""" """Return the first non-empty str value found for *keys*, stripped."""
for k in keys: for k in keys:
v = src.get(k) v = src.get(k)
@@ -100,7 +102,7 @@ def _make_synthetic_event(
payload["authorInfo"] = _safe_dict(author_info) payload["authorInfo"] = _safe_dict(author_info)
return { return {
"type": "message.add", "type": "message.add",
"timestamp": timestamp or datetime.utcnow().isoformat(), "timestamp": timestamp or datetime.utcnow().isoformat(), # pyright: ignore[reportDeprecated]
"payload": payload, "payload": payload,
} }
@@ -141,11 +143,12 @@ def extract_mention_ids(value: Any) -> list[str]:
if not isinstance(value, list): if not isinstance(value, list):
return [] return []
ids: list[str] = [] ids: list[str] = []
for item in value: for item in cast(list[object], value):
if isinstance(item, str): if isinstance(item, str):
if item.strip(): if item.strip():
ids.append(item.strip()) ids.append(item.strip())
elif isinstance(item, dict): elif isinstance(item, dict):
item = cast(dict[str, Any], item)
for key in ("id", "userId", "_id"): for key in ("id", "userId", "_id"):
candidate = item.get(key) candidate = item.get(key)
if isinstance(candidate, str) and candidate.strip(): if isinstance(candidate, str) and candidate.strip():
@@ -158,6 +161,7 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
"""Resolve mention state from payload metadata and text fallback.""" """Resolve mention state from payload metadata and text fallback."""
meta = payload.get("meta") meta = payload.get("meta")
if isinstance(meta, dict): if isinstance(meta, dict):
meta = cast(dict[str, Any], meta)
if meta.get("mentioned") is True or meta.get("wasMentioned") is True: if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
return True return True
for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"): for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
@@ -278,7 +282,7 @@ class MochatChannel(BaseChannel):
self._state_dir = get_runtime_subdir("mochat") self._state_dir = get_runtime_subdir("mochat")
self._cursor_path = self._state_dir / "session_cursors.json" self._cursor_path = self._state_dir / "session_cursors.json"
self._session_cursor: dict[str, int] = {} self._session_cursor: dict[str, int] = {}
self._cursor_save_task: asyncio.Task | None = None self._cursor_save_task: asyncio.Task[None] | None = None
self._session_set: set[str] = set() self._session_set: set[str] = set()
self._panel_set: set[str] = set() self._panel_set: set[str] = set()
@@ -292,9 +296,9 @@ class MochatChannel(BaseChannel):
self._delay_states: dict[str, DelayState] = {} self._delay_states: dict[str, DelayState] = {}
self._fallback_mode = False self._fallback_mode = False
self._session_fallback_tasks: dict[str, asyncio.Task] = {} self._session_fallback_tasks: dict[str, asyncio.Task[None]] = {}
self._panel_fallback_tasks: dict[str, asyncio.Task] = {} self._panel_fallback_tasks: dict[str, asyncio.Task[None]] = {}
self._refresh_task: asyncio.Task | None = None self._refresh_task: asyncio.Task[None] | None = None
self._target_locks: dict[str, asyncio.Lock] = {} self._target_locks: dict[str, asyncio.Lock] = {}
# ---- lifecycle --------------------------------------------------------- # ---- lifecycle ---------------------------------------------------------
@@ -352,7 +356,11 @@ class MochatChannel(BaseChannel):
parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
if msg.media: if msg.media:
parts.extend(m for m in msg.media if isinstance(m, str) and m.strip()) parts.extend(
m
for m in msg.media
if isinstance(cast(object, m), str) and m.strip()
)
content = "\n".join(parts).strip() content = "\n".join(parts).strip()
if not content: if not content:
return return
@@ -404,7 +412,8 @@ class MochatChannel(BaseChannel):
else: else:
self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") self.logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
client = socketio.AsyncClient( socketio_module = cast(Any, socketio)
client: Any = socketio_module.AsyncClient(
reconnection=True, reconnection=True,
reconnection_attempts=self.config.max_retry_attempts or None, reconnection_attempts=self.config.max_retry_attempts or None,
reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0), reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
@@ -412,7 +421,6 @@ class MochatChannel(BaseChannel):
logger=False, engineio_logger=False, serializer=serializer, logger=False, engineio_logger=False, serializer=serializer,
) )
@client.event
async def connect() -> None: async def connect() -> None:
self._ws_connected, self._ws_ready = True, False self._ws_connected, self._ws_ready = True, False
self.logger.info("websocket connected") self.logger.info("websocket connected")
@@ -420,7 +428,6 @@ class MochatChannel(BaseChannel):
self._ws_ready = subscribed self._ws_ready = subscribed
await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers()) await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
@client.event
async def disconnect() -> None: async def disconnect() -> None:
if not self._running: if not self._running:
return return
@@ -428,18 +435,21 @@ class MochatChannel(BaseChannel):
self.logger.warning("websocket disconnected") self.logger.warning("websocket disconnected")
await self._ensure_fallback_workers() await self._ensure_fallback_workers()
@client.event
async def connect_error(data: Any) -> None: async def connect_error(data: Any) -> None:
self.logger.error("websocket connect error: {}", data) self.logger.error("websocket connect error: {}", data)
@client.on("claw.session.events")
async def on_session_events(payload: dict[str, Any]) -> None: async def on_session_events(payload: dict[str, Any]) -> None:
await self._handle_watch_payload(payload, "session") await self._handle_watch_payload(payload, "session")
@client.on("claw.panel.events")
async def on_panel_events(payload: dict[str, Any]) -> None: async def on_panel_events(payload: dict[str, Any]) -> None:
await self._handle_watch_payload(payload, "panel") await self._handle_watch_payload(payload, "panel")
client.event(connect)
client.event(disconnect)
client.event(connect_error)
client.on("claw.session.events", on_session_events)
client.on("claw.panel.events", on_panel_events)
for ev in ("notify:chat.inbox.append", "notify:chat.message.add", for ev in ("notify:chat.inbox.append", "notify:chat.message.add",
"notify:chat.message.update", "notify:chat.message.recall", "notify:chat.message.update", "notify:chat.message.recall",
"notify:chat.message.delete"): "notify:chat.message.delete"):
@@ -463,7 +473,10 @@ class MochatChannel(BaseChannel):
self._socket = None self._socket = None
return False return False
def _build_notify_handler(self, event_name: str): def _build_notify_handler(
self,
event_name: str,
) -> Callable[[Any], Awaitable[None]]:
async def handler(payload: Any) -> None: async def handler(payload: Any) -> None:
if event_name == "notify:chat.inbox.append": if event_name == "notify:chat.inbox.append":
await self._handle_notify_inbox_append(payload) await self._handle_notify_inbox_append(payload)
@@ -498,11 +511,20 @@ class MochatChannel(BaseChannel):
data = ack.get("data") data = ack.get("data")
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
if isinstance(data, list): if isinstance(data, list):
items = [i for i in data if isinstance(i, dict)] items = [
cast(dict[str, Any], item)
for item in cast(list[object], data)
if isinstance(item, dict)
]
elif isinstance(data, dict): elif isinstance(data, dict):
data = cast(dict[str, Any], data)
sessions = data.get("sessions") sessions = data.get("sessions")
if isinstance(sessions, list): if isinstance(sessions, list):
items = [i for i in sessions if isinstance(i, dict)] items = [
cast(dict[str, Any], item)
for item in cast(list[object], sessions)
if isinstance(item, dict)
]
elif "sessionId" in data: elif "sessionId" in data:
items = [data] items = [data]
for p in items: for p in items:
@@ -525,7 +547,11 @@ class MochatChannel(BaseChannel):
raw = await self._socket.call(event_name, payload, timeout=10) raw = await self._socket.call(event_name, payload, timeout=10)
except Exception as e: except Exception as e:
return {"result": False, "message": str(e)} return {"result": False, "message": str(e)}
return raw if isinstance(raw, dict) else {"result": True, "data": raw} return (
cast(dict[str, Any], raw)
if isinstance(raw, dict)
else {"result": True, "data": raw}
)
# ---- refresh / discovery ----------------------------------------------- # ---- refresh / discovery -----------------------------------------------
@@ -558,10 +584,11 @@ class MochatChannel(BaseChannel):
return return
new_ids: list[str] = [] new_ids: list[str] = []
for s in sessions: for session_value in cast(list[object], sessions):
if not isinstance(s, dict): if not isinstance(session_value, dict):
continue continue
sid = _str_field(s, "sessionId") session = cast(dict[str, Any], session_value)
sid = _str_field(session, "sessionId")
if not sid: if not sid:
continue continue
if sid not in self._session_set: if sid not in self._session_set:
@@ -569,7 +596,7 @@ class MochatChannel(BaseChannel):
new_ids.append(sid) new_ids.append(sid)
if sid not in self._session_cursor: if sid not in self._session_cursor:
self._cold_sessions.add(sid) self._cold_sessions.add(sid)
cid = _str_field(s, "converseId") cid = _str_field(session, "converseId")
if cid: if cid:
self._session_by_converse[cid] = sid self._session_by_converse[cid] = sid
@@ -592,13 +619,14 @@ class MochatChannel(BaseChannel):
return return
new_ids: list[str] = [] new_ids: list[str] = []
for p in raw_panels: for panel_value in cast(list[object], raw_panels):
if not isinstance(p, dict): if not isinstance(panel_value, dict):
continue continue
pt = p.get("type") panel = cast(dict[str, Any], panel_value)
pt = panel.get("type")
if isinstance(pt, int) and pt != 0: if isinstance(pt, int) and pt != 0:
continue continue
pid = _str_field(p, "id", "_id") pid = _str_field(panel, "id", "_id")
if pid and pid not in self._panel_set: if pid and pid not in self._panel_set:
self._panel_set.add(pid) self._panel_set.add(pid)
new_ids.append(pid) new_ids.append(pid)
@@ -658,16 +686,19 @@ class MochatChannel(BaseChannel):
}) })
msgs = resp.get("messages") msgs = resp.get("messages")
if isinstance(msgs, list): if isinstance(msgs, list):
for m in reversed(msgs): for message_value in reversed(cast(list[object], msgs)):
if not isinstance(m, dict): if not isinstance(message_value, dict):
continue continue
message = cast(dict[str, Any], message_value)
evt = _make_synthetic_event( evt = _make_synthetic_event(
message_id=str(m.get("messageId") or ""), message_id=str(message.get("messageId") or ""),
author=str(m.get("author") or ""), author=str(message.get("author") or ""),
content=m.get("content"), content=message.get("content"),
meta=m.get("meta"), group_id=str(resp.get("groupId") or ""), meta=message.get("meta"),
converse_id=panel_id, timestamp=m.get("createdAt"), group_id=str(resp.get("groupId") or ""),
author_info=m.get("authorInfo"), converse_id=panel_id,
timestamp=message.get("createdAt"),
author_info=message.get("authorInfo"),
) )
await self._process_inbound_event(panel_id, evt, "panel") await self._process_inbound_event(panel_id, evt, "panel")
except asyncio.CancelledError: except asyncio.CancelledError:
@@ -679,7 +710,7 @@ class MochatChannel(BaseChannel):
# ---- inbound event processing ------------------------------------------ # ---- inbound event processing ------------------------------------------
async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None: async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None:
if not isinstance(payload, dict): if not isinstance(cast(object, payload), dict):
return return
target_id = _str_field(payload, "sessionId") target_id = _str_field(payload, "sessionId")
if not target_id: if not target_id:
@@ -699,9 +730,10 @@ class MochatChannel(BaseChannel):
self._cold_sessions.discard(target_id) self._cold_sessions.discard(target_id)
return return
for event in raw_events: for event_value in cast(list[object], raw_events):
if not isinstance(event, dict): if not isinstance(event_value, dict):
continue continue
event = cast(dict[str, Any], event_value)
seq = event.get("seq") seq = event.get("seq")
if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev): if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev):
self._mark_session_cursor(target_id, seq) self._mark_session_cursor(target_id, seq)
@@ -712,6 +744,7 @@ class MochatChannel(BaseChannel):
payload = event.get("payload") payload = event.get("payload")
if not isinstance(payload, dict): if not isinstance(payload, dict):
return return
payload = cast(dict[str, Any], payload)
author = _str_field(payload, "author") author = _str_field(payload, "author")
if not author or (self.config.agent_user_id and author == self.config.agent_user_id): if not author or (self.config.agent_user_id and author == self.config.agent_user_id):
@@ -821,6 +854,7 @@ class MochatChannel(BaseChannel):
async def _handle_notify_chat_message(self, payload: Any) -> None: async def _handle_notify_chat_message(self, payload: Any) -> None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return return
payload = cast(dict[str, Any], payload)
group_id = _str_field(payload, "groupId") group_id = _str_field(payload, "groupId")
panel_id = _str_field(payload, "converseId", "panelId") panel_id = _str_field(payload, "converseId", "panelId")
if not group_id or not panel_id: if not group_id or not panel_id:
@@ -838,11 +872,15 @@ class MochatChannel(BaseChannel):
await self._process_inbound_event(panel_id, evt, "panel") await self._process_inbound_event(panel_id, evt, "panel")
async def _handle_notify_inbox_append(self, payload: Any) -> None: async def _handle_notify_inbox_append(self, payload: Any) -> None:
if not isinstance(payload, dict) or payload.get("type") != "message": if not isinstance(payload, dict):
return
payload = cast(dict[str, Any], payload)
if payload.get("type") != "message":
return return
detail = payload.get("payload") detail = payload.get("payload")
if not isinstance(detail, dict): if not isinstance(detail, dict):
return return
detail = cast(dict[str, Any], detail)
if _str_field(detail, "groupId"): if _str_field(detail, "groupId"):
return return
converse_id = _str_field(detail, "converseId") converse_id = _str_field(detail, "converseId")
@@ -886,9 +924,14 @@ class MochatChannel(BaseChannel):
except Exception as e: except Exception as e:
self.logger.warning("Failed to read cursor file: {}", e) self.logger.warning("Failed to read cursor file: {}", e)
return return
cursors = data.get("cursors") if isinstance(data, dict) else None data_object = cast(object, data)
cursors = (
cast(dict[str, Any], data_object).get("cursors")
if isinstance(data_object, dict)
else None
)
if isinstance(cursors, dict): if isinstance(cursors, dict):
for sid, cur in cursors.items(): for sid, cur in cast(dict[object, object], cursors).items():
if isinstance(sid, str) and isinstance(cur, int) and cur >= 0: if isinstance(sid, str) and isinstance(cur, int) and cur >= 0:
self._session_cursor[sid] = cur self._session_cursor[sid] = cur
@@ -896,7 +939,8 @@ class MochatChannel(BaseChannel):
try: try:
self._state_dir.mkdir(parents=True, exist_ok=True) self._state_dir.mkdir(parents=True, exist_ok=True)
self._cursor_path.write_text(json.dumps({ self._cursor_path.write_text(json.dumps({
"schemaVersion": 1, "updatedAt": datetime.utcnow().isoformat(), "schemaVersion": 1,
"updatedAt": datetime.utcnow().isoformat(), # pyright: ignore[reportDeprecated]
"cursors": self._session_cursor, "cursors": self._session_cursor,
}, ensure_ascii=False, indent=2) + "\n", "utf-8") }, ensure_ascii=False, indent=2) + "\n", "utf-8")
except Exception as e: except Exception as e:
@@ -917,13 +961,22 @@ class MochatChannel(BaseChannel):
parsed = response.json() parsed = response.json()
except Exception: except Exception:
parsed = response.text parsed = response.text
if isinstance(parsed, dict) and isinstance(parsed.get("code"), int): if isinstance(parsed, dict):
if parsed["code"] != 200: parsed_dict = cast(dict[str, Any], parsed)
msg = str(parsed.get("message") or parsed.get("name") or "request failed") if isinstance(parsed_dict.get("code"), int):
raise RuntimeError(f"Mochat API error: {msg} (code={parsed['code']})") if parsed_dict["code"] != 200:
data = parsed.get("data") msg = str(
return data if isinstance(data, dict) else {} parsed_dict.get("message")
return parsed if isinstance(parsed, dict) else {} or parsed_dict.get("name")
or "request failed"
)
raise RuntimeError(
f"Mochat API error: {msg} (code={parsed_dict['code']})"
)
data = parsed_dict.get("data")
return cast(dict[str, Any], data) if isinstance(data, dict) else {}
return parsed_dict
return {}
async def _api_send(self, path: str, id_key: str, id_val: str, async def _api_send(self, path: str, id_key: str, id_val: str,
content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]: content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]:
@@ -937,7 +990,7 @@ class MochatChannel(BaseChannel):
@staticmethod @staticmethod
def _read_group_id(metadata: dict[str, Any]) -> str | None: def _read_group_id(metadata: dict[str, Any]) -> str | None:
if not isinstance(metadata, dict): if not isinstance(cast(object, metadata), dict):
return None return None
value = metadata.get("group_id") or metadata.get("groupId") value = metadata.get("group_id") or metadata.get("groupId")
return value.strip() if isinstance(value, str) and value.strip() else None return value.strip() if isinstance(value, str) and value.strip() else None
+60 -39
View File
@@ -23,7 +23,8 @@ import time
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from dataclasses import dataclass from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import TYPE_CHECKING, Any from pathlib import Path
from typing import TYPE_CHECKING, Any, Generator, cast
from urllib.parse import urlparse from urllib.parse import urlparse
try: # pragma: no cover - Windows fallback path try: # pragma: no cover - Windows fallback path
@@ -47,9 +48,11 @@ MSTEAMS_AVAILABLE = (
if TYPE_CHECKING: if TYPE_CHECKING:
import jwt import jwt
from jwt.algorithms import RSAAlgorithm
if MSTEAMS_AVAILABLE: if MSTEAMS_AVAILABLE:
import jwt import jwt
from jwt.algorithms import RSAAlgorithm
MSTEAMS_REF_TTL_DAYS = 30 MSTEAMS_REF_TTL_DAYS = 30
MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com" MSTEAMS_WEBCHAT_HOST = "webchat.botframework.com"
@@ -182,9 +185,10 @@ class MSTeamsChannel(BaseChannel):
auth_header = self.headers.get("Authorization", "") auth_header = self.headers.get("Authorization", "")
if channel.config.validate_inbound_auth: if channel.config.validate_inbound_auth:
try: try:
loop = cast(asyncio.AbstractEventLoop, channel._loop)
fut = asyncio.run_coroutine_threadsafe( fut = asyncio.run_coroutine_threadsafe(
channel._validate_inbound_auth(auth_header, payload), channel._validate_inbound_auth(auth_header, payload),
channel._loop, loop,
) )
fut.result(timeout=15) fut.result(timeout=15)
except Exception as e: except Exception as e:
@@ -195,9 +199,10 @@ class MSTeamsChannel(BaseChannel):
self.wfile.write(b'{"error":"unauthorized"}') self.wfile.write(b'{"error":"unauthorized"}')
return return
try: try:
loop = cast(asyncio.AbstractEventLoop, channel._loop)
fut = asyncio.run_coroutine_threadsafe( fut = asyncio.run_coroutine_threadsafe(
channel._handle_activity(payload), channel._handle_activity(payload),
channel._loop, loop,
) )
fut.result(timeout=15) fut.result(timeout=15)
except Exception as e: except Exception as e:
@@ -269,7 +274,7 @@ class MSTeamsChannel(BaseChannel):
"text": msg.content or " ", "text": msg.content or " ",
} }
if use_thread_reply: if use_thread_reply:
payload["replyToId"] = ref.activity_id payload["replyToId"] = cast(str, ref.activity_id)
try: try:
resp = await self._http.post(base_url, headers=headers, json=payload) resp = await self._http.post(base_url, headers=headers, json=payload)
@@ -285,10 +290,10 @@ class MSTeamsChannel(BaseChannel):
if activity.get("type") != "message": if activity.get("type") != "message":
return return
conversation = activity.get("conversation") or {} conversation = cast(dict[str, Any], activity.get("conversation") or {})
from_user = activity.get("from") or {} from_user = cast(dict[str, Any], activity.get("from") or {})
recipient = activity.get("recipient") or {} recipient = cast(dict[str, Any], activity.get("recipient") or {})
channel_data = activity.get("channelData") or {} channel_data = cast(dict[str, Any], activity.get("channelData") or {})
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip() sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
conversation_id = str(conversation.get("id") or "").strip() conversation_id = str(conversation.get("id") or "").strip()
@@ -336,7 +341,16 @@ class MSTeamsChannel(BaseChannel):
bot_id=str(recipient.get("id") or "") or None, bot_id=str(recipient.get("id") or "") or None,
activity_id=activity_id or None, activity_id=activity_id or None,
conversation_type=conversation_type or None, conversation_type=conversation_type or None,
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None, tenant_id=(
str(
cast(
dict[str, Any],
channel_data.get("tenant") or {},
).get("id")
or ""
)
or None
),
updated_at=time.time(), updated_at=time.time(),
) )
self._save_refs_locked() self._save_refs_locked()
@@ -361,7 +375,7 @@ class MSTeamsChannel(BaseChannel):
text = self._strip_possible_bot_mention(text) text = self._strip_possible_bot_mention(text)
text = self._normalize_html_whitespace(text) text = self._normalize_html_whitespace(text)
channel_data = activity.get("channelData") or {} channel_data = cast(dict[str, Any], activity.get("channelData") or {})
reply_to_id = str(activity.get("replyToId") or "").strip() reply_to_id = str(activity.get("replyToId") or "").strip()
normalized_preview = html.unescape(text).replace("&rsquo", "").strip() normalized_preview = html.unescape(text).replace("&rsquo", "").strip()
normalized_preview = normalized_preview.replace("\xa0", " ") normalized_preview = normalized_preview.replace("\xa0", " ")
@@ -473,15 +487,15 @@ class MSTeamsChannel(BaseChannel):
raise ValueError("missing token kid") raise ValueError("missing token kid")
jwks = await self._get_botframework_jwks() jwks = await self._get_botframework_jwks()
keys = jwks.get("keys") or [] keys = cast(list[dict[str, Any]], jwks.get("keys") or [])
jwk = next((key for key in keys if key.get("kid") == kid), None) jwk = next((key for key in keys if key.get("kid") == kid), None)
if not jwk: if not jwk:
raise ValueError(f"signing key not found for kid={kid}") raise ValueError(f"signing key not found for kid={kid}")
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk)) public_key = RSAAlgorithm.from_jwk(json.dumps(jwk))
claims = jwt.decode( claims = jwt.decode(
token, token,
key=public_key, key=cast(Any, public_key),
algorithms=["RS256"], algorithms=["RS256"],
audience=self.config.app_id, audience=self.config.app_id,
issuer="https://api.botframework.com", issuer="https://api.botframework.com",
@@ -509,9 +523,10 @@ class MSTeamsChannel(BaseChannel):
resp = await self._http.get(self._botframework_openid_config_url) resp = await self._http.get(self._botframework_openid_config_url)
resp.raise_for_status() resp.raise_for_status()
self._botframework_openid_config = resp.json() openid_config = cast(dict[str, Any], resp.json())
self._botframework_openid_config = openid_config
self._botframework_openid_config_expires_at = now + 3600 self._botframework_openid_config_expires_at = now + 3600
return self._botframework_openid_config return openid_config
async def _get_botframework_jwks(self) -> dict[str, Any]: async def _get_botframework_jwks(self) -> dict[str, Any]:
"""Fetch and cache Bot Framework JWKS.""" """Fetch and cache Bot Framework JWKS."""
@@ -530,36 +545,38 @@ class MSTeamsChannel(BaseChannel):
resp = await self._http.get(jwks_uri) resp = await self._http.get(jwks_uri)
resp.raise_for_status() resp.raise_for_status()
self._botframework_jwks = resp.json() jwks = cast(dict[str, Any], resp.json())
self._botframework_jwks = jwks
self._botframework_jwks_expires_at = now + 3600 self._botframework_jwks_expires_at = now + 3600
return self._botframework_jwks return jwks
@staticmethod @staticmethod
def _safe_float(value: Any) -> float | None: def _safe_float(value: object) -> float | None:
try: try:
out = float(value) out = float(cast(Any, value))
if out > 0: if out > 0:
return out return out
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
return None return None
def _normalize_ref_record(self, value: Any) -> ConversationRef | None: def _normalize_ref_record(self, value: object) -> ConversationRef | None:
"""Normalize a stored ref record from legacy/current schema.""" """Normalize a stored ref record from legacy/current schema."""
if not isinstance(value, dict): if not isinstance(value, dict):
return None return None
service_url = str(value.get("service_url") or "").strip() record = cast(dict[str, Any], value)
conversation_id = str(value.get("conversation_id") or "").strip() service_url = str(record.get("service_url") or "").strip()
conversation_id = str(record.get("conversation_id") or "").strip()
if not service_url or not conversation_id: if not service_url or not conversation_id:
return None return None
return ConversationRef( return ConversationRef(
service_url=service_url, service_url=service_url,
conversation_id=conversation_id, conversation_id=conversation_id,
bot_id=str(value.get("bot_id") or "") or None, bot_id=str(record.get("bot_id") or "") or None,
activity_id=str(value.get("activity_id") or "") or None, activity_id=str(record.get("activity_id") or "") or None,
conversation_type=str(value.get("conversation_type") or "") or None, conversation_type=str(record.get("conversation_type") or "") or None,
tenant_id=str(value.get("tenant_id") or "") or None, tenant_id=str(record.get("tenant_id") or "") or None,
updated_at=self._safe_float(value.get("updated_at")), updated_at=self._safe_float(cast(object, record.get("updated_at"))),
) )
def _load_refs_raw(self) -> tuple[dict[str, Any], dict[str, Any], bool]: def _load_refs_raw(self) -> tuple[dict[str, Any], dict[str, Any], bool]:
@@ -570,17 +587,19 @@ class MSTeamsChannel(BaseChannel):
if self._refs_path.exists(): if self._refs_path.exists():
try: try:
loaded = json.loads(self._refs_path.read_text(encoding="utf-8")) loaded: object = json.loads(self._refs_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict): if isinstance(loaded, dict):
main_data = loaded main_data = cast(dict[str, Any], loaded)
except Exception as e: except Exception as e:
self.logger.warning("Failed to load conversation refs: {}", e) self.logger.warning("Failed to load conversation refs: {}", e)
if meta_exists: if meta_exists:
try: try:
loaded_meta = json.loads(self._refs_meta_path.read_text(encoding="utf-8")) loaded_meta: object = json.loads(
self._refs_meta_path.read_text(encoding="utf-8")
)
if isinstance(loaded_meta, dict): if isinstance(loaded_meta, dict):
meta_data = loaded_meta meta_data = cast(dict[str, Any], loaded_meta)
except Exception as e: except Exception as e:
self.logger.warning("Failed to load conversation refs metadata: {}", e) self.logger.warning("Failed to load conversation refs metadata: {}", e)
@@ -599,10 +618,11 @@ class MSTeamsChannel(BaseChannel):
if not ref: if not ref:
continue continue
meta_entry = meta_data.get(key) if isinstance(meta_data, dict) else None meta_entry = cast(object, meta_data.get(key))
meta_ts = None meta_ts: float | None = None
if isinstance(meta_entry, dict): if isinstance(meta_entry, dict):
meta_ts = self._safe_float(meta_entry.get("updated_at")) meta_record = cast(dict[str, Any], meta_entry)
meta_ts = self._safe_float(cast(object, meta_record.get("updated_at")))
elif meta_entry is not None: elif meta_entry is not None:
meta_ts = self._safe_float(meta_entry) meta_ts = self._safe_float(meta_entry)
@@ -623,7 +643,7 @@ class MSTeamsChannel(BaseChannel):
return self._load_refs_from_disk() return self._load_refs_from_disk()
@contextmanager @contextmanager
def _refs_file_lock(self): def _refs_file_lock(self) -> Generator[None, None, None]:
"""Cross-process lock while merging and writing refs state.""" """Cross-process lock while merging and writing refs state."""
self._refs_path.parent.mkdir(parents=True, exist_ok=True) self._refs_path.parent.mkdir(parents=True, exist_ok=True)
lock_fp = self._refs_lock_path.open("a+", encoding="utf-8") lock_fp = self._refs_lock_path.open("a+", encoding="utf-8")
@@ -742,7 +762,7 @@ class MSTeamsChannel(BaseChannel):
if persist: if persist:
self._save_refs_locked() self._save_refs_locked()
def _write_json_atomically(self, path, data: dict[str, Any]) -> None: def _write_json_atomically(self, path: Path, data: dict[str, Any]) -> None:
"""Write refs JSON atomically to reduce corruption risk during crashes.""" """Write refs JSON atomically to reduce corruption risk during crashes."""
payload = json.dumps(data, indent=2) payload = json.dumps(data, indent=2)
tmp_path: str | None = None tmp_path: str | None = None
@@ -816,7 +836,8 @@ class MSTeamsChannel(BaseChannel):
} }
resp = await self._http.post(token_url, data=data) resp = await self._http.post(token_url, data=data)
resp.raise_for_status() resp.raise_for_status()
payload = resp.json() payload = cast(dict[str, Any], resp.json())
self._token = payload["access_token"] token = cast(str, payload["access_token"])
self._token = token
self._token_expires_at = now + int(payload.get("expires_in", 3600)) self._token_expires_at = now + int(payload.get("expires_in", 3600))
return self._token return token
+27 -18
View File
@@ -11,7 +11,7 @@ import time
import uuid import uuid
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal, cast
import aiohttp import aiohttp
from loguru import logger from loguru import logger
@@ -103,7 +103,7 @@ class NapcatChannel(BaseChannel):
await asyncio.sleep(next(backoff, 30)) await asyncio.sleep(next(backoff, 30))
async def _run_once(self) -> None: async def _run_once(self) -> None:
headers = [] headers: list[tuple[str, str]] = []
if self.config.access_token: if self.config.access_token:
headers.append(("Authorization", f"Bearer {self.config.access_token}")) headers.append(("Authorization", f"Bearer {self.config.access_token}"))
@@ -132,12 +132,17 @@ class NapcatChannel(BaseChannel):
payload = json.loads(raw) payload = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if isinstance(payload, dict) and payload.get("echo") == echo: if isinstance(payload, dict):
data = payload.get("data") or {} login_payload = cast(dict[str, Any], payload)
else:
login_payload = None
if login_payload is not None and login_payload.get("echo") == echo:
data = login_payload.get("data")
login_data = cast(dict[str, Any], data) if isinstance(data, dict) else {}
logger.info( logger.info(
"napcat: logged in as {} (user_id={})", "napcat: logged in as {} (user_id={})",
data.get("nickname"), login_data.get("nickname"),
data.get("user_id"), login_data.get("user_id"),
) )
break break
await self._dispatch_frame(raw) await self._dispatch_frame(raw)
@@ -189,26 +194,27 @@ class NapcatChannel(BaseChannel):
return return
if not isinstance(payload, dict): if not isinstance(payload, dict):
return return
frame = cast(dict[str, Any], payload)
# Action response: identified by `echo` and absence of post_type. # Action response: identified by `echo` and absence of post_type.
if "echo" in payload and payload.get("post_type") is None: if "echo" in frame and frame.get("post_type") is None:
echo = payload.get("echo") echo = frame.get("echo")
fut = self._pending.pop(echo, None) if isinstance(echo, str) else None fut = self._pending.pop(echo, None) if isinstance(echo, str) else None
if fut and not fut.done(): if fut and not fut.done():
fut.set_result(payload) fut.set_result(frame)
return return
if (sid := payload.get("self_id")) is not None: if (sid := frame.get("self_id")) is not None:
try: try:
self._self_id = int(sid) self._self_id = int(sid)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
post_type = payload.get("post_type") post_type = frame.get("post_type")
if post_type == "message": if post_type == "message":
self._create_background_task(self._on_message(payload), "message") self._create_background_task(self._on_message(frame), "message")
elif post_type == "notice": elif post_type == "notice":
self._create_background_task(self._on_notice(payload), "notice") self._create_background_task(self._on_notice(frame), "notice")
def _create_background_task(self, coro: Any, kind: str) -> None: def _create_background_task(self, coro: Any, kind: str) -> None:
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
@@ -249,7 +255,8 @@ class NapcatChannel(BaseChannel):
if local := await self._download_image(info): if local := await self._download_image(info):
media_paths.append(local) media_paths.append(local)
sender = ev.get("sender") or {} sender_raw = ev.get("sender")
sender = cast(dict[str, Any], sender_raw) if isinstance(sender_raw, dict) else {}
nickname = sender.get("card") or sender.get("nickname") nickname = sender.get("card") or sender.get("nickname")
if message_type == "group": if message_type == "group":
@@ -270,7 +277,7 @@ class NapcatChannel(BaseChannel):
chat_id = f"group:{group_id}" chat_id = f"group:{group_id}"
content = self._format_group_content( content = self._format_group_content(
text=text, text=text,
nickname=nickname, nickname=cast(str, nickname),
user_id=user_id, user_id=user_id,
) )
else: else:
@@ -299,7 +306,7 @@ class NapcatChannel(BaseChannel):
# segment rather than parsing CQ codes — that path is fragile and # segment rather than parsing CQ codes — that path is fragile and
# users can configure napcat to emit arrays. # users can configure napcat to emit arrays.
if isinstance(message, list): if isinstance(message, list):
return [seg for seg in message if isinstance(seg, dict)] return [cast(dict[str, Any], seg) for seg in cast(list[Any], message) if isinstance(seg, dict)]
if isinstance(message, str) and message: if isinstance(message, str) and message:
return [{"type": "text", "data": {"text": message}}] return [{"type": "text", "data": {"text": message}}]
return [] return []
@@ -315,7 +322,8 @@ class NapcatChannel(BaseChannel):
for seg in segments: for seg in segments:
stype = seg.get("type") stype = seg.get("type")
data = seg.get("data") or {} raw_data = seg.get("data")
data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {}
if stype == "text": if stype == "text":
if txt := data.get("text"): if txt := data.get("text"):
parts.append(str(txt)) parts.append(str(txt))
@@ -455,7 +463,8 @@ class NapcatChannel(BaseChannel):
params["user_id"] = int(target) params["user_id"] = int(target)
resp = await self._call_action("send_msg", params) resp = await self._call_action("send_msg", params)
data = resp.get("data") or {} raw_data = resp.get("data")
data = cast(dict[str, Any], raw_data) if isinstance(raw_data, dict) else {}
if (mid := data.get("message_id")) is not None: if (mid := data.get("message_id")) is not None:
self._bot_outbound_ids.append(int(mid)) self._bot_outbound_ids.append(int(mid))
+5 -5
View File
@@ -7,7 +7,7 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from importlib.resources import files from importlib.resources import files
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, cast
from packaging.requirements import InvalidRequirement, Requirement from packaging.requirements import InvalidRequirement, Requirement
@@ -49,12 +49,12 @@ class ChannelPlugin:
_target_parts(self.runtime, label="runtime") _target_parts(self.runtime, label="runtime")
if self.connector is not None: if self.connector is not None:
_target_parts(self.connector, label="connector") _target_parts(self.connector, label="connector")
if self.setup is not None and not isinstance(self.setup, ChannelSetupSpec): if self.setup is not None and not isinstance(cast(object, self.setup), ChannelSetupSpec):
raise TypeError("channel plugin setup must be a ChannelSetupSpec or None") raise TypeError("channel plugin setup must be a ChannelSetupSpec or None")
if not isinstance(self.management, ChannelManagementSpec): if not isinstance(cast(object, self.management), ChannelManagementSpec):
raise TypeError("channel plugin management must be a ChannelManagementSpec") raise TypeError("channel plugin management must be a ChannelManagementSpec")
if not isinstance(self.dependencies, tuple) or not all( if not isinstance(cast(object, self.dependencies), tuple) or not all(
isinstance(requirement, str) and requirement.strip() isinstance(cast(object, requirement), str) and requirement.strip()
for requirement in self.dependencies for requirement in self.dependencies
): ):
raise TypeError("channel plugin dependencies must be a tuple of requirements") raise TypeError("channel plugin dependencies must be a tuple of requirements")
+55 -39
View File
@@ -16,6 +16,8 @@ Notes:
- Attachment structures differ across botpy versions; we try multiple field candidates. - Attachment structures differ across botpy versions; we try multiple field candidates.
""" """
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@@ -27,7 +29,7 @@ import time
from collections import deque from collections import deque
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal from typing import Any, BinaryIO, Literal, cast
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
import aiohttp import aiohttp
@@ -58,11 +60,6 @@ except ImportError: # pragma: no cover
BotWebSocket = None BotWebSocket = None
Route = None Route = None
if TYPE_CHECKING:
from botpy.message import BaseMessage, C2CMessage, GroupMessage
from botpy.types.message import Media
# QQ rich media file_type: 1=image, 4=file # QQ rich media file_type: 1=image, 4=file
# (2=voice, 3=video are restricted; we only use image vs file) # (2=voice, 3=video are restricted; we only use image vs file)
QQ_FILE_TYPE_IMAGE = 1 QQ_FILE_TYPE_IMAGE = 1
@@ -118,30 +115,34 @@ def _is_network_error(exc: BaseException) -> bool:
) )
def _make_bot_class(channel: QQChannel) -> type[botpy.Client]: def _make_bot_class(channel: QQChannel) -> type[Any]:
"""Create a botpy client with per-session reconnect backoff.""" """Create a botpy client with per-session reconnect backoff."""
intents = botpy.Intents(public_messages=True, direct_message=True) botpy_sdk = cast(Any, botpy)
intents = botpy_sdk.Intents(public_messages=True, direct_message=True)
class _Bot(botpy.Client): class _Bot(botpy_sdk.Client):
def __init__(self): def __init__(self):
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs # Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
super().__init__(intents=intents, ext_handlers=False) super().__init__( # pyright: ignore[reportUnknownMemberType]
intents=intents,
ext_handlers=False,
)
self._ws_backoff: dict[int, int] = {} self._ws_backoff: dict[int, int] = {}
self._ws_retry_at: dict[int, float] = {} self._ws_retry_at: dict[int, float] = {}
async def on_ready(self): async def on_ready(self):
logger.info("QQ bot ready: {}", self.robot.name) logger.info("QQ bot ready: {}", self.robot.name)
async def on_c2c_message_create(self, message: C2CMessage): async def on_c2c_message_create(self, message: object) -> None:
await channel._on_message(message, is_group=False) await channel._on_message(message, is_group=False)
async def on_group_at_message_create(self, message: GroupMessage): async def on_group_at_message_create(self, message: object) -> None:
await channel._on_message(message, is_group=True) await channel._on_message(message, is_group=True)
async def on_direct_message_create(self, message): async def on_direct_message_create(self, message: object) -> None:
await channel._on_message(message, is_group=False) await channel._on_message(message, is_group=False)
async def bot_connect(self, session): async def bot_connect(self, session: object) -> None:
"""Connect a botpy session with exponential retry backoff.""" """Connect a botpy session with exponential retry backoff."""
session_id = id(session) session_id = id(session)
retry_at = self._ws_retry_at.pop(session_id, None) retry_at = self._ws_retry_at.pop(session_id, None)
@@ -150,7 +151,8 @@ def _make_bot_class(channel: QQChannel) -> type[botpy.Client]:
if remaining > 0: if remaining > 0:
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
client = BotWebSocket(session, self._connection) websocket_class = cast(Any, BotWebSocket)
client = websocket_class(session, self._connection)
backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START) backoff = self._ws_backoff.get(session_id, _RECONNECT_BACKOFF_START)
try: try:
await client.ws_connect() await client.ws_connect()
@@ -207,7 +209,7 @@ class QQChannel(BaseChannel):
super().__init__(config, bus) super().__init__(config, bus)
self.config: QQConfig = config self.config: QQConfig = config
self._client: botpy.Client | None = None self._client: Any | None = None
self._http: aiohttp.ClientSession | None = None self._http: aiohttp.ClientSession | None = None
self._processed_ids: deque[str] = deque(maxlen=1000) self._processed_ids: deque[str] = deque(maxlen=1000)
@@ -260,7 +262,8 @@ class QQChannel(BaseChannel):
max_backoff = 300 max_backoff = 300
while self._running: while self._running:
try: try:
await self._client.start(appid=self.config.app_id, secret=self.config.secret) client = cast(Any, self._client)
await client.start(appid=self.config.app_id, secret=self.config.secret)
backoff = 5 backoff = 5
except Exception as e: except Exception as e:
if _is_network_error(e): if _is_network_error(e):
@@ -490,7 +493,7 @@ class QQChannel(BaseChannel):
file_data: str, file_data: str,
file_name: str | None = None, file_name: str | None = None,
srv_send_msg: bool = False, srv_send_msg: bool = False,
) -> Media: ) -> dict[str, Any]:
"""Upload base64-encoded file and return Media object.""" """Upload base64-encoded file and return Media object."""
if not self._client: if not self._client:
raise RuntimeError("QQ client not initialized") raise RuntimeError("QQ client not initialized")
@@ -514,39 +517,44 @@ class QQChannel(BaseChannel):
if file_type != QQ_FILE_TYPE_IMAGE and file_name: if file_type != QQ_FILE_TYPE_IMAGE and file_name:
payload["file_name"] = file_name payload["file_name"] = file_name
route = Route("POST", endpoint, **{id_key: chat_id}) route_class = cast(Any, Route)
result = await self._client.api._http.request(route, json=payload) route = route_class("POST", endpoint, **{id_key: chat_id})
client = self._client
result: object = await client.api._http.request(route, json=payload)
# Extract only the file_info field to avoid extra fields (file_uuid, ttl, etc.) # Extract only the file_info field to avoid extra fields (file_uuid, ttl, etc.)
# that may confuse QQ client when sending the media object. # that may confuse QQ client when sending the media object.
if isinstance(result, dict) and "file_info" in result: if isinstance(result, dict) and "file_info" in result:
return {"file_info": result["file_info"]} result_data = cast(dict[str, Any], result)
return result return {"file_info": result_data["file_info"]}
return cast(dict[str, Any], result)
# --------------------------- # ---------------------------
# Inbound (receive) # Inbound (receive)
# --------------------------- # ---------------------------
async def _on_message(self, data: C2CMessage | GroupMessage, is_group: bool = False) -> None: async def _on_message(self, data: object, is_group: bool = False) -> None:
"""Parse inbound message, download attachments, and publish to the bus.""" """Parse inbound message, download attachments, and publish to the bus."""
try: try:
message = cast(Any, data)
if is_group: if is_group:
chat_id = data.group_openid chat_id = cast(str, message.group_openid)
user_id = data.author.member_openid user_id = cast(str, message.author.member_openid)
chat_type = "group" chat_type = "group"
else: else:
chat_id = str( chat_id = str(
getattr(data.author, "id", None) getattr(message.author, "id", None)
or getattr(data.author, "user_openid", "unknown") or getattr(message.author, "user_openid", "unknown")
) )
user_id = chat_id user_id = chat_id
chat_type = "c2c" chat_type = "c2c"
content = (data.content or "").strip() content = str(message.content or "").strip()
if data.id in self._processed_ids: message_id = cast(str, message.id)
if message_id in self._processed_ids:
return return
self._processed_ids.append(data.id) self._processed_ids.append(message_id)
self._chat_type_cache[chat_id] = chat_type self._chat_type_cache[chat_id] = chat_type
# Early permission check — avoid attachment downloads and ack side effects # Early permission check — avoid attachment downloads and ack side effects
@@ -564,7 +572,10 @@ class QQChannel(BaseChannel):
# the data used by tests don't contain attachments property # the data used by tests don't contain attachments property
# so we use getattr with a default of [] to avoid AttributeError in tests # so we use getattr with a default of [] to avoid AttributeError in tests
attachments = getattr(data, "attachments", None) or [] attachments = cast(
list[object],
getattr(message, "attachments", None) or [],
)
media_paths, recv_lines, att_meta = await self._handle_attachments(attachments) media_paths, recv_lines, att_meta = await self._handle_attachments(attachments)
# Compose content that always contains actionable saved paths # Compose content that always contains actionable saved paths
@@ -587,7 +598,7 @@ class QQChannel(BaseChannel):
await self._send_text_only( await self._send_text_only(
chat_id=chat_id, chat_id=chat_id,
is_group=is_group, is_group=is_group,
msg_id=data.id, msg_id=message_id,
content=self.config.ack_message, content=self.config.ack_message,
) )
except Exception: except Exception:
@@ -599,17 +610,20 @@ class QQChannel(BaseChannel):
content=content, content=content,
media=media_paths if media_paths else None, media=media_paths if media_paths else None,
metadata={ metadata={
"message_id": data.id, "message_id": message_id,
"attachments": att_meta, "attachments": att_meta,
}, },
is_dm=not is_group, is_dm=not is_group,
) )
except Exception: except Exception:
self.logger.exception("Error handling inbound message id={}", getattr(data, "id", "?")) self.logger.exception(
"Error handling inbound message id={}",
getattr(data, "id", "?"),
)
async def _handle_attachments( async def _handle_attachments(
self, self,
attachments: list[BaseMessage._Attachments], attachments: list[object],
) -> tuple[list[str], list[str], list[dict[str, Any]]]: ) -> tuple[list[str], list[str], list[dict[str, Any]]]:
"""Extract, download (chunked), and format attachments for agent consumption.""" """Extract, download (chunked), and format attachments for agent consumption."""
media_paths: list[str] = [] media_paths: list[str] = []
@@ -718,9 +732,11 @@ class QQChannel(BaseChannel):
1024 * 1024, int(self.config.download_max_bytes or (200 * 1024 * 1024)) 1024 * 1024, int(self.config.download_max_bytes or (200 * 1024 * 1024))
) )
def _open_tmp(): active_tmp_path = tmp_path
tmp_path.parent.mkdir(parents=True, exist_ok=True)
return open(tmp_path, "wb") # noqa: SIM115 def _open_tmp() -> BinaryIO:
active_tmp_path.parent.mkdir(parents=True, exist_ok=True)
return active_tmp_path.open("wb") # noqa: SIM115
f = await asyncio.to_thread(_open_tmp) f = await asyncio.to_thread(_open_tmp)
try: try:
@@ -740,7 +756,7 @@ class QQChannel(BaseChannel):
await asyncio.to_thread(f.close) await asyncio.to_thread(f.close)
# Atomic rename # Atomic rename
await asyncio.to_thread(os.replace, tmp_path, target) await asyncio.to_thread(os.replace, active_tmp_path, target)
tmp_path = None # mark as moved tmp_path = None # mark as moved
self.logger.info("file saved: {}", str(target)) self.logger.info("file saved: {}", str(target))
return str(target) return str(target)
+76 -51
View File
@@ -12,7 +12,7 @@ from collections.abc import AsyncIterator, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, TypedDict, cast
import httpx import httpx
from pydantic import Field, computed_field, field_validator from pydantic import Field, computed_field, field_validator
@@ -53,7 +53,7 @@ _SIG_TOKEN_RE = re.compile(r"\x00C(\d+)\x00")
# stripper needs a fixed, narrow subset (no single-asterisk italic, no # stripper needs a fixed, narrow subset (no single-asterisk italic, no
# single-tilde strikethrough) and benefits from each pattern's group 1 being # single-tilde strikethrough) and benefits from each pattern's group 1 being
# the content directly. # the content directly.
_SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern, str], ...] = ( _SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"\*\*(.+?)\*\*"), r"\1"), (re.compile(r"\*\*(.+?)\*\*"), r"\1"),
(re.compile(r"__(.+?)__"), r"\1"), (re.compile(r"__(.+?)__"), r"\1"),
(re.compile(r"~~(.+?)~~"), r"\1"), (re.compile(r"~~(.+?)~~"), r"\1"),
@@ -61,6 +61,27 @@ _SIG_CELL_STRIP_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
) )
def _as_json_object(value: object) -> dict[str, Any] | None:
"""Return an untrusted JSON value only when it is an object."""
if isinstance(value, dict):
return cast(dict[str, Any], value)
return None
def _as_json_object_list(value: object) -> list[dict[str, Any]]:
"""Return the object members of an untrusted JSON array."""
if not isinstance(value, list):
return []
return [cast(dict[str, Any], item) for item in cast(list[object], value) if isinstance(item, dict)]
class _BufferedMessage(TypedDict):
sender_name: str
sender_number: str
content: str
timestamp: int | None
def _utf16_len(s: str) -> int: def _utf16_len(s: str) -> int:
"""UTF-16 code-unit length, matching Signal BodyRange semantics.""" """UTF-16 code-unit length, matching Signal BodyRange semantics."""
return len(s.encode("utf-16-le")) // 2 return len(s.encode("utf-16-le")) // 2
@@ -118,7 +139,7 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
# so they're protected from inline-style processing. # so they're protected from inline-style processing.
protected: list[str] = [] protected: list[str] = []
def save_code(m: re.Match) -> str: def save_code(m: re.Match[str]) -> str:
protected.append(m.group(1)) protected.append(m.group(1))
return f"\x00C{len(protected) - 1}\x00" return f"\x00C{len(protected) - 1}\x00"
@@ -149,8 +170,8 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
runs: list[_Run] = [_Run(text)] runs: list[_Run] = [_Run(text)]
def transform( def transform(
pattern: re.Pattern, pattern: re.Pattern[str],
make_runs: Callable[[re.Match, frozenset[str]], list[_Run]], make_runs: Callable[[re.Match[str], frozenset[str]], list[_Run]],
) -> None: ) -> None:
new_runs: list[_Run] = [] new_runs: list[_Run] = []
for run in runs: for run in runs:
@@ -189,7 +210,7 @@ def _markdown_to_signal(text: str) -> tuple[str, list[str]]:
transform(_SIG_OLIST_RE, lambda m, s: [_Run(m.group(1) + ". ", s)]) transform(_SIG_OLIST_RE, lambda m, s: [_Run(m.group(1) + ". ", s)])
# Links → "text (url)" or bare url when text equals url. # Links → "text (url)" or bare url when text equals url.
def _link_runs(m: re.Match, s: frozenset) -> list[_Run]: def _link_runs(m: re.Match[str], s: frozenset[str]) -> list[_Run]:
link_text, url = m.group(1), m.group(2) link_text, url = m.group(1), m.group(2)
def _norm(u: str) -> str: def _norm(u: str) -> str:
@@ -357,15 +378,15 @@ class SignalChannel(BaseChannel):
self.config: SignalConfig = config self.config: SignalConfig = config
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._request_id = 0 self._request_id = 0
self._sse_task: asyncio.Task | None = None self._sse_task: asyncio.Task[None] | None = None
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task[None]] = {}
self._typing_uuid_warnings: set[str] = set() self._typing_uuid_warnings: set[str] = set()
self._account_id_aliases: set[str] = set() self._account_id_aliases: set[str] = set()
self._remember_account_id_alias(self.config.phone_number) self._remember_account_id_alias(self.config.phone_number)
# Rolling message buffer for group context (group_id -> deque of messages) # Rolling message buffer for group context (group_id -> deque of messages)
# Each message is a dict with: sender_name, sender_number, content, timestamp # Each message is a dict with: sender_name, sender_number, content, timestamp
self._group_buffers: dict[str, deque] = {} self._group_buffers: dict[str, deque[_BufferedMessage]] = {}
def is_allowed(self, sender_id: str) -> bool: def is_allowed(self, sender_id: str) -> bool:
"""Override base check to normalize and split pipe-joined identifiers. """Override base check to normalize and split pipe-joined identifiers.
@@ -409,6 +430,7 @@ class SignalChannel(BaseChannel):
metadata: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None,
session_key: str | None = None, session_key: str | None = None,
is_dm: bool = False, is_dm: bool = False,
authorization_id: str | None = None,
) -> None: ) -> None:
"""Handle an inbound message whose policy has already been checked. """Handle an inbound message whose policy has already been checked.
@@ -418,6 +440,7 @@ class SignalChannel(BaseChannel):
``super()._handle_message`` instead, which goes through ``super()._handle_message`` instead, which goes through
``is_allowed`` and issues a pairing code. ``is_allowed`` and issues a pairing code.
""" """
del authorization_id
meta = metadata or {} meta = metadata or {}
if self.supports_streaming: if self.supports_streaming:
meta = {**meta, "_wants_stream": True} meta = {**meta, "_wants_stream": True}
@@ -594,7 +617,7 @@ class SignalChannel(BaseChannel):
self.logger.info("Subscribed to Signal messages via SSE") self.logger.info("Subscribed to Signal messages via SSE")
# Buffer for accumulating SSE data across multiple lines # Buffer for accumulating SSE data across multiple lines
event_buffer = [] event_buffer: list[str] = []
async for line in response.aiter_lines(): async for line in response.aiter_lines():
if not self._running: if not self._running:
@@ -605,7 +628,7 @@ class SignalChannel(BaseChannel):
self.logger.debug("SSE line received: {}", line[:200]) self.logger.debug("SSE line received: {}", line[:200])
# SSE format handling # SSE format handling
if isinstance(line, str): if isinstance(line, str): # pyright: ignore[reportUnnecessaryIsInstance]
# Empty line signals end of event # Empty line signals end of event
if not line or line == ":": if not line or line == ":":
if event_buffer: if event_buffer:
@@ -613,7 +636,10 @@ class SignalChannel(BaseChannel):
data_str = "" data_str = ""
try: try:
data_str = "\n".join(event_buffer) data_str = "\n".join(event_buffer)
data = json.loads(data_str) data = _as_json_object(json.loads(data_str))
if data is None:
self.logger.warning("Ignoring non-object SSE event: {}", data_str[:200])
continue
self.logger.debug("SSE event parsed: {}", data) self.logger.debug("SSE event parsed: {}", data)
await self._handle_receive_notification(data) await self._handle_receive_notification(data)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
@@ -644,7 +670,7 @@ class SignalChannel(BaseChannel):
self.logger.error("Error in SSE receive loop: {}", e) self.logger.error("Error in SSE receive loop: {}", e)
raise raise
@asynccontextmanager @asynccontextmanager # pyright: ignore[reportDeprecated]
async def _safe_handle(self, action: str, payload: Any = None) -> AsyncIterator[None]: async def _safe_handle(self, action: str, payload: Any = None) -> AsyncIterator[None]:
"""Swallow and log any exception from a top-level handler block. """Swallow and log any exception from a top-level handler block.
@@ -666,17 +692,18 @@ class SignalChannel(BaseChannel):
self.logger.debug("_handle_receive_notification called with: {}", params) self.logger.debug("_handle_receive_notification called with: {}", params)
async with self._safe_handle("receive notification", params): async with self._safe_handle("receive notification", params):
# Extract envelope from SSE notification: {"envelope": {...}} # Extract envelope from SSE notification: {"envelope": {...}}
envelope = params.get("envelope", {}) envelope = _as_json_object(params.get("envelope"))
self.logger.debug("Extracted envelope: {}", envelope) self.logger.debug("Extracted envelope: {}", envelope)
if not envelope: if envelope is None:
self.logger.debug("No envelope found in params") self.logger.debug("No envelope found in params")
return return
# Extract sender information # Extract sender information
sender_parts = self._collect_sender_id_parts(envelope) sender_parts = self._collect_sender_id_parts(envelope)
source_name = envelope.get("sourceName") source_name_value = envelope.get("sourceName")
source_name = source_name_value if isinstance(source_name_value, str) else None
if not sender_parts: if not sender_parts:
self.logger.debug("Received message without source, skipping") self.logger.debug("Received message without source, skipping")
@@ -691,10 +718,10 @@ class SignalChannel(BaseChannel):
self._remember_account_id_alias(part) self._remember_account_id_alias(part)
# Check different message types # Check different message types
data_message = envelope.get("dataMessage") data_message = _as_json_object(envelope.get("dataMessage"))
sync_message = envelope.get("syncMessage") sync_message = _as_json_object(envelope.get("syncMessage"))
typing_message = envelope.get("typingMessage") typing_message = _as_json_object(envelope.get("typingMessage"))
receipt_message = envelope.get("receiptMessage") receipt_message = _as_json_object(envelope.get("receiptMessage"))
# Ignore receipt messages (delivery/read receipts) # Ignore receipt messages (delivery/read receipts)
if receipt_message: if receipt_message:
@@ -705,8 +732,7 @@ class SignalChannel(BaseChannel):
await self._handle_data_message(sender_id, sender_number, data_message, source_name) await self._handle_data_message(sender_id, sender_number, data_message, source_name)
# Handle sync messages (messages sent from another device) # Handle sync messages (messages sent from another device)
elif sync_message and sync_message.get("sentMessage"): elif sync_message and (sent_msg := _as_json_object(sync_message.get("sentMessage"))):
sent_msg = sync_message["sentMessage"]
destination = sent_msg.get("destination") or sent_msg.get("destinationNumber") destination = sent_msg.get("destination") or sent_msg.get("destinationNumber")
if destination: if destination:
self.logger.debug( self.logger.debug(
@@ -725,10 +751,12 @@ class SignalChannel(BaseChannel):
sender_name: str | None, sender_name: str | None,
) -> None: ) -> None:
"""Handle a data message (text, attachments, etc.).""" """Handle a data message (text, attachments, etc.)."""
message_text = data_message.get("message") or "" message_value = data_message.get("message")
attachments = data_message.get("attachments", []) message_text = message_value if isinstance(message_value, str) else ""
mentions = data_message.get("mentions", []) attachments = _as_json_object_list(data_message.get("attachments"))
timestamp = data_message.get("timestamp") mentions = _as_json_object_list(data_message.get("mentions"))
timestamp_value = data_message.get("timestamp")
timestamp = timestamp_value if isinstance(timestamp_value, int) else None
self.logger.info( self.logger.info(
"Data message from {}: groupInfo={}, groupV2={}, keys={}", "Data message from {}: groupInfo={}, groupV2={}, keys={}",
@@ -815,7 +843,7 @@ class SignalChannel(BaseChannel):
group_id: str | None, group_id: str | None,
is_group_message: bool, is_group_message: bool,
message_text: str, message_text: str,
mentions: list, mentions: list[dict[str, Any]],
sender_name: str | None, sender_name: str | None,
timestamp: int | None, timestamp: int | None,
) -> tuple[bool, str]: ) -> tuple[bool, str]:
@@ -877,8 +905,8 @@ class SignalChannel(BaseChannel):
sender_name: str | None, sender_name: str | None,
sender_number: str, sender_number: str,
message_text: str, message_text: str,
attachments: list, attachments: list[dict[str, Any]],
mentions: list, mentions: list[dict[str, Any]],
is_group_message: bool, is_group_message: bool,
chat_id: str, chat_id: str,
) -> tuple[str, list[str]]: ) -> tuple[str, list[str]]:
@@ -952,7 +980,9 @@ class SignalChannel(BaseChannel):
""" """
# Create buffer for this group if it doesn't exist # Create buffer for this group if it doesn't exist
if group_id not in self._group_buffers: if group_id not in self._group_buffers:
self._group_buffers[group_id] = deque(maxlen=self.config.group_message_buffer_size) self._group_buffers[group_id] = deque[_BufferedMessage](
maxlen=self.config.group_message_buffer_size
)
# Add message to buffer (deque will automatically drop oldest when full) # Add message to buffer (deque will automatically drop oldest when full)
self._group_buffers[group_id].append( self._group_buffers[group_id].append(
@@ -992,7 +1022,7 @@ class SignalChannel(BaseChannel):
# We want to show context BEFORE the mention # We want to show context BEFORE the mention
context_messages = list(buffer)[:-1] # Exclude the last (current) message context_messages = list(buffer)[:-1] # Exclude the last (current) message
lines = [] lines: list[str] = []
for msg in context_messages: for msg in context_messages:
sender = msg["sender_name"] sender = msg["sender_name"]
content = msg["content"][:200] # Limit to 200 chars per message content = msg["content"][:200] # Limit to 200 chars per message
@@ -1053,8 +1083,6 @@ class SignalChannel(BaseChannel):
"""Remember known bot identifiers for mention matching.""" """Remember known bot identifiers for mention matching."""
if not value: if not value:
return return
if not isinstance(value, str):
return
for candidate in self._normalize_signal_id(value): for candidate in self._normalize_signal_id(value):
self._account_id_aliases.add(candidate) self._account_id_aliases.add(candidate)
@@ -1062,8 +1090,6 @@ class SignalChannel(BaseChannel):
"""Return True when an identifier refers to the bot account.""" """Return True when an identifier refers to the bot account."""
if not value: if not value:
return False return False
if not isinstance(value, str):
return False
return any( return any(
candidate in self._account_id_aliases for candidate in self._normalize_signal_id(value) candidate in self._account_id_aliases for candidate in self._normalize_signal_id(value)
) )
@@ -1097,13 +1123,14 @@ class SignalChannel(BaseChannel):
return sender_parts[0] if sender_parts else "" return sender_parts[0] if sender_parts else ""
@staticmethod @staticmethod
def _extract_group_id(group_info: Any, group_v2: Any) -> str | None: def _extract_group_id(group_info: object, group_v2: object) -> str | None:
"""Extract group ID from groupInfo/groupV2 payloads across signal-cli variants.""" """Extract group ID from groupInfo/groupV2 payloads across signal-cli variants."""
for group_obj in (group_info, group_v2): for group_obj in (group_info, group_v2):
if not isinstance(group_obj, dict): if not isinstance(group_obj, dict):
continue continue
group = cast(dict[str, Any], group_obj)
for key in ("groupId", "id", "groupID"): for key in ("groupId", "id", "groupID"):
value = group_obj.get(key) value = group.get(key)
if isinstance(value, str) and value: if isinstance(value, str) and value:
return value return value
return None return None
@@ -1113,18 +1140,19 @@ class SignalChannel(BaseChannel):
"""Extract possible identifier fields from a mention payload.""" """Extract possible identifier fields from a mention payload."""
ids: list[str] = [] ids: list[str] = []
def _walk(value: dict[str, Any] | Any, depth: int = 0) -> None: def _walk(value: object, depth: int = 0) -> None:
if depth > 2: if depth > 2:
return return
if not isinstance(value, dict): if not isinstance(value, dict):
return return
for key, child in value.items(): object_value = cast(dict[str, Any], value)
key_lower = str(key).lower() for key, child in object_value.items():
key_lower = key.lower()
if isinstance(child, str) and child: if isinstance(child, str) and child:
if any(token in key_lower for token in ("number", "uuid", "serviceid", "aci")): if any(token in key_lower for token in ("number", "uuid", "serviceid", "aci")):
ids.append(child) ids.append(child)
elif isinstance(child, dict): elif isinstance(child, dict):
_walk(child, depth + 1) _walk(cast(object, child), depth + 1)
_walk(mention) _walk(mention)
return list(dict.fromkeys(ids)) return list(dict.fromkeys(ids))
@@ -1187,8 +1215,6 @@ class SignalChannel(BaseChannel):
# If mention is required, check if bot was mentioned. # If mention is required, check if bot was mentioned.
for mention in mentions: for mention in mentions:
if not isinstance(mention, dict):
continue
for mention_id in self._mention_id_candidates(mention): for mention_id in self._mention_id_candidates(mention):
if self._id_matches_account(mention_id): if self._id_matches_account(mention_id):
return True return True
@@ -1197,15 +1223,13 @@ class SignalChannel(BaseChannel):
# (for handle-style mentions). Accept a leading identifier-less mention # (for handle-style mentions). Accept a leading identifier-less mention
# as a mention of the bot to avoid false negatives. # as a mention of the bot to avoid false negatives.
for mention in mentions: for mention in mentions:
if not isinstance(mention, dict):
continue
if self._mention_id_candidates(mention): if self._mention_id_candidates(mention):
continue continue
span = self._mention_span(mention) span = self._mention_span(mention)
if not span: if not span:
continue continue
start, _ = span start, _ = span
if message_text is not None and not message_text[:start].strip(): if not message_text[:start].strip():
self.logger.debug("Accepting identifier-less leading mention as bot mention") self.logger.debug("Accepting identifier-less leading mention as bot mention")
return True return True
@@ -1241,10 +1265,8 @@ class SignalChannel(BaseChannel):
return text return text
# Build a list of (start, length) tuples for our bot's mentions # Build a list of (start, length) tuples for our bot's mentions
bot_mentions = [] bot_mentions: list[tuple[int, int]] = []
for mention in mentions: for mention in mentions:
if not isinstance(mention, dict):
continue
mention_ids = self._mention_id_candidates(mention) mention_ids = self._mention_id_candidates(mention)
span = self._mention_span(mention) span = self._mention_span(mention)
if not span: if not span:
@@ -1382,7 +1404,7 @@ class SignalChannel(BaseChannel):
request_id = self._request_id request_id = self._request_id
# Build JSON-RPC request # Build JSON-RPC request
request = {"jsonrpc": "2.0", "method": method, "id": request_id} request: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "id": request_id}
if params: if params:
request["params"] = params request["params"] = params
@@ -1397,7 +1419,10 @@ class SignalChannel(BaseChannel):
try: try:
response = await self._http.post("/api/v1/rpc", json=request) response = await self._http.post("/api/v1/rpc", json=request)
response.raise_for_status() response.raise_for_status()
return response.json() response_json = _as_json_object(response.json())
if response_json is None:
return {"error": {"message": "signal-cli returned a non-object JSON-RPC response"}}
return response_json
except Exception as e: except Exception as e:
self.logger.error("HTTP request failed: {}", e) self.logger.error("HTTP request failed: {}", e)
return {"error": {"message": str(e)}} return {"error": {"message": str(e)}}
+155 -54
View File
@@ -3,15 +3,16 @@
import asyncio import asyncio
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Protocol, cast
import httpx import httpx
from pydantic import Field from pydantic import Field
from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.socket_mode.response import SocketModeResponse
from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.socket_mode.websockets import SocketModeClient
from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.async_client import AsyncWebClient
from slackify_markdown import slackify_markdown from slackify_markdown import slackify_markdown # pyright: ignore[reportMissingTypeStubs]
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.outbound_events import ProgressEvent from nanobot.bus.outbound_events import ProgressEvent
@@ -23,6 +24,30 @@ from nanobot.pairing import is_approved
from nanobot.utils.helpers import safe_filename, split_message from nanobot.utils.helpers import safe_filename, split_message
def _as_json_object(value: Any) -> dict[str, Any] | None:
"""Narrow Slack's untyped Socket Mode payloads at the boundary."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def _as_json_list(value: Any) -> list[Any] | None:
"""Narrow Slack's untyped Socket Mode arrays at the boundary."""
return cast(list[Any], value) if isinstance(value, list) else None
class _SlackWebAPI(Protocol):
"""Subset of slack-sdk's dynamically typed Web API used by this channel."""
async def auth_test(self, **kwargs: Any) -> Any: ...
async def chat_postMessage(self, **kwargs: Any) -> Any: ... # noqa: N802
async def conversations_list(self, **kwargs: Any) -> Any: ...
async def conversations_open(self, **kwargs: Any) -> Any: ...
async def conversations_replies(self, **kwargs: Any) -> Any: ...
async def files_upload_v2(self, **kwargs: Any) -> Any: ...
async def reactions_add(self, **kwargs: Any) -> Any: ...
async def reactions_remove(self, **kwargs: Any) -> Any: ...
async def users_list(self, **kwargs: Any) -> Any: ...
class SlackDMConfig(Base): class SlackDMConfig(Base):
"""Slack DM policy configuration.""" """Slack DM policy configuration."""
@@ -90,6 +115,13 @@ class SlackChannel(BaseChannel):
self._target_cache: dict[str, str] = {} self._target_cache: dict[str, str] = {}
self._thread_context_attempted: set[str] = set() self._thread_context_attempted: set[str] = set()
def _require_web_api(self) -> _SlackWebAPI:
if self._web_client is None:
raise RuntimeError("Slack Web API client is not started")
# slack-sdk's public methods are runtime-stable but its annotations do
# not expose a useful shared interface, so narrow once at the SDK edge.
return cast(_SlackWebAPI, self._web_client)
async def start(self) -> None: async def start(self) -> None:
"""Start the Slack Socket Mode client.""" """Start the Slack Socket Mode client."""
if not self.config.bot_token or not self.config.app_token: if not self.config.bot_token or not self.config.app_token:
@@ -111,7 +143,8 @@ class SlackChannel(BaseChannel):
# Resolve bot user ID for mention handling # Resolve bot user ID for mention handling
try: try:
auth = await self._web_client.auth_test() web_api = self._require_web_api()
auth = await web_api.auth_test()
self._bot_user_id = auth.get("user_id") self._bot_user_id = auth.get("user_id")
self.logger.info("bot connected as {}", self._bot_user_id) self.logger.info("bot connected as {}", self._bot_user_id)
except Exception as e: except Exception as e:
@@ -155,10 +188,17 @@ class SlackChannel(BaseChannel):
self.logger.warning("client not running") self.logger.warning("client not running")
return return
try: try:
web_api = self._require_web_api()
target_chat_id = await self._resolve_target_chat_id(msg.chat_id) target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} raw_slack_meta: Any = msg.metadata.get("slack", {}) if msg.metadata else {}
slack_meta: dict[str, Any] = (
cast(dict[str, Any], raw_slack_meta)
if isinstance(raw_slack_meta, dict)
else {}
)
thread_ts = slack_meta.get("thread_ts") thread_ts = slack_meta.get("thread_ts")
origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id) event_meta = cast(dict[str, Any], slack_meta.get("event", {}) or {})
origin_chat_id = str(event_meta.get("channel") or msg.chat_id)
# Reply in the same thread the inbound message belongs to (works # Reply in the same thread the inbound message belongs to (works
# for both real channel threads and DM threads). When the agent # for both real channel threads and DM threads). When the agent
# is forwarding to a different channel, drop thread_ts because it # is forwarding to a different channel, drop thread_ts because it
@@ -170,7 +210,20 @@ class SlackChannel(BaseChannel):
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 []):
mrkdwn = self._to_mrkdwn(msg.content) if msg.content else " " mrkdwn = self._to_mrkdwn(msg.content) if msg.content else " "
buttons = getattr(msg, "buttons", None) or [] raw_buttons = getattr(msg, "buttons", None)
buttons: list[list[str]] = (
cast(list[list[str]], raw_buttons)
if isinstance(raw_buttons, list)
and all(
isinstance(row, list)
and all(
isinstance(label, str)
for label in cast(list[object], row)
)
for row in cast(list[object], raw_buttons)
)
else []
)
chunks = split_message(mrkdwn, SLACK_MAX_MESSAGE_LEN) chunks = split_message(mrkdwn, SLACK_MAX_MESSAGE_LEN)
for index, chunk in enumerate(chunks): for index, chunk in enumerate(chunks):
kwargs: dict[str, Any] = dict( kwargs: dict[str, Any] = dict(
@@ -178,11 +231,11 @@ class SlackChannel(BaseChannel):
) )
if buttons and index == len(chunks) - 1: if buttons and index == len(chunks) - 1:
kwargs["blocks"] = self._build_button_blocks(chunk, buttons) kwargs["blocks"] = self._build_button_blocks(chunk, buttons)
await self._web_client.chat_postMessage(**kwargs) await web_api.chat_postMessage(**kwargs)
for media_path in msg.media or []: for media_path in msg.media or []:
try: try:
await self._web_client.files_upload_v2( await web_api.files_upload_v2(
channel=target_chat_id, channel=target_chat_id,
file=media_path, file=media_path,
thread_ts=thread_ts_param, thread_ts=thread_ts_param,
@@ -192,8 +245,16 @@ class SlackChannel(BaseChannel):
# 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 is_progress:
event = slack_meta.get("event", {}) raw_event = slack_meta.get("event", {})
await self._update_react_emoji(origin_chat_id, event.get("ts")) event = (
cast(dict[str, Any], raw_event)
if isinstance(raw_event, dict)
else {}
)
await self._update_react_emoji(
origin_chat_id,
cast(str | None, event.get("ts")),
)
except Exception: except Exception:
self.logger.exception("Error sending message") self.logger.exception("Error sending message")
@@ -237,20 +298,26 @@ class SlackChannel(BaseChannel):
return self._target_cache[cache_key] return self._target_cache[cache_key]
cursor: str | None = None cursor: str | None = None
web_api = self._require_web_api()
while True: while True:
response = await self._web_client.conversations_list( response = cast(dict[str, Any], await web_api.conversations_list(
types="public_channel,private_channel", types="public_channel,private_channel",
exclude_archived=True, exclude_archived=True,
limit=200, limit=200,
cursor=cursor, cursor=cursor,
) ))
for channel in response.get("channels", []): for channel_value in cast(list[object], response.get("channels", [])):
channel = cast(dict[str, Any], channel_value)
if self._normalize_target_name(str(channel.get("name") or "")) == normalized: if self._normalize_target_name(str(channel.get("name") or "")) == normalized:
channel_id = str(channel.get("id") or "") channel_id = str(channel.get("id") or "")
if channel_id: if channel_id:
self._target_cache[cache_key] = channel_id self._target_cache[cache_key] = channel_id
return channel_id return channel_id
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() response_metadata = cast(
dict[str, Any],
response.get("response_metadata") or {},
)
cursor = str(response_metadata.get("next_cursor") or "").strip()
if not cursor: if not cursor:
break break
@@ -269,9 +336,14 @@ class SlackChannel(BaseChannel):
return self._target_cache[cache_key] return self._target_cache[cache_key]
cursor: str | None = None cursor: str | None = None
web_api = self._require_web_api()
while True: while True:
response = await self._web_client.users_list(limit=200, cursor=cursor) response = cast(
for member in response.get("members", []): dict[str, Any],
await web_api.users_list(limit=200, cursor=cursor),
)
for member_value in cast(list[object], response.get("members", [])):
member = cast(dict[str, Any], member_value)
if self._member_matches_handle(member, normalized): if self._member_matches_handle(member, normalized):
user_id = str(member.get("id") or "") user_id = str(member.get("id") or "")
if not user_id: if not user_id:
@@ -279,7 +351,11 @@ class SlackChannel(BaseChannel):
dm_id = await self._open_dm_for_user(user_id) dm_id = await self._open_dm_for_user(user_id)
self._target_cache[cache_key] = dm_id self._target_cache[cache_key] = dm_id
return dm_id return dm_id
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip() response_metadata = cast(
dict[str, Any],
response.get("response_metadata") or {},
)
cursor = str(response_metadata.get("next_cursor") or "").strip()
if not cursor: if not cursor:
break break
@@ -288,8 +364,13 @@ class SlackChannel(BaseChannel):
) )
async def _open_dm_for_user(self, user_id: str) -> str: async def _open_dm_for_user(self, user_id: str) -> str:
response = await self._web_client.conversations_open(users=user_id) web_api = self._require_web_api()
channel_id = str(((response.get("channel") or {}).get("id")) or "") response = cast(
dict[str, Any],
await web_api.conversations_open(users=user_id),
)
channel = cast(dict[str, Any], response.get("channel") or {})
channel_id = str(channel.get("id") or "")
if not channel_id: if not channel_id:
raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.") raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.")
return channel_id return channel_id
@@ -300,7 +381,7 @@ class SlackChannel(BaseChannel):
@classmethod @classmethod
def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool: def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool:
profile = member.get("profile") or {} profile = cast(dict[str, Any], member.get("profile") or {})
candidates = { candidates = {
str(member.get("name") or ""), str(member.get("name") or ""),
str(profile.get("display_name") or ""), str(profile.get("display_name") or ""),
@@ -312,7 +393,7 @@ class SlackChannel(BaseChannel):
async def _on_socket_request( async def _on_socket_request(
self, self,
client: SocketModeClient, client: AsyncBaseSocketModeClient,
req: SocketModeRequest, req: SocketModeRequest,
) -> None: ) -> None:
"""Handle incoming Socket Mode requests.""" """Handle incoming Socket Mode requests."""
@@ -327,8 +408,8 @@ class SlackChannel(BaseChannel):
SocketModeResponse(envelope_id=req.envelope_id) SocketModeResponse(envelope_id=req.envelope_id)
) )
payload = req.payload or {} payload = _as_json_object(cast(Any, req).payload) or {}
event = payload.get("event") or {} event = _as_json_object(payload.get("event")) or {}
event_type = event.get("type") event_type = event.get("type")
# Handle app mentions or plain messages # Handle app mentions or plain messages
@@ -349,6 +430,8 @@ class SlackChannel(BaseChannel):
# Avoid double-processing: Slack sends both `message` and `app_mention` # Avoid double-processing: Slack sends both `message` and `app_mention`
# for mentions in channels. Prefer `app_mention`. # for mentions in channels. Prefer `app_mention`.
text = event.get("text") or "" text = event.get("text") or ""
if not isinstance(text, str):
return
if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text: if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
return return
@@ -362,10 +445,12 @@ class SlackChannel(BaseChannel):
event.get("channel_type"), event.get("channel_type"),
text[:80], text[:80],
) )
if not sender_id or not chat_id: if not isinstance(sender_id, str) or not sender_id or not isinstance(chat_id, str) or not chat_id:
return return
channel_type = event.get("channel_type") or "" channel_type = event.get("channel_type") or ""
if not isinstance(channel_type, str):
channel_type = ""
if not self._is_allowed(sender_id, chat_id, channel_type): if not self._is_allowed(sender_id, chat_id, channel_type):
if channel_type == "im" and self.config.dm.enabled: if channel_type == "im" and self.config.dm.enabled:
@@ -383,7 +468,9 @@ class SlackChannel(BaseChannel):
text = self._strip_bot_mention(text) text = self._strip_bot_mention(text)
event_ts = event.get("ts") event_ts = event.get("ts")
event_ts = event_ts if isinstance(event_ts, str) else None
raw_thread_ts = event.get("thread_ts") raw_thread_ts = event.get("thread_ts")
raw_thread_ts = raw_thread_ts if isinstance(raw_thread_ts, str) else None
thread_ts = raw_thread_ts thread_ts = raw_thread_ts
# In DMs we don't auto-open a thread on top-level messages (it would # In DMs we don't auto-open a thread on top-level messages (it would
# bury replies under "1 reply"). But if the user explicitly opened a # bury replies under "1 reply"). But if the user explicitly opened a
@@ -396,27 +483,28 @@ class SlackChannel(BaseChannel):
thread_ts = event_ts thread_ts = event_ts
# Add :eyes: reaction to the triggering message (best-effort) # Add :eyes: reaction to the triggering message (best-effort)
try: try:
if self._web_client and event.get("ts"): if self._web_client and event_ts:
await self._web_client.reactions_add( web_api = self._require_web_api()
await web_api.reactions_add(
channel=chat_id, channel=chat_id,
name=self.config.react_emoji, name=self.config.react_emoji,
timestamp=event.get("ts"), timestamp=event_ts,
) )
except Exception as e: except Exception as e:
self.logger.debug("reactions_add failed: {}", e) self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the user is in a real thread # Thread-scoped session key whenever the turn lives in a thread: either the
# (raw_thread_ts is set). DM threads get their own session, separate # message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
# from the DM root, so context doesn't bleed across thread boundaries. # thread for this channel message. DM roots have no thread_ts and keep the
session_key = ( # default per-chat session, so context doesn't bleed across thread boundaries.
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
)
media_paths: list[str] = [] media_paths: list[str] = []
file_markers: list[str] = [] file_markers: list[str] = []
for file_info in event.get("files") or []: for file_info in _as_json_list(event.get("files")) or []:
if not isinstance(file_info, dict): file_info_object = _as_json_object(file_info)
if file_info_object is None:
continue continue
file_path, marker = await self._download_slack_file(file_info) file_path, marker = await self._download_slack_file(file_info_object)
if file_path: if file_path:
media_paths.append(file_path) media_paths.append(file_path)
if marker: if marker:
@@ -503,22 +591,30 @@ class SlackChannel(BaseChannel):
preview = response.content[:256].lstrip().lower() preview = response.content[:256].lstrip().lower()
return preview.startswith(_HTML_DOWNLOAD_PREFIXES) return preview.startswith(_HTML_DOWNLOAD_PREFIXES)
async def _on_block_action(self, client: SocketModeClient, req: SocketModeRequest) -> None: async def _on_block_action(
self,
client: AsyncBaseSocketModeClient,
req: SocketModeRequest,
) -> None:
"""Handle button clicks from inline action buttons.""" """Handle button clicks from inline action buttons."""
await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id)) await client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
payload = req.payload or {} payload = cast(dict[str, Any], cast(Any, req).payload or {})
actions = payload.get("actions") or [] actions = cast(list[Any], payload.get("actions") or [])
if not actions: if not actions:
return return
value = str(actions[0].get("value") or "") action = cast(dict[str, Any], actions[0])
user_info = payload.get("user") or {} value = str(action.get("value") or "")
user_info = cast(dict[str, Any], payload.get("user") or {})
sender_id = str(user_info.get("id") or "") sender_id = str(user_info.get("id") or "")
channel_info = payload.get("channel") or {} channel_info = cast(dict[str, Any], payload.get("channel") or {})
chat_id = str(channel_info.get("id") or "") chat_id = str(channel_info.get("id") or "")
if not sender_id or not chat_id or not value: if not sender_id or not chat_id or not value:
return return
message_info = payload.get("message") or {} message_info = cast(dict[str, Any], payload.get("message") or {})
thread_ts = message_info.get("thread_ts") or message_info.get("ts") thread_ts = cast(
str | None,
message_info.get("thread_ts") or message_info.get("ts"),
)
channel_type = self._infer_channel_type(chat_id) channel_type = self._infer_channel_type(chat_id)
if not self._is_allowed(sender_id, chat_id, channel_type): if not self._is_allowed(sender_id, chat_id, channel_type):
return return
@@ -563,17 +659,18 @@ class SlackChannel(BaseChannel):
self._thread_context_attempted.add(key) self._thread_context_attempted.add(key)
try: try:
response = await self._web_client.conversations_replies( web_api = self._require_web_api()
response = cast(dict[str, Any], await web_api.conversations_replies(
channel=chat_id, channel=chat_id,
ts=thread_ts, ts=thread_ts,
limit=max(1, self.config.thread_context_limit), limit=max(1, self.config.thread_context_limit),
) ))
except Exception as e: except Exception as e:
self.logger.warning("thread context unavailable for {}: {}", key, e) self.logger.warning("thread context unavailable for {}: {}", key, e)
return text return text
lines = self._format_thread_context( lines = self._format_thread_context(
response.get("messages", []), cast(list[dict[str, Any]], response.get("messages", [])),
current_ts=current_ts, current_ts=current_ts,
) )
if not lines: if not lines:
@@ -605,7 +702,7 @@ class SlackChannel(BaseChannel):
blocks: list[dict[str, Any]] = [ blocks: list[dict[str, Any]] = [
{"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}}, {"type": "section", "text": {"type": "mrkdwn", "text": text[:3000]}},
] ]
elements = [] elements: list[dict[str, Any]] = []
for row in buttons: for row in buttons:
for label in row: for label in row:
elements.append({ elements.append({
@@ -622,8 +719,9 @@ class SlackChannel(BaseChannel):
"""Remove the in-progress reaction and optionally add a done reaction.""" """Remove the in-progress reaction and optionally add a done reaction."""
if not self._web_client or not ts: if not self._web_client or not ts:
return return
web_api = self._require_web_api()
try: try:
await self._web_client.reactions_remove( await web_api.reactions_remove(
channel=chat_id, channel=chat_id,
name=self.config.react_emoji, name=self.config.react_emoji,
timestamp=ts, timestamp=ts,
@@ -632,7 +730,7 @@ class SlackChannel(BaseChannel):
self.logger.debug("reactions_remove failed: {}", e) self.logger.debug("reactions_remove failed: {}", e)
if self.config.done_emoji: if self.config.done_emoji:
try: try:
await self._web_client.reactions_add( await web_api.reactions_add(
channel=chat_id, channel=chat_id,
name=self.config.done_emoji, name=self.config.done_emoji,
timestamp=ts, timestamp=ts,
@@ -703,7 +801,7 @@ class SlackChannel(BaseChannel):
return "" return ""
code_blocks: list[str] = [] code_blocks: list[str] = []
def _save_fence(m: re.Match) -> str: def _save_fence(m: re.Match[str]) -> str:
code_blocks.append(m.group(0)) code_blocks.append(m.group(0))
return f"\x00CB{len(code_blocks) - 1}\x00" return f"\x00CB{len(code_blocks) - 1}\x00"
@@ -718,7 +816,7 @@ class SlackChannel(BaseChannel):
"""Fix markdown artifacts that slackify_markdown misses.""" """Fix markdown artifacts that slackify_markdown misses."""
code_blocks: list[str] = [] code_blocks: list[str] = []
def _save_code(m: re.Match) -> str: def _save_code(m: re.Match[str]) -> str:
code_blocks.append(m.group(0)) code_blocks.append(m.group(0))
return f"\x00CB{len(code_blocks) - 1}\x00" return f"\x00CB{len(code_blocks) - 1}\x00"
@@ -726,14 +824,17 @@ class SlackChannel(BaseChannel):
text = cls._INLINE_CODE_RE.sub(_save_code, text) text = cls._INLINE_CODE_RE.sub(_save_code, text)
text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text) text = cls._LEFTOVER_BOLD_RE.sub(r"*\1*", text)
text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text) text = cls._LEFTOVER_HEADER_RE.sub(r"*\1*", text)
text = cls._BARE_URL_RE.sub(lambda m: m.group(0).replace("&amp;", "&"), text) text = cls._BARE_URL_RE.sub(
lambda m: m.group(0).replace("&amp;", "&"),
text,
)
for i, block in enumerate(code_blocks): for i, block in enumerate(code_blocks):
text = text.replace(f"\x00CB{i}\x00", block) text = text.replace(f"\x00CB{i}\x00", block)
return text return text
@staticmethod @staticmethod
def _convert_table(match: re.Match) -> str: def _convert_table(match: re.Match[str]) -> str:
"""Convert a Markdown table to a Slack-readable list.""" """Convert a Markdown table to a Slack-readable list."""
lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()] lines = [ln.strip() for ln in match.group(0).strip().splitlines() if ln.strip()]
if len(lines) < 2: if len(lines) < 2:
@@ -555,6 +555,113 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100" assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
return SimpleNamespace(
type="events_api",
envelope_id=envelope_id,
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> hello",
"ts": ts,
}
},
)
@pytest.mark.asyncio
async def test_channel_root_message_uses_thread_scoped_session() -> None:
"""A channel mention that opens a thread belongs to that thread's session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c1", "1700000000.000100")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
@pytest.mark.asyncio
async def test_channel_root_messages_do_not_share_one_session() -> None:
"""Two threads opened in the same channel must not collapse into one session."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
first = _channel_mention_request("env-c1", "1700000000.000100")
second = _channel_mention_request("env-c2", "1700000000.000200")
await channel._on_socket_request(client, first)
await channel._on_socket_request(client, second)
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
assert session_keys == [
"slack:C123:1700000000.000100",
"slack:C123:1700000000.000200",
]
@pytest.mark.asyncio
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = _channel_mention_request("env-c3", "1700000000.000300")
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] is None
assert kwargs["metadata"]["slack"]["thread_ts"] is None
@pytest.mark.asyncio
async def test_channel_thread_reply_keeps_thread_session() -> None:
"""A reply inside a channel thread stays in the session opened by the root message."""
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
channel._bot_user_id = "UBOT"
channel._web_client = _FakeAsyncWebClient()
channel._handle_message = AsyncMock() # type: ignore[method-assign]
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
req = SimpleNamespace(
type="events_api",
envelope_id="env-c4",
payload={
"event": {
"type": "app_mention",
"user": "U1",
"channel": "C123",
"text": "<@UBOT> follow up",
"ts": "1700000000.000400",
"thread_ts": "1700000000.000100",
}
},
)
await channel._on_socket_request(client, req)
channel._handle_message.assert_awaited_once()
kwargs = channel._handle_message.await_args.kwargs
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None: async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
+98 -55
View File
@@ -8,8 +8,9 @@ import time
import unicodedata import unicodedata
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Literal from typing import Any, Awaitable, Callable, Literal, TypeAlias, TypeVar, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
@@ -17,9 +18,12 @@ from telegram import (
BotCommand, BotCommand,
InlineKeyboardButton, InlineKeyboardButton,
InlineKeyboardMarkup, InlineKeyboardMarkup,
Message,
MessageEntity,
ReactionTypeEmoji, ReactionTypeEmoji,
ReplyParameters, ReplyParameters,
Update, Update,
User,
) )
from telegram.error import BadRequest, NetworkError, TimedOut from telegram.error import BadRequest, NetworkError, TimedOut
from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters from telegram.ext import Application, CallbackQueryHandler, ContextTypes, MessageHandler, filters
@@ -43,6 +47,12 @@ TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit
TELEGRAM_HTML_MAX_LEN = 4096 TELEGRAM_HTML_MAX_LEN = 4096
TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message TELEGRAM_REPLY_CONTEXT_MAX_LEN = TELEGRAM_MAX_MESSAGE_LEN # Max length for reply context in user message
# python-telegram-bot exposes a six-parameter Application generic. Nanobot
# doesn't customize its context/data/job-queue types, so keep that SDK boundary
# explicit rather than allowing unspecialized generics to spread Unknown.
TelegramApplication: TypeAlias = Application[Any, Any, Any, Any, Any, Any]
_T = TypeVar("_T")
def _split_telegram_markdown(content: str, max_len: int) -> list[str]: def _split_telegram_markdown(content: str, max_len: int) -> list[str]:
"""Split raw Telegram Markdown without leaving fenced code blocks unbalanced.""" """Split raw Telegram Markdown without leaving fenced code blocks unbalanced."""
@@ -218,7 +228,7 @@ def _markdown_to_telegram_html(text: str) -> str:
# 1. Extract and protect code blocks (preserve content from other processing) # 1. Extract and protect code blocks (preserve content from other processing)
code_blocks: list[str] = [] code_blocks: list[str] = []
def save_code_block(m: re.Match) -> str: def save_code_block(m: re.Match[str]) -> str:
code_blocks.append(m.group(1)) code_blocks.append(m.group(1))
return f"\x00CB{len(code_blocks) - 1}\x00" return f"\x00CB{len(code_blocks) - 1}\x00"
@@ -247,7 +257,7 @@ def _markdown_to_telegram_html(text: str) -> str:
# 2. Extract and protect inline code # 2. Extract and protect inline code
inline_codes: list[str] = [] inline_codes: list[str] = []
def save_inline_code(m: re.Match) -> str: def save_inline_code(m: re.Match[str]) -> str:
inline_codes.append(m.group(1)) inline_codes.append(m.group(1))
return f"\x00IC{len(inline_codes) - 1}\x00" return f"\x00IC{len(inline_codes) - 1}\x00"
@@ -350,7 +360,7 @@ class _QueuedTelegramUpdate:
kind: Literal["command", "message"] kind: Literal["command", "message"]
update: Update update: Update
context: Any context: ContextTypes.DEFAULT_TYPE
sort_key: tuple[int, int] sort_key: tuple[int, int]
@@ -421,7 +431,7 @@ class TelegramChannel(BaseChannel):
display_name = "Telegram" display_name = "Telegram"
# Commands registered with Telegram's command menu # Commands registered with Telegram's command menu
BOT_COMMANDS = [ BOT_COMMANDS: list[BotCommand] = [
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"), BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"), BotCommand("stop", "Stop the current task"),
@@ -455,19 +465,24 @@ class TelegramChannel(BaseChannel):
config = TelegramConfig.model_validate(config) config = TelegramConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
self.config: TelegramConfig = config self.config: TelegramConfig = config
self._app: Application | None = None self._app: TelegramApplication | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._typing_tasks: dict[str, asyncio.Task[None]] = {} # chat_id -> typing loop task
self._media_group_buffers: dict[str, dict] = {} self._media_group_buffers: dict[str, dict[str, Any]] = {}
self._media_group_tasks: dict[str, asyncio.Task] = {} self._media_group_tasks: dict[str, asyncio.Task[None]] = {}
self._message_threads: dict[tuple[str, int], int] = {} self._message_threads: dict[tuple[str, int], int] = {}
self._bot_user_id: int | None = None self._bot_user_id: int | None = None
self._bot_username: str | None = None self._bot_username: str | None = None
self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state self._stream_bufs: dict[str, _StreamBuf] = {} # chat_id -> streaming state
self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {} self._inbound_buffers: dict[str, list[_QueuedTelegramUpdate]] = {}
self._inbound_workers: dict[str, asyncio.Task] = {} self._inbound_workers: dict[str, asyncio.Task[None]] = {}
self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1 self._rich_send_disabled: bool = False # Latch off if Bot API < 10.1
def _require_app(self) -> TelegramApplication:
if self._app is None:
raise RuntimeError("Telegram application is not started")
return self._app
def is_allowed(self, sender_id: str) -> bool: def is_allowed(self, sender_id: str) -> bool:
"""Preserve Telegram's legacy id|username allowlist matching.""" """Preserve Telegram's legacy id|username allowlist matching."""
if super().is_allowed(sender_id): if super().is_allowed(sender_id):
@@ -595,7 +610,7 @@ class TelegramChannel(BaseChannel):
if self.config.mode == "webhook": if self.config.mode == "webhook":
# ``url_path`` is the local HTTP route. ``webhook_url`` is the # ``url_path`` is the local HTTP route. ``webhook_url`` is the
# public HTTPS URL Telegram calls; reverse proxies may rewrite it. # public HTTPS URL Telegram calls; reverse proxies may rewrite it.
await self._app.updater.start_webhook( await cast(Any, self._app.updater).start_webhook(
listen=self.config.webhook_listen_host, listen=self.config.webhook_listen_host,
port=self.config.webhook_listen_port, port=self.config.webhook_listen_port,
url_path=self.config.webhook_path.lstrip("/"), url_path=self.config.webhook_path.lstrip("/"),
@@ -607,7 +622,7 @@ class TelegramChannel(BaseChannel):
) )
else: else:
# Start polling (this runs until stopped) # Start polling (this runs until stopped)
await self._app.updater.start_polling( await cast(Any, self._app.updater).start_polling(
allowed_updates=allowed_updates, allowed_updates=allowed_updates,
drop_pending_updates=False, # Process pending messages on startup drop_pending_updates=False, # Process pending messages on startup
error_callback=self._on_polling_error, error_callback=self._on_polling_error,
@@ -637,7 +652,7 @@ class TelegramChannel(BaseChannel):
if self._app: if self._app:
self.logger.info("Stopping bot...") self.logger.info("Stopping bot...")
await self._app.updater.stop() await cast(Any, self._app.updater).stop()
await self._app.stop() await self._app.stop()
await self._app.shutdown() await self._app.shutdown()
self._app = None self._app = None
@@ -674,9 +689,9 @@ class TelegramChannel(BaseChannel):
self, self,
chat_id: int, chat_id: int,
content: str, content: str,
reply_params=None, reply_params: ReplyParameters | dict[str, int | bool] | None = None,
thread_kwargs: dict | None = None, thread_kwargs: dict[str, int] | None = None,
reply_markup=None, reply_markup: InlineKeyboardMarkup | None = None,
) -> bool: ) -> bool:
"""Attempt sendRichMessage (Bot API 10.1). Returns True on success.""" """Attempt sendRichMessage (Bot API 10.1). Returns True on success."""
if not self._app: if not self._app:
@@ -692,13 +707,17 @@ class TelegramChannel(BaseChannel):
# sendRichMessage uses reply_parameters (object), not reply_to_message_id. # sendRichMessage uses reply_parameters (object), not reply_to_message_id.
if hasattr(reply_params, "message_id"): if hasattr(reply_params, "message_id"):
payload["reply_parameters"] = { payload["reply_parameters"] = {
"message_id": reply_params.message_id, "message_id": cast(ReplyParameters, reply_params).message_id,
"allow_sending_without_reply": True, "allow_sending_without_reply": True,
} }
else: else:
payload["reply_parameters"] = reply_params payload["reply_parameters"] = reply_params
if thread_kwargs: if thread_kwargs:
payload.update({k: v for k, v in thread_kwargs.items() if v is not None}) payload.update({
k: v
for k, v in thread_kwargs.items()
if v is not None # pyright: ignore[reportUnnecessaryComparison]
})
if reply_markup is not None: if reply_markup is not None:
payload["reply_markup"] = reply_markup payload["reply_markup"] = reply_markup
@@ -749,7 +768,7 @@ class TelegramChannel(BaseChannel):
message_thread_id = msg.metadata.get("message_thread_id") message_thread_id = msg.metadata.get("message_thread_id")
if message_thread_id is None and reply_to_message_id is not None: if message_thread_id is None and reply_to_message_id is not None:
message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id)) message_thread_id = self._message_threads.get((msg.chat_id, reply_to_message_id))
thread_kwargs = {} thread_kwargs: dict[str, int] = {}
if message_thread_id is not None: if message_thread_id is not None:
thread_kwargs["message_thread_id"] = message_thread_id thread_kwargs["message_thread_id"] = message_thread_id
@@ -820,7 +839,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(progress_event and progress_event.tool_hint)
buttons = getattr(msg, "buttons", None) or [] buttons = cast(list[list[str]], 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
# Fallback: no native keyboard → splice labels into the message so the choices survive. # Fallback: no native keyboard → splice labels into the message so the choices survive.
@@ -850,7 +869,12 @@ class TelegramChannel(BaseChannel):
reply_markup=reply_markup if is_last else None, reply_markup=reply_markup if is_last else None,
) )
async def _call_with_retry(self, fn, *args, **kwargs): async def _call_with_retry(
self,
fn: Callable[..., Awaitable[_T]],
*args: Any,
**kwargs: Any,
) -> _T:
"""Call an async Telegram API function with retry on pool/network timeout and RetryAfter.""" """Call an async Telegram API function with retry on pool/network timeout and RetryAfter."""
from telegram.error import RetryAfter from telegram.error import RetryAfter
@@ -869,27 +893,34 @@ class TelegramChannel(BaseChannel):
except RetryAfter as e: except RetryAfter as e:
if attempt == _SEND_MAX_RETRIES: if attempt == _SEND_MAX_RETRIES:
raise raise
delay = float(e.retry_after) retry_after = e.retry_after
delay = (
retry_after.total_seconds()
if isinstance(retry_after, timedelta)
else float(retry_after)
)
self.logger.warning( self.logger.warning(
"Flood Control (attempt {}/{}), retrying in {:.1f}s", "Flood Control (attempt {}/{}), retrying in {:.1f}s",
attempt, _SEND_MAX_RETRIES, delay, attempt, _SEND_MAX_RETRIES, delay,
) )
await asyncio.sleep(delay) await asyncio.sleep(delay)
raise RuntimeError("Telegram retry loop exited unexpectedly")
async def _send_text( async def _send_text(
self, self,
chat_id: int, chat_id: int,
text: str, text: str,
reply_params=None, reply_params: ReplyParameters | None = None,
thread_kwargs: dict | None = None, thread_kwargs: dict[str, int] | None = None,
render_as_blockquote: bool = False, render_as_blockquote: bool = False,
reply_markup=None, reply_markup: InlineKeyboardMarkup | None = None,
) -> None: ) -> None:
"""Send a plain text message with HTML fallback.""" """Send a plain text message with HTML fallback."""
app = self._require_app()
try: try:
html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text) html = _tool_hint_to_telegram_blockquote(text) if render_as_blockquote else _markdown_to_telegram_html(text)
await self._call_with_retry( await self._call_with_retry(
self._app.bot.send_message, app.bot.send_message,
chat_id=chat_id, text=html, parse_mode="HTML", chat_id=chat_id, text=html, parse_mode="HTML",
reply_parameters=reply_params, reply_parameters=reply_params,
reply_markup=reply_markup, reply_markup=reply_markup,
@@ -899,7 +930,7 @@ class TelegramChannel(BaseChannel):
self.logger.warning("HTML parse failed, falling back to plain text: {}", e) self.logger.warning("HTML parse failed, falling back to plain text: {}", e)
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.send_message, app.bot.send_message,
chat_id=chat_id, chat_id=chat_id,
text=text, text=text,
reply_parameters=reply_params, reply_parameters=reply_params,
@@ -945,7 +976,7 @@ class TelegramChannel(BaseChannel):
if reply_to_message_id := meta.get("message_id"): if reply_to_message_id := meta.get("message_id"):
with suppress(ValueError): with suppress(ValueError):
await self._remove_reaction(chat_id, int(reply_to_message_id)) await self._remove_reaction(chat_id, int(reply_to_message_id))
thread_kwargs = {} thread_kwargs: dict[str, int] = {}
if message_thread_id := meta.get("message_thread_id"): if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id thread_kwargs["message_thread_id"] = message_thread_id
raw_text = buf.text raw_text = buf.text
@@ -1032,16 +1063,16 @@ class TelegramChannel(BaseChannel):
return return
now = time.monotonic() now = time.monotonic()
thread_kwargs = {} stream_thread_kwargs: dict[str, int] = {}
if message_thread_id := meta.get("message_thread_id"): if message_thread_id := meta.get("message_thread_id"):
thread_kwargs["message_thread_id"] = message_thread_id stream_thread_kwargs["message_thread_id"] = message_thread_id
if buf.message_id is None: if buf.message_id is None:
preview = _strip_md_block(buf.text) preview = _strip_md_block(buf.text)
try: try:
sent = await self._call_with_retry( sent = await self._call_with_retry(
self._app.bot.send_message, self._app.bot.send_message,
chat_id=int_chat_id, text=preview, chat_id=int_chat_id, text=preview,
**thread_kwargs, **stream_thread_kwargs,
) )
buf.message_id = sent.message_id buf.message_id = sent.message_id
buf.last_edit = now buf.last_edit = now
@@ -1050,7 +1081,7 @@ class TelegramChannel(BaseChannel):
raise # Let ChannelManager handle retry raise # Let ChannelManager handle retry
elif (now - buf.last_edit) >= self.config.stream_edit_interval: elif (now - buf.last_edit) >= self.config.stream_edit_interval:
if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN: if len(buf.text) > TELEGRAM_MAX_MESSAGE_LEN:
await self._flush_stream_overflow(int_chat_id, buf, thread_kwargs) await self._flush_stream_overflow(int_chat_id, buf, stream_thread_kwargs)
buf.last_edit = now buf.last_edit = now
return return
preview = _strip_md_block(buf.text) preview = _strip_md_block(buf.text)
@@ -1072,7 +1103,7 @@ class TelegramChannel(BaseChannel):
self, self,
chat_id: int, chat_id: int,
buf: "_StreamBuf", buf: "_StreamBuf",
thread_kwargs: dict, thread_kwargs: dict[str, int],
) -> None: ) -> None:
"""Split an oversized stream buffer mid-flight. """Split an oversized stream buffer mid-flight.
@@ -1083,10 +1114,11 @@ class TelegramChannel(BaseChannel):
chunks = _split_telegram_markdown_html_chunks(buf.text, TELEGRAM_HTML_MAX_LEN) chunks = _split_telegram_markdown_html_chunks(buf.text, TELEGRAM_HTML_MAX_LEN)
if len(chunks) <= 1: if len(chunks) <= 1:
return return
app = self._require_app()
first_markdown, first_html = chunks[0] first_markdown, first_html = chunks[0]
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, app.bot.edit_message_text,
chat_id=chat_id, message_id=buf.message_id, chat_id=chat_id, message_id=buf.message_id,
text=first_html, text=first_html,
parse_mode="HTML", parse_mode="HTML",
@@ -1098,7 +1130,7 @@ class TelegramChannel(BaseChannel):
) )
try: try:
await self._call_with_retry( await self._call_with_retry(
self._app.bot.edit_message_text, app.bot.edit_message_text,
chat_id=chat_id, message_id=buf.message_id, chat_id=chat_id, message_id=buf.message_id,
text=first_markdown, text=first_markdown,
) )
@@ -1113,7 +1145,7 @@ class TelegramChannel(BaseChannel):
async def send_chunk(markdown: str, html: str) -> Any: async def send_chunk(markdown: str, html: str) -> Any:
try: try:
return await self._call_with_retry( return await self._call_with_retry(
self._app.bot.send_message, app.bot.send_message,
chat_id=chat_id, text=html, parse_mode="HTML", **thread_kwargs, chat_id=chat_id, text=html, parse_mode="HTML", **thread_kwargs,
) )
except BadRequest as e: except BadRequest as e:
@@ -1121,7 +1153,7 @@ class TelegramChannel(BaseChannel):
"Stream overflow HTML send failed, falling back to plain text: {}", e "Stream overflow HTML send failed, falling back to plain text: {}", e
) )
return await self._call_with_retry( return await self._call_with_retry(
self._app.bot.send_message, app.bot.send_message,
chat_id=chat_id, text=markdown, **thread_kwargs, chat_id=chat_id, text=markdown, **thread_kwargs,
) )
@@ -1160,12 +1192,14 @@ class TelegramChannel(BaseChannel):
await update.message.reply_text(build_help_text()) await update.message.reply_text(build_help_text())
@staticmethod @staticmethod
def _sender_id(user) -> str: def _sender_id(user: User) -> str:
"""Build sender_id with username for allowlist matching.""" """Build sender_id with username for allowlist matching."""
sid = str(user.id) sid = str(user.id)
return f"{sid}|{user.username}" if user.username else sid return f"{sid}|{user.username}" if user.username else sid
async def _send_pairing_code_if_private(self, sender_id: str, message, user) -> None: async def _send_pairing_code_if_private(
self, sender_id: str, message: Message, user: User
) -> None:
if message.chat.type != "private": if message.chat.type != "private":
return return
await self._handle_message( await self._handle_message(
@@ -1177,7 +1211,7 @@ class TelegramChannel(BaseChannel):
) )
@staticmethod @staticmethod
def _derive_topic_session_key(message) -> str | None: def _derive_topic_session_key(message: Message) -> str | None:
"""Derive topic-scoped session key for Telegram chats with threads.""" """Derive topic-scoped session key for Telegram chats with threads."""
message_thread_id = getattr(message, "message_thread_id", None) message_thread_id = getattr(message, "message_thread_id", None)
if message_thread_id is None: if message_thread_id is None:
@@ -1185,7 +1219,7 @@ class TelegramChannel(BaseChannel):
return f"telegram:{message.chat_id}:topic:{message_thread_id}" return f"telegram:{message.chat_id}:topic:{message_thread_id}"
@staticmethod @staticmethod
def _build_message_metadata(message, user) -> dict: def _build_message_metadata(message: Message, user: User) -> dict[str, Any]:
"""Build common Telegram inbound metadata payload.""" """Build common Telegram inbound metadata payload."""
reply_to = getattr(message, "reply_to_message", None) reply_to = getattr(message, "reply_to_message", None)
return { return {
@@ -1199,7 +1233,7 @@ class TelegramChannel(BaseChannel):
"reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None, "reply_to_message_id": getattr(reply_to, "message_id", None) if reply_to else None,
} }
async def _extract_reply_context(self, message) -> str | None: async def _extract_reply_context(self, message: Message) -> str | None:
"""Extract text from the message being replied to, if any.""" """Extract text from the message being replied to, if any."""
reply = getattr(message, "reply_to_message", None) reply = getattr(message, "reply_to_message", None)
if not reply: if not reply:
@@ -1224,7 +1258,7 @@ class TelegramChannel(BaseChannel):
return f"[Reply to: {text}]" return f"[Reply to: {text}]"
async def _download_message_media( async def _download_message_media(
self, msg, *, add_failure_content: bool = False self, msg: Message, *, add_failure_content: bool = False
) -> tuple[list[str], list[str]]: ) -> tuple[list[str], list[str]]:
"""Download media from a message (current or reply). Returns (media_paths, content_parts).""" """Download media from a message (current or reply). Returns (media_paths, content_parts)."""
media_file = None media_file = None
@@ -1255,7 +1289,7 @@ class TelegramChannel(BaseChannel):
try: try:
file = await self._app.bot.get_file(media_file.file_id) file = await self._app.bot.get_file(media_file.file_id)
ext = self._get_extension( ext = self._get_extension(
media_type, cast(str, media_type),
getattr(media_file, "mime_type", None), getattr(media_file, "mime_type", None),
getattr(media_file, "file_name", None), getattr(media_file, "file_name", None),
) )
@@ -1291,7 +1325,7 @@ class TelegramChannel(BaseChannel):
@staticmethod @staticmethod
def _has_mention_entity( def _has_mention_entity(
text: str, text: str,
entities, entities: list[MessageEntity] | None,
bot_username: str, bot_username: str,
bot_id: int | None, bot_id: int | None,
) -> bool: ) -> bool:
@@ -1314,7 +1348,7 @@ class TelegramChannel(BaseChannel):
return True return True
return handle in text.lower() return handle in text.lower()
async def _is_group_message_for_bot(self, message) -> bool: async def _is_group_message_for_bot(self, message: Message) -> bool:
"""Allow group messages when policy is open, @mentioned, or replying to the bot.""" """Allow group messages when policy is open, @mentioned, or replying to the bot."""
if message.chat.type == "private" or self.config.group_policy == "open": if message.chat.type == "private" or self.config.group_policy == "open":
return True return True
@@ -1341,7 +1375,7 @@ class TelegramChannel(BaseChannel):
reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None) reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None)
return bool(bot_id and reply_user and reply_user.id == bot_id) return bool(bot_id and reply_user and reply_user.id == bot_id)
def _remember_thread_context(self, message) -> None: def _remember_thread_context(self, message: Message) -> None:
"""Cache Telegram thread context by chat/message id for follow-up replies.""" """Cache Telegram thread context by chat/message id for follow-up replies."""
message_thread_id = getattr(message, "message_thread_id", None) message_thread_id = getattr(message, "message_thread_id", None)
if message_thread_id is None: if message_thread_id is None:
@@ -1352,7 +1386,7 @@ class TelegramChannel(BaseChannel):
self._message_threads.pop(next(iter(self._message_threads))) self._message_threads.pop(next(iter(self._message_threads)))
@staticmethod @staticmethod
def _queue_key_for_message(message) -> str: def _queue_key_for_message(message: Message) -> str:
"""Return the final nanobot session key used for ordered Telegram ingress.""" """Return the final nanobot session key used for ordered Telegram ingress."""
return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}" return TelegramChannel._derive_topic_session_key(message) or f"telegram:{message.chat_id}"
@@ -1373,6 +1407,8 @@ class TelegramChannel(BaseChannel):
) -> None: ) -> None:
"""Stage a Telegram update behind a short per-session reorder window.""" """Stage a Telegram update behind a short per-session reorder window."""
message = update.message message = update.message
if message is None:
return
key = self._queue_key_for_message(message) key = self._queue_key_for_message(message)
self._inbound_buffers.setdefault(key, []).append( self._inbound_buffers.setdefault(key, []).append(
_QueuedTelegramUpdate( _QueuedTelegramUpdate(
@@ -1432,6 +1468,8 @@ class TelegramChannel(BaseChannel):
"""Process a queued slash command.""" """Process a queued slash command."""
message = update.message message = update.message
user = update.effective_user user = update.effective_user
if message is None or user is None:
return
sender_id = self._sender_id(user) sender_id = self._sender_id(user)
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
await self._send_pairing_code_if_private(sender_id, message, user) await self._send_pairing_code_if_private(sender_id, message, user)
@@ -1469,6 +1507,8 @@ class TelegramChannel(BaseChannel):
message = update.message message = update.message
user = update.effective_user user = update.effective_user
if message is None or user is None:
return
chat_id = message.chat_id chat_id = message.chat_id
sender_id = self._sender_id(user) sender_id = self._sender_id(user)
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
@@ -1483,8 +1523,8 @@ class TelegramChannel(BaseChannel):
return return
# Build content from text and/or media # Build content from text and/or media
content_parts = [] content_parts: list[str] = []
media_paths = [] media_paths: list[str] = []
# Text content # Text content
if message.text: if message.text:
@@ -1625,8 +1665,10 @@ class TelegramChannel(BaseChannel):
self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e) self.logger.debug("Typing indicator stopped for {}: {}", chat_id, e)
@staticmethod @staticmethod
def _format_telegram_error(exc: Exception) -> str: def _format_telegram_error(exc: Exception | None) -> str:
"""Return a short, readable error summary for logs.""" """Return a short, readable error summary for logs."""
if exc is None:
return "None"
text = str(exc).strip() text = str(exc).strip()
if text: if text:
return text return text
@@ -1682,7 +1724,7 @@ class TelegramChannel(BaseChannel):
return "" return ""
def _build_keyboard(self, buttons: list) -> InlineKeyboardMarkup | None: def _build_keyboard(self, buttons: list[list[str]]) -> InlineKeyboardMarkup | None:
"""Build inline keyboard markup if inline_keyboards is enabled.""" """Build inline keyboard markup if inline_keyboards is enabled."""
if not buttons or not self.config.inline_keyboards: if not buttons or not self.config.inline_keyboards:
return None return None
@@ -1711,7 +1753,8 @@ class TelegramChannel(BaseChannel):
return return
query = update.callback_query query = update.callback_query
user = update.effective_user user = update.effective_user
chat_id = query.message.chat_id if query.message else None query_message = query.message
chat_id = query_message.chat.id if query_message else None
sender_id = self._sender_id(user) sender_id = self._sender_id(user)
if not chat_id: if not chat_id:
self.logger.warning("Callback query without chat_id") self.logger.warning("Callback query without chat_id")
@@ -1720,9 +1763,9 @@ class TelegramChannel(BaseChannel):
return return
button_label = query.data or "" button_label = query.data or ""
await query.answer() await query.answer()
if query.message: if isinstance(query_message, Message):
with suppress(Exception): with suppress(Exception):
await query.message.edit_reply_markup(reply_markup=None) await query_message.edit_reply_markup(reply_markup=None)
self.logger.debug("Inline button tap from {}: {}", sender_id, button_label) self.logger.debug("Inline button tap from {}: {}", sender_id, button_label)
self._start_typing(str(chat_id)) self._start_typing(str(chat_id))
await self._handle_message( await self._handle_message(
@@ -1,4 +1,5 @@
import asyncio import asyncio
from datetime import timedelta
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
@@ -1911,6 +1912,36 @@ async def test_on_message_location_with_text() -> None:
# Tests for retry amplification fix (issue #3050) # Tests for retry amplification fix (issue #3050)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_call_with_retry_accepts_timedelta_retry_after(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from telegram.error import RetryAfter
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]),
MessageBus(),
)
attempts = 0
async def retry_once() -> str:
nonlocal attempts
attempts += 1
if attempts == 1:
raise RetryAfter(timedelta(seconds=1.5))
return "ok"
sleep = AsyncMock()
monkeypatch.setenv("PTB_TIMEDELTA", "1")
monkeypatch.setattr(
"nanobot.channels.telegram.runtime.asyncio.sleep",
sleep,
)
assert await channel._call_with_retry(retry_once) == "ok"
sleep.assert_awaited_once_with(1.5)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_text_does_not_fallback_on_network_timeout() -> None: async def test_send_text_does_not_fallback_on_network_timeout() -> None:
"""TimedOut should propagate immediately, NOT trigger plain-text fallback. """TimedOut should propagate immediately, NOT trigger plain-text fallback.
@@ -2318,7 +2349,7 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
data="Yes", data="Yes",
answer=AsyncMock(), answer=AsyncMock(),
message=SimpleNamespace( message=SimpleNamespace(
chat_id=123, chat=SimpleNamespace(id=123),
edit_reply_markup=AsyncMock(), edit_reply_markup=AsyncMock(),
), ),
) )
@@ -2332,3 +2363,35 @@ async def test_callback_query_ignores_unauthorized_user_before_side_effects() ->
query.answer.assert_not_awaited() query.answer.assert_not_awaited()
query.message.edit_reply_markup.assert_not_awaited() query.message.edit_reply_markup.assert_not_awaited()
channel._handle_message.assert_not_awaited() channel._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_callback_query_handles_inaccessible_message() -> None:
from telegram import Chat, InaccessibleMessage
channel = TelegramChannel(
TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], inline_keyboards=True),
MessageBus(),
)
channel._handle_message = AsyncMock()
channel._start_typing = lambda _chat_id: None
query = SimpleNamespace(
id="cb_inaccessible",
data="Yes",
answer=AsyncMock(),
message=InaccessibleMessage(
chat=Chat(id=123, type="private"),
message_id=456,
),
)
update = SimpleNamespace(
callback_query=query,
effective_user=SimpleNamespace(id=12345, username="alice", first_name="Alice"),
)
await channel._on_callback_query(update, None)
query.answer.assert_awaited_once()
channel._handle_message.assert_awaited_once()
assert channel._handle_message.await_args.kwargs["chat_id"] == "123"
+2 -2
View File
@@ -1,7 +1,7 @@
"""Telegram setup validation owned by the channel package.""" """Telegram setup validation owned by the channel package."""
import re import re
from typing import Any from typing import Any, cast
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
@@ -39,7 +39,7 @@ def _get_me(token: str, proxy: str | None) -> dict[str, Any]:
response = client.get(f"https://api.telegram.org/bot{token}/getMe") response = client.get(f"https://api.telegram.org/bot{token}/getMe")
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data if isinstance(data, dict) else {} return cast(dict[str, Any], data) if isinstance(data, dict) else {}
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]: def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
+9 -9
View File
@@ -11,7 +11,7 @@ import re
import socket import socket
import ssl import ssl
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any, cast
import httpx import httpx
@@ -76,7 +76,7 @@ def validate_channel_config(
allow_local_service_access=config.tools.webui_allow_local_service_access, allow_local_service_access=config.tools.webui_allow_local_service_access,
) )
custom_payload = setup_spec.validator(values, context) custom_payload = setup_spec.validator(values, context)
if custom_payload is not None: if cast(object, custom_payload) is not None:
payload = dict(custom_payload) payload = dict(custom_payload)
payload.setdefault("checks", []) payload.setdefault("checks", [])
payload.setdefault("missing_fields", []) payload.setdefault("missing_fields", [])
@@ -116,7 +116,7 @@ def _channel_config(
if hasattr(section, "model_dump"): if hasattr(section, "model_dump"):
return dict(section.model_dump(mode="json", by_alias=True)) return dict(section.model_dump(mode="json", by_alias=True))
if isinstance(section, dict): if isinstance(section, dict):
return dict(section) return dict(cast(dict[str, Any], section))
return {} return {}
@@ -130,9 +130,9 @@ def _merge_form_values(
merged = dict(values) merged = dict(values)
prefix = f"channels.{name}." prefix = f"channels.{name}."
spec = setup_spec spec = setup_spec
secrets = spec.secrets if spec is not None else frozenset() secrets: frozenset[str] = spec.secrets if spec is not None else frozenset()
for raw_key, raw_value in raw_values.items(): for raw_key, raw_value in raw_values.items():
if not isinstance(raw_key, str) or not raw_key: if not raw_key:
continue continue
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
if field in secrets and not _str(raw_value): if field in secrets and not _str(raw_value):
@@ -281,7 +281,7 @@ def _assign(values: dict[str, Any], field: str, value: Any) -> None:
if not isinstance(current, dict): if not isinstance(current, dict):
current = {} current = {}
target[part] = current target[part] = current
target = current target = cast(dict[str, Any], current)
target[parts[-1]] = value target[parts[-1]] = value
@@ -290,7 +290,7 @@ def _get(values: dict[str, Any], field: str) -> Any:
for part in field.split("."): for part in field.split("."):
if not isinstance(target, dict): if not isinstance(target, dict):
return None return None
target = target.get(part) target = cast(dict[str, Any], target).get(part)
return target return target
@@ -346,7 +346,7 @@ def _http_get(url: str, *, headers: dict[str, str] | None = None) -> dict[str, A
response = client.get(url, headers=headers) response = client.get(url, headers=headers)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data if isinstance(data, dict) else {} return cast(dict[str, Any], data) if isinstance(data, dict) else {}
def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]: def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str, Any]:
@@ -354,7 +354,7 @@ def _http_post(url: str, *, headers: dict[str, str] | None = None) -> dict[str,
response = client.post(url, headers=headers) response = client.post(url, headers=headers)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
return data if isinstance(data, dict) else {} return cast(dict[str, Any], data) if isinstance(data, dict) else {}
def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None: def _probe_tcp(host: str, port: int, *, allow_loopback: bool = False) -> None:
+83 -27
View File
@@ -11,7 +11,7 @@ import uuid
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
from typing import Any, Self from typing import Any, Self, TypeGuard, cast
from pydantic import Field, field_validator, model_validator from pydantic import Field, field_validator, model_validator
from websockets.asyncio.server import ServerConnection, serve, unix_serve from websockets.asyncio.server import ServerConnection, serve, unix_serve
@@ -67,6 +67,7 @@ from nanobot.webui.http_utils import (
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
from nanobot.webui.metadata import ( from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY, WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
WEBUI_TURN_METADATA_KEY, WEBUI_TURN_METADATA_KEY,
) )
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
@@ -191,12 +192,13 @@ def _parse_inbound_payload(raw: str) -> str | None:
return None return None
if text.startswith("{"): if text.startswith("{"):
try: try:
data = json.loads(text) data = cast(object, json.loads(text))
except json.JSONDecodeError: except json.JSONDecodeError:
return text return text
if isinstance(data, dict): if isinstance(data, dict):
payload = cast(dict[str, Any], data)
for key in ("content", "text", "message"): for key in ("content", "text", "message"):
value = data.get(key) value = payload.get(key)
if isinstance(value, str) and value.strip(): if isinstance(value, str) and value.strip():
return value return value
return None return None
@@ -209,7 +211,7 @@ def _parse_inbound_payload(raw: str) -> str | None:
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$") _CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
def _is_valid_chat_id(value: Any) -> bool: def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
@@ -224,15 +226,16 @@ def _parse_envelope(raw: str) -> dict[str, Any] | None:
if not text.startswith("{"): if not text.startswith("{"):
return None return None
try: try:
data = json.loads(text) data = cast(object, json.loads(text))
except json.JSONDecodeError: except json.JSONDecodeError:
return None return None
if not isinstance(data, dict): if not isinstance(data, dict):
return None return None
t = data.get("type") envelope = cast(dict[str, Any], data)
t = envelope.get("type")
if not isinstance(t, str): if not isinstance(t, str):
return None return None
return data return envelope
def _is_websocket_upgrade(request: WsRequest) -> bool: def _is_websocket_upgrade(request: WsRequest) -> bool:
@@ -264,13 +267,13 @@ class WebSocketChannel(BaseChannel):
super().__init__(config, bus) super().__init__(config, bus)
self.config: WebSocketConfig = config self.config: WebSocketConfig = config
# chat_id -> connections subscribed to it (fan-out target). # chat_id -> connections subscribed to it (fan-out target).
self._subs: dict[str, set[Any]] = {} self._subs: dict[str, set[ServerConnection]] = {}
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect). # connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
self._conn_chats: dict[Any, set[str]] = {} self._conn_chats: dict[ServerConnection, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing. # connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {} self._conn_default: dict[ServerConnection, str] = {}
# Connections authenticated with a one-time token from /webui/bootstrap. # Connections authenticated with a one-time token from /webui/bootstrap.
self._webui_connections: set[Any] = set() self._webui_connections: set[ServerConnection] = set()
self._stop_event: asyncio.Event | None = None self._stop_event: asyncio.Event | None = None
self._server_task: asyncio.Task[None] | None = None self._server_task: asyncio.Task[None] | None = None
@@ -286,15 +289,43 @@ class WebSocketChannel(BaseChannel):
# -- Subscription bookkeeping ------------------------------------------- # -- Subscription bookkeeping -------------------------------------------
def _workspace_controls_available(self, connection: Any) -> bool: def _workspace_controls_available(self, connection: ServerConnection) -> bool:
return self._http_router.workspace_controls_available(connection) return self._http_router.workspace_controls_available(connection)
def _attach(self, connection: Any, chat_id: str) -> None: def _attach(self, connection: ServerConnection, chat_id: str) -> None:
"""Idempotently subscribe *connection* to *chat_id*.""" """Idempotently subscribe *connection* to *chat_id*."""
self._subs.setdefault(chat_id, set()).add(connection) self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id) self._conn_chats.setdefault(connection, set()).add(chat_id)
def _cleanup_connection(self, connection: Any) -> None: async def send_webui_protocol_error(
self,
connection: ServerConnection,
detail: str,
) -> None:
"""Send a stable protocol error from a WebUI-owned orchestration helper."""
await self._send_event(connection, "error", detail=detail)
async def attach_webui_fork(
self,
connection: ServerConnection,
*,
fork_id: str,
fork_key: str,
) -> None:
"""Attach and hydrate a newly created WebUI chat fork."""
scope = self._workspaces.scope_for_session_key(fork_key)
self._attach(connection, fork_id)
await self._send_event(connection, "attached", chat_id=fork_id)
await self._send_event(
connection,
"session_updated",
chat_id=fork_id,
scope="metadata",
workspace_scope=scope.payload(),
)
await self._hydrate_after_subscribe(fork_id)
def _cleanup_connection(self, connection: ServerConnection) -> None:
"""Remove *connection* from every subscription set; safe to call multiple times.""" """Remove *connection* from every subscription set; safe to call multiple times."""
chat_ids = self._conn_chats.pop(connection, set()) chat_ids = self._conn_chats.pop(connection, set())
for cid in chat_ids: for cid in chat_ids:
@@ -317,10 +348,11 @@ class WebSocketChannel(BaseChannel):
if self.gateway.session_manager is None: if self.gateway.session_manager is None:
return return
row = self.gateway.session_manager.read_session_file(f"websocket:{chat_id}") row = self.gateway.session_manager.read_session_file(f"websocket:{chat_id}")
meta = row.get("metadata", {}) if isinstance(row, dict) else {} row_data = row if isinstance(row, dict) else {}
meta = row_data.get("metadata", {})
if not isinstance(meta, dict): if not isinstance(meta, dict):
meta = {} meta = {}
blob = goal_state_ws_blob(meta) blob = goal_state_ws_blob(cast(dict[str, Any], meta))
if not blob.get("active"): if not blob.get("active"):
return return
await self.send_goal_state(chat_id, blob) await self.send_goal_state(chat_id, blob)
@@ -342,7 +374,12 @@ class WebSocketChannel(BaseChannel):
await self._maybe_push_active_goal_state(chat_id) await self._maybe_push_active_goal_state(chat_id)
await self._maybe_push_turn_run_wall_clock(chat_id) await self._maybe_push_turn_run_wall_clock(chat_id)
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None: async def _send_event(
self,
connection: ServerConnection,
event: str,
**fields: Any,
) -> None:
"""Send a control event (attached, error, ...) to a single connection.""" """Send a control event (attached, error, ...) to a single connection."""
payload: dict[str, Any] = {"event": event} payload: dict[str, Any] = {"event": event}
payload.update(fields) payload.update(fields)
@@ -377,7 +414,7 @@ class WebSocketChannel(BaseChannel):
# -- HTTP dispatch ------------------------------------------------------ # -- HTTP dispatch ------------------------------------------------------
async def _dispatch_http(self, connection: Any, request: WsRequest) -> Any: async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
"""Route an inbound HTTP request to the HTTP handler or WS upgrade.""" """Route an inbound HTTP request to the HTTP handler or WS upgrade."""
got, query = _parse_request_path(request.path) got, query = _parse_request_path(request.path)
@@ -394,7 +431,11 @@ class WebSocketChannel(BaseChannel):
# Everything else goes to the HTTP handler # Everything else goes to the HTTP handler
return await self._http_router.dispatch(connection, request) return await self._http_router.dispatch(connection, request)
def _authorize_websocket_handshake(self, connection: Any, query: dict[str, list[str]]) -> Any: def _authorize_websocket_handshake(
self,
connection: ServerConnection,
query: dict[str, list[str]],
) -> Any:
supplied = _query_first(query, "token") supplied = _query_first(query, "token")
static_token = self.config.token.strip() static_token = self.config.token.strip()
@@ -414,7 +455,7 @@ class WebSocketChannel(BaseChannel):
self._consume_issued_token(connection, supplied) self._consume_issued_token(connection, supplied)
return None return None
def _consume_issued_token(self, connection: Any, token: str) -> bool: def _consume_issued_token(self, connection: ServerConnection, token: str) -> bool:
audience = self._tokens.take_issued_token_audience(token) audience = self._tokens.take_issued_token_audience(token)
if audience == "webui": if audience == "webui":
self._webui_connections.add(connection) self._webui_connections.add(connection)
@@ -509,7 +550,7 @@ class WebSocketChannel(BaseChannel):
self._server_task = asyncio.create_task(runner()) self._server_task = asyncio.create_task(runner())
await self._server_task await self._server_task
async def _connection_loop(self, connection: Any) -> None: async def _connection_loop(self, connection: ServerConnection) -> None:
request = connection.request request = connection.request
path_part = request.path if request else "/" path_part = request.path if request else "/"
_, query = _parse_request_path(path_part) _, query = _parse_request_path(path_part)
@@ -574,7 +615,7 @@ class WebSocketChannel(BaseChannel):
async def _dispatch_envelope( async def _dispatch_envelope(
self, self,
connection: Any, connection: ServerConnection,
client_id: str, client_id: str,
envelope: dict[str, Any], envelope: dict[str, Any],
) -> None: ) -> None:
@@ -700,7 +741,7 @@ class WebSocketChannel(BaseChannel):
**rejection_fields, **rejection_fields,
) )
return return
media_paths, reason = self._media.store_inbound_attachments(raw_media) media_paths, reason = self._media.store_inbound_attachments(cast(list[Any], raw_media))
if reason is not None: if reason is not None:
await self._send_event( await self._send_event(
connection, connection,
@@ -810,7 +851,7 @@ class WebSocketChannel(BaseChannel):
async def _workspace_scope_or_error( async def _workspace_scope_or_error(
self, self,
connection: Any, connection: ServerConnection,
resolver: Callable[[], Any], resolver: Callable[[], Any],
*, *,
chat_id: str | None = None, chat_id: str | None = None,
@@ -841,7 +882,8 @@ class WebSocketChannel(BaseChannel):
try: try:
await self._server_task await self._server_task
except asyncio.CancelledError: except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling(): current_task = asyncio.current_task()
if current_task is not None and current_task.cancelling():
raise raise
self.logger.debug("server task was already cancelled during shutdown") self.logger.debug("server task was already cancelled during shutdown")
except Exception as e: except Exception as e:
@@ -853,7 +895,13 @@ class WebSocketChannel(BaseChannel):
self._webui_connections.clear() self._webui_connections.clear()
self._tokens.clear() self._tokens.clear()
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None: async def _safe_send_to(
self,
connection: ServerConnection,
raw: str,
*,
label: str = "",
) -> None:
"""Send a raw frame to one connection, cleaning up on ConnectionClosed.""" """Send a raw frame to one connection, cleaning up on ConnectionClosed."""
try: try:
await connection.send(raw) await connection.send(raw)
@@ -956,6 +1004,13 @@ class WebSocketChannel(BaseChannel):
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 isinstance(event, TurnEndEvent):
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
session_update_scope = (
"metadata"
if isinstance(turn_id, str)
and turn_id.startswith(WEBUI_SYSTEM_COMMAND_TURN_PREFIX)
else "thread"
)
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY) turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
await self.send_turn_end( await self.send_turn_end(
msg.chat_id, msg.chat_id,
@@ -964,7 +1019,7 @@ class WebSocketChannel(BaseChannel):
metadata=msg.metadata, metadata=msg.metadata,
turn_owner=turn_owner if isinstance(turn_owner, str) else None, turn_owner=turn_owner if isinstance(turn_owner, str) else None,
) )
await self.send_session_updated(msg.chat_id, scope="thread") await self.send_session_updated(msg.chat_id, scope=session_update_scope)
return return
if isinstance(event, SessionUpdatedEvent): if isinstance(event, SessionUpdatedEvent):
if conns: if conns:
@@ -1161,6 +1216,7 @@ class WebSocketChannel(BaseChannel):
body, body,
metadata=meta, metadata=meta,
phase="answer", phase="answer",
include_source=True,
) )
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
if not conns: if not conns:
@@ -49,7 +49,12 @@ from nanobot.webui.http_utils import (
from nanobot.webui.http_utils import ( from nanobot.webui.http_utils import (
parse_request_path as _parse_request_path, parse_request_path as _parse_request_path,
) )
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY from nanobot.webui.metadata import (
WEBSOCKET_TURN_OWNER_METADATA_KEY,
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
WEBUI_TURN_METADATA_KEY,
)
from nanobot.webui.settings_api import settings_payload, update_provider_settings from nanobot.webui.settings_api import settings_payload, update_provider_settings
from nanobot.webui.transcript import ( from nanobot.webui.transcript import (
append_transcript_object, append_transcript_object,
@@ -1346,6 +1351,35 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert "text" not in second assert "text" not in second
@pytest.mark.asyncio
async def test_send_delta_preserves_webui_source_metadata() -> None:
bus = MagicMock()
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-source-stream")
source = {"kind": "cron", "label": "Repo check"}
metadata = {WEBUI_MESSAGE_SOURCE_METADATA_KEY: source}
await channel.send_delta("chat-source-stream", "done", metadata=metadata, stream_id="sid")
await channel.send_delta(
"chat-source-stream",
"",
metadata=metadata,
stream_id="sid",
stream_end=True,
)
first = json.loads(mock_ws.send.call_args_list[0][0][0])
second = json.loads(mock_ws.send.call_args_list[1][0][0])
assert first["event"] == "delta"
assert first["source"] == source
assert second["event"] == "stream_end"
assert second["source"] == source
lines = read_transcript_lines("websocket:chat-source-stream")
assert lines[-2]["source"] == source
assert lines[-1]["source"] == source
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_delta_marks_resuming_stream_end() -> None: async def test_send_delta_marks_resuming_stream_end() -> None:
bus = MagicMock() bus = MagicMock()
@@ -1618,6 +1652,43 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
] ]
@pytest.mark.asyncio
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
bus = MagicMock()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus),
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-model")
await channel.send(OutboundMessage(
channel="websocket",
chat_id="chat-model",
content="",
metadata={
WEBUI_TURN_METADATA_KEY: f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
},
event=TurnEndEvent(),
))
assert _sent_ws_payloads(mock_ws) == [
{
"event": "turn_end",
"chat_id": "chat-model",
"turn_id": f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
"turn_phase": "complete",
"turn_seq": 1,
},
{
"event": "session_updated",
"chat_id": "chat-model",
"scope": "metadata",
},
]
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize( @pytest.mark.parametrize(
("active_owner", "event_owner", "expected_cleared"), ("active_owner", "event_owner", "expected_cleared"),
@@ -519,6 +519,8 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
"name": "workspace-skill", "name": "workspace-skill",
"description": "Workspace skill.", "description": "Workspace skill.",
"source": "workspace", "source": "workspace",
"enabled": True,
"deletable": True,
"available": True, "available": True,
"unavailable_reason": "", "unavailable_reason": "",
} }
@@ -548,6 +550,366 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
await server_task await server_task
@pytest.mark.asyncio
async def test_webui_skill_management_routes(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
skill_dir = tmp_path / "skills" / "custom-skill"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: custom-skill\ndescription: Custom skill.\n---\n",
encoding="utf-8",
)
def set_enabled(
workspace: Path,
name: str,
*,
enabled: bool,
disabled_skills: set[str],
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
assert enabled is False
disabled_skills.add(name)
return {"name": name, "enabled": enabled, "deleted": False}
def delete(
workspace: Path,
name: str,
*,
disabled_skills: set[str],
) -> dict[str, Any]:
assert workspace == tmp_path
assert name == "custom-skill"
disabled_skills.discard(name)
for child in skill_dir.iterdir():
child.unlink()
skill_dir.rmdir()
return {"name": name, "enabled": False, "deleted": True}
monkeypatch.setattr("nanobot.webui.ws_http.set_webui_skill_enabled", set_enabled)
monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete)
port = _free_port()
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=port,
)
server_task = asyncio.create_task(channel.start())
try:
token = channel.gateway.tokens.issue_api_token(300)
headers = {"Authorization": f"Bearer {token}"}
update_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/update"
"?name=custom-skill&enabled=false",
headers=headers,
)
assert update_response.status_code == 200
assert update_response.json()["last_action"]["enabled"] is False
custom = next(
item
for item in update_response.json()["skills"]
if item["name"] == "custom-skill"
)
assert custom["enabled"] is False
delete_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/delete"
"?name=custom-skill",
headers=headers,
)
assert delete_response.status_code == 200
assert delete_response.json()["last_action"]["deleted"] is True
assert all(
item["name"] != "custom-skill"
for item in delete_response.json()["skills"]
)
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skills_marketplace_routes_search_and_install(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
search = AsyncMock(return_value={
"query": "react",
"install_supported": True,
"skills": [{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"installs": 42,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": False,
}],
})
trending = AsyncMock(return_value={
"period": "24h",
"install_supported": True,
"skills": [{
"id": "acme/agent-skills/react-testing",
"skill_id": "react-testing",
"name": "React Testing",
"source": "acme/agent-skills",
"installs": 12,
"url": "https://skills.sh/acme/agent-skills/react-testing",
"installed": False,
"rank": 1,
}],
})
trends = AsyncMock(return_value={
"trends": {"acme/agent-skills/react-testing": [2, 4, 3, 8]},
})
async def install(
source: str,
skill_id: str,
workspace: Path,
*,
provider: str,
version: str,
) -> dict[str, Any]:
assert source == "acme/agent-skills"
assert skill_id == "react-testing"
assert workspace == tmp_path
assert provider == "skills_sh"
assert version == ""
skill_dir = workspace / "skills" / skill_id
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
encoding="utf-8",
)
return {"installed": True, "already_installed": False, "name": skill_id}
install_mock = AsyncMock(side_effect=install)
monkeypatch.setattr("nanobot.webui.ws_http.search_marketplace_skills", search)
monkeypatch.setattr("nanobot.webui.ws_http.trending_marketplace_skills", trending)
monkeypatch.setattr("nanobot.webui.ws_http.marketplace_skill_trends", trends)
monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock)
port = _free_port()
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=port,
)
server_task = asyncio.create_task(channel.start())
try:
denied = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/search?q=react"
)
assert denied.status_code == 401
token = channel.gateway.tokens.issue_api_token(300)
headers = {"Authorization": f"Bearer {token}"}
search_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/search?q=react",
headers=headers,
)
assert search_response.status_code == 200
assert search_response.json()["skills"][0]["skill_id"] == "react-testing"
search.assert_awaited_once_with("react", tmp_path, provider="all")
trending_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/trending",
headers=headers,
)
assert trending_response.status_code == 200
assert trending_response.json()["period"] == "24h"
trending.assert_awaited_once_with(tmp_path, provider="all")
trends_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/trends"
"?id=acme%2Fagent-skills%2Freact-testing",
headers=headers,
)
assert trends_response.status_code == 200
assert trends_response.json()["trends"] == {
"acme/agent-skills/react-testing": [2, 4, 3, 8],
}
trends.assert_awaited_once_with(["acme/agent-skills/react-testing"])
params = urlencode({
"source": "acme/agent-skills",
"skill": "react-testing",
})
install_response = await _http_get(
f"http://127.0.0.1:{port}/api/webui/skills/install?{params}",
headers=headers,
)
assert install_response.status_code == 200
body = install_response.json()
assert body["last_action"] == {
"installed": True,
"already_installed": False,
"name": "react-testing",
}
assert next(
skill for skill in body["skills"] if skill["name"] == "react-testing"
)["source"] == "workspace"
install_mock.assert_awaited_once()
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_webui_skill_install_rejects_overlapping_requests(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
started = asyncio.Event()
finish = asyncio.Event()
async def install(
source: str,
skill_id: str,
workspace: Path,
*,
provider: str,
version: str,
) -> dict[str, Any]:
started.set()
await finish.wait()
skill_dir = workspace / "skills" / skill_id
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
encoding="utf-8",
)
return {"installed": True, "already_installed": False, "name": skill_id}
install_mock = AsyncMock(side_effect=install)
monkeypatch.setattr("nanobot.webui.ws_http.install_marketplace_skill", install_mock)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
path = (
"/api/webui/skills/install"
"?source=acme%2Fagent-skills&skill=react-testing"
)
request = _FakeReq(
{
"Authorization": f"Bearer {token}",
"Host": "127.0.0.1:8765",
},
path=path,
)
first = asyncio.create_task(channel.gateway.http.dispatch(_LOCAL, request))
await started.wait()
overlapping = await channel.gateway.http.dispatch(_LOCAL, request)
assert overlapping.status_code == 409
assert "already in progress" in overlapping.body.decode()
assert install_mock.await_count == 1
finish.set()
completed = await first
assert completed.status_code == 200
assert install_mock.await_count == 1
@pytest.mark.asyncio
async def test_webui_skill_delete_remains_local_only(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
delete = MagicMock()
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
monkeypatch.setattr("nanobot.webui.ws_http.delete_webui_skill", delete)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.dispatch(
_REMOTE,
_FakeReq(
{"Authorization": f"Bearer {token}"},
path="/api/webui/skills/delete?name=custom-skill",
),
)
assert response.status_code == 403
assert "remote skill deletion is disabled" in response.body.decode()
delete.assert_not_called()
@pytest.mark.asyncio
async def test_webui_skill_install_honors_remote_install_opt_in(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
policy = MagicMock()
policy.tools.webui_allow_remote_package_install = True
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: policy)
async def install(
source: str,
skill_id: str,
workspace: Path,
*,
provider: str,
version: str,
) -> dict[str, Any]:
skill_dir = workspace / "skills" / skill_id
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: react-testing\ndescription: Test React apps.\n---\n",
encoding="utf-8",
)
return {"installed": True, "already_installed": False, "name": skill_id}
monkeypatch.setattr(
"nanobot.webui.ws_http.install_marketplace_skill",
AsyncMock(side_effect=install),
)
channel = _ch(
bus,
session_manager=_seed_session(tmp_path),
workspace_path=tmp_path,
port=_free_port(),
)
token = channel.gateway.tokens.issue_api_token(300)
response = await channel.gateway.http.dispatch(
_REMOTE,
_FakeReq(
{"Authorization": f"Bearer {token}"},
path=(
"/api/webui/skills/install"
"?source=acme%2Fagent-skills&skill=react-testing"
),
),
)
assert response.status_code == 200
assert json.loads(response.body.decode())["last_action"]["name"] == "react-testing"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload( async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock, bus: MagicMock,
@@ -2575,6 +2937,17 @@ async def test_webui_thread_resigns_assistant_media_urls(
assert media[0]["url"].startswith("/api/media/") assert media[0]["url"].startswith("/api/media/")
assert media[0]["url"] != "/api/media/old-sig/old-payload" assert media[0]["url"] != "/api/media/old-sig/old-payload"
repeated = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread",
headers=auth,
)
repeated_assistant = next(
m for m in repeated.json()["messages"] if m["role"] == "assistant"
)
assert repeated_assistant["id"] == assistant["id"]
assert repeated_assistant["media"][0]["url"] == media[0]["url"]
assert len(list(websocket_media.iterdir())) == 1
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}") fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
assert fetched.status_code == 200 assert fetched.status_code == 200
assert fetched.content == b"video" assert fetched.content == b"video"
@@ -146,16 +146,41 @@ def test_local_markdown_image_is_staged_and_rewritten(
channel = _ch(bus, workspace_path=workspace, port=0) channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)): with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel.gateway.media.rewrite_local_markdown_images( first = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)"
)
second = channel.gateway.media.rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)" "The result:\n![Cloud Architecture Diagram](demo_arch.png)"
) )
assert "![Cloud Architecture Diagram](/api/media/" in rewritten assert "![Cloud Architecture Diagram](/api/media/" in first
assert second == first
staged = list((media / "websocket").iterdir()) staged = list((media / "websocket").iterdir())
assert len(staged) == 1 assert len(staged) == 1
assert staged[0].read_bytes() == _PNG_BYTES assert staged[0].read_bytes() == _PNG_BYTES
def test_modified_local_markdown_image_gets_a_new_immutable_url(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
source = workspace / "demo_arch.png"
source.write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
markdown = "![Cloud Architecture Diagram](demo_arch.png)"
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
first = channel.gateway.media.rewrite_local_markdown_images(markdown)
source.write_bytes(_PNG_BYTES + b"updated")
second = channel.gateway.media.rewrite_local_markdown_images(markdown)
assert second != first
assert len(list((media / "websocket").iterdir())) == 2
def test_local_markdown_video_is_staged_and_rewritten( def test_local_markdown_video_is_staged_and_rewritten(
bus: MagicMock, bus: MagicMock,
tmp_path: Path, tmp_path: Path,
+58 -37
View File
@@ -1,3 +1,4 @@
# pyright: reportMissingTypeStubs=false
"""WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk.""" """WeCom (Enterprise WeChat) channel implementation using wecom_aibot_sdk."""
import asyncio import asyncio
@@ -7,8 +8,9 @@ import importlib.util
import os import os
import re import re
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from pydantic import Field from pydantic import Field
@@ -96,7 +98,7 @@ class WecomChannel(BaseChannel):
self._client: Any = None self._client: Any = None
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._generate_req_id = None self._generate_req_id: Callable[[str], str] | None = None
# Store frame headers for each chat to enable replies # Store frame headers for each chat to enable replies
self._chat_frames: dict[str, Any] = {} self._chat_frames: dict[str, Any] = {}
@@ -117,7 +119,8 @@ class WecomChannel(BaseChannel):
self._generate_req_id = generate_req_id self._generate_req_id = generate_req_id
# Create WebSocket client # Create WebSocket client
self._client = WSClient({ ws_client = cast(Any, WSClient)
self._client = ws_client({
"bot_id": self.config.bot_id, "bot_id": self.config.bot_id,
"secret": self.config.secret, "secret": self.config.secret,
"reconnect_interval": 1000, "reconnect_interval": 1000,
@@ -195,14 +198,16 @@ class WecomChannel(BaseChannel):
"""Handle enter_chat event (user opens chat with bot).""" """Handle enter_chat event (user opens chat with bot)."""
try: try:
# Extract body from WsFrame dataclass or dict # Extract body from WsFrame dataclass or dict
if hasattr(frame, 'body'): if hasattr(frame, "body"):
body = frame.body or {} body: Any = frame.body or {}
elif isinstance(frame, dict): elif isinstance(frame, dict):
body = frame.get("body", frame) frame_dict = cast(dict[str, Any], frame)
body = frame_dict.get("body", frame_dict)
else: else:
body = {} body = {}
chat_id = body.get("chatid", "") if isinstance(body, dict) else "" body_dict = cast(dict[str, Any], body) if isinstance(body, dict) else {}
chat_id = cast(str, body_dict.get("chatid", ""))
if chat_id and not self.is_allowed(chat_id): if chat_id and not self.is_allowed(chat_id):
return return
@@ -219,26 +224,32 @@ class WecomChannel(BaseChannel):
"""Process incoming message and forward to bus.""" """Process incoming message and forward to bus."""
try: try:
# Extract body from WsFrame dataclass or dict # Extract body from WsFrame dataclass or dict
if hasattr(frame, 'body'): if hasattr(frame, "body"):
body = frame.body or {} body: Any = frame.body or {}
elif isinstance(frame, dict): elif isinstance(frame, dict):
body = frame.get("body", frame) frame_dict = cast(dict[str, Any], frame)
body = frame_dict.get("body", frame_dict)
else: else:
body = {} body = {}
# Ensure body is a dict # Ensure body is a dict
if not isinstance(body, dict): if not isinstance(body, dict):
self.logger.warning("Invalid body type: {}", type(body)) self.logger.warning("Invalid body type: {}", type(cast(object, body)))
return return
body = cast(dict[str, Any], body)
# Extract message info # Extract message info
msg_id = body.get("msgid", "") msg_id = cast(str, body.get("msgid", ""))
if not msg_id: if not msg_id:
msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}" msg_id = f"{body.get('chatid', '')}_{body.get('sendertime', '')}"
# Extract sender info from "from" field (SDK format) # Extract sender info from "from" field (SDK format)
from_info = body.get("from", {}) from_info = body.get("from", {})
sender_id = from_info.get("userid", "unknown") if isinstance(from_info, dict) else "unknown" sender_id = (
cast(str, cast(dict[str, Any], from_info).get("userid", "unknown"))
if isinstance(from_info, dict)
else "unknown"
)
if not self.is_allowed(sender_id): if not self.is_allowed(sender_id):
return return
@@ -253,21 +264,22 @@ class WecomChannel(BaseChannel):
# For single chat, chatid is the sender's userid # For single chat, chatid is the sender's userid
# For group chat, chatid is provided in body # For group chat, chatid is provided in body
chat_type = body.get("chattype", "single") chat_type = cast(str, body.get("chattype", "single"))
chat_id = body.get("chatid", sender_id) chat_id = cast(str, body.get("chatid", sender_id))
content_parts = [] content_parts: list[str] = []
media_paths: list[str] = [] media_paths: list[str] = []
if msg_type == "text": if msg_type == "text":
text = body.get("text", {}).get("content", "") text_info = cast(dict[str, Any], body.get("text", {}))
text = cast(str, text_info.get("content", ""))
if text: if text:
content_parts.append(text) content_parts.append(text)
elif msg_type == "image": elif msg_type == "image":
image_info = body.get("image", {}) image_info = cast(dict[str, Any], body.get("image", {}))
file_url = image_info.get("url", "") file_url = cast(str, image_info.get("url", ""))
aes_key = image_info.get("aeskey", "") aes_key = cast(str, image_info.get("aeskey", ""))
if file_url and aes_key: if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "image") file_path = await self._download_and_save_media(file_url, aes_key, "image")
@@ -281,19 +293,19 @@ class WecomChannel(BaseChannel):
content_parts.append("[image: download failed]") content_parts.append("[image: download failed]")
elif msg_type == "voice": elif msg_type == "voice":
voice_info = body.get("voice", {}) voice_info = cast(dict[str, Any], body.get("voice", {}))
# Voice message already contains transcribed content from WeCom # Voice message already contains transcribed content from WeCom
voice_content = voice_info.get("content", "") voice_content = cast(str, voice_info.get("content", ""))
if voice_content: if voice_content:
content_parts.append(f"[voice] {voice_content}") content_parts.append(f"[voice] {voice_content}")
else: else:
content_parts.append("[voice]") content_parts.append("[voice]")
elif msg_type == "file": elif msg_type == "file":
file_info = body.get("file", {}) file_info = cast(dict[str, Any], body.get("file", {}))
file_url = file_info.get("url", "") file_url = cast(str, file_info.get("url", ""))
aes_key = file_info.get("aeskey", "") aes_key = cast(str, file_info.get("aeskey", ""))
file_name = file_info.get("name") or None file_name = cast(str | None, file_info.get("name") or None)
if file_url and aes_key: if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name) file_path = await self._download_and_save_media(file_url, aes_key, "file", file_name)
@@ -308,16 +320,20 @@ class WecomChannel(BaseChannel):
elif msg_type == "mixed": elif msg_type == "mixed":
# Mixed content contains multiple message items # Mixed content contains multiple message items
msg_items = body.get("mixed", {}).get("msg_item", []) mixed_info = cast(dict[str, Any], body.get("mixed", {}))
for item in msg_items: msg_items = cast(list[Any], mixed_info.get("msg_item", []))
item_type = item.get("msgtype", "") for raw_item in msg_items:
item = cast(dict[str, Any], raw_item)
item_type = cast(str, item.get("msgtype", ""))
if item_type == "text": if item_type == "text":
text = item.get("text", {}).get("content", "") text_info = cast(dict[str, Any], item.get("text", {}))
text = cast(str, text_info.get("content", ""))
if text: if text:
content_parts.append(text) content_parts.append(text)
elif item_type == "image": elif item_type == "image":
file_url = item.get("image", {}).get("url", "") image_info = cast(dict[str, Any], item.get("image", {}))
aes_key = item.get("image", {}).get("aeskey", "") file_url = cast(str, image_info.get("url", ""))
aes_key = cast(str, image_info.get("aeskey", ""))
if file_url and aes_key: if file_url and aes_key:
file_path = await self._download_and_save_media(file_url, aes_key, "image") file_path = await self._download_and_save_media(file_url, aes_key, "image")
if file_path: if file_path:
@@ -385,7 +401,7 @@ class WecomChannel(BaseChannel):
media_dir = get_media_dir("wecom") media_dir = get_media_dir("wecom")
if not filename: if not filename:
filename = fname or f"{media_type}_{hash(file_url) % 100000}" filename = fname or f"{media_type}_{hash(file_url) % 100000}"
filename = _sanitize_filename(filename) filename = _sanitize_filename(cast(str, filename))
file_path = media_dir / filename file_path = media_dir / filename
await asyncio.to_thread(file_path.write_bytes, data) await asyncio.to_thread(file_path.write_bytes, data)
@@ -397,8 +413,10 @@ class WecomChannel(BaseChannel):
return None return None
async def _upload_media_ws( async def _upload_media_ws(
self, client: Any, file_path: str, self,
) -> "tuple[str, str] | tuple[None, None]": client: Any,
file_path: str,
) -> tuple[str, str] | tuple[None, None]:
"""Upload a local file to WeCom via WebSocket 3-step protocol (base64). """Upload a local file to WeCom via WebSocket 3-step protocol (base64).
Uses the WeCom WebSocket upload commands directly via Uses the WeCom WebSocket upload commands directly via
@@ -417,7 +435,7 @@ class WecomChannel(BaseChannel):
media_type = _guess_wecom_media_type(fname) media_type = _guess_wecom_media_type(fname)
# Read file size and data in a thread to avoid blocking the event loop # Read file size and data in a thread to avoid blocking the event loop
def _read_file(): def _read_file() -> tuple[int, bytes]:
file_size = os.path.getsize(file_path) file_size = os.path.getsize(file_path)
if file_size > WECOM_UPLOAD_MAX_BYTES: if file_size > WECOM_UPLOAD_MAX_BYTES:
raise ValueError( raise ValueError(
@@ -530,7 +548,10 @@ class WecomChannel(BaseChannel):
# Both progress and final messages must use reply_stream (cmd="aibot_respond_msg"). # Both progress and final messages must use reply_stream (cmd="aibot_respond_msg").
# The plain reply() uses cmd="reply" which does not support "text" msgtype # The plain reply() uses cmd="reply" which does not support "text" msgtype
# and causes errcode=40008 from WeCom API. # and causes errcode=40008 from WeCom API.
stream_id = self._generate_req_id("stream") generate_req_id = self._generate_req_id
if generate_req_id is None:
raise RuntimeError("WeCom request-id generator is not initialized")
stream_id = generate_req_id("stream")
await self._client.reply_stream( await self._client.reply_stream(
frame, frame,
stream_id, stream_id,
+29 -44
View File
@@ -4,22 +4,22 @@ from __future__ import annotations
import secrets import secrets
import time import time
from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import TYPE_CHECKING, Any, cast
import httpx
from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first from nanobot.channels.connect import ChannelConnectError, QueryParams, query_first
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
if TYPE_CHECKING:
from nanobot.channels.weixin.runtime import WeixinChannel
@dataclass(slots=True) @dataclass(slots=True)
class WeixinConnectSession: class WeixinConnectSession:
id: str id: str
qrcode_id: str qrcode_id: str
qr_url: str qr_url: str
channel: Any channel: WeixinChannel
current_poll_base_url: str current_poll_base_url: str
refresh_count: int refresh_count: int
created_wall: float created_wall: float
@@ -58,9 +58,8 @@ class WeixinConnectStore:
channel = self._build_channel() channel = self._build_channel()
if force: if force:
# Preserve the working account until a replacement scan succeeds. # Preserve the working account until a replacement scan succeeds.
channel._token = "" channel.connect_reset_pending_credentials()
channel._get_updates_buf = "" elif channel.connect_load_state():
elif channel._load_state():
return { return {
"session_id": "", "session_id": "",
"status": "succeeded", "status": "succeeded",
@@ -68,13 +67,9 @@ class WeixinConnectStore:
"interval_ms": 2000, "interval_ms": 2000,
} }
channel._client = httpx.AsyncClient( channel.connect_open_client()
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
channel._running = True
try: try:
qrcode_id, qr_url = await channel._fetch_qr_code() qrcode_id, qr_url = await channel.connect_fetch_qr_code()
except Exception as exc: except Exception as exc:
await self._close_channel(channel) await self._close_channel(channel)
raise ChannelConnectError( raise ChannelConnectError(
@@ -89,7 +84,7 @@ class WeixinConnectStore:
qrcode_id=qrcode_id, qrcode_id=qrcode_id,
qr_url=qr_url, qr_url=qr_url,
channel=channel, channel=channel,
current_poll_base_url=channel.config.base_url, current_poll_base_url=channel.connect_base_url,
refresh_count=0, refresh_count=0,
created_wall=now_wall, created_wall=now_wall,
deadline=time.monotonic() + 600, deadline=time.monotonic() + 600,
@@ -107,14 +102,12 @@ class WeixinConnectStore:
} }
try: try:
status_data = await session.channel._api_get_with_base( status_data = await session.channel.connect_poll_qr_code(
base_url=session.current_poll_base_url, base_url=session.current_poll_base_url,
endpoint="ilink/bot/get_qrcode_status", qrcode_id=session.qrcode_id,
params={"qrcode": session.qrcode_id},
auth=False,
) )
except Exception as exc: except Exception as exc:
if session.channel._is_retryable_qr_poll_error(exc): if session.channel.connect_poll_error_is_retryable(exc):
session.last_error = str(exc) session.last_error = str(exc)
return self._pending_payload(session) return self._pending_payload(session)
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
@@ -125,10 +118,8 @@ class WeixinConnectStore:
"message": f"WeChat QR login failed: {exc}", "message": f"WeChat QR login failed: {exc}",
} }
if not isinstance(status_data, dict): status_payload = status_data
return self._pending_payload(session) status = status_payload.get("status", "")
status = status_data.get("status", "")
if status == "confirmed": if status == "confirmed":
if self._sessions.get(session_id) is not session: if self._sessions.get(session_id) is not session:
return { return {
@@ -136,7 +127,7 @@ class WeixinConnectStore:
"status": "cancelled", "status": "cancelled",
"message": "WeChat login cancelled.", "message": "WeChat login cancelled.",
} }
token = str(status_data.get("bot_token", "") or "") token = str(status_payload.get("bot_token", "") or "")
if not token: if not token:
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
await self._close_channel(session.channel) await self._close_channel(session.channel)
@@ -145,22 +136,19 @@ class WeixinConnectStore:
"status": "failed", "status": "failed",
"message": "WeChat confirmed the scan but returned no token.", "message": "WeChat confirmed the scan but returned no token.",
} }
base_url = str(status_data.get("baseurl", "") or "") base_url = str(status_payload.get("baseurl", "") or "")
session.channel._token = token session.channel.connect_commit_account(token=token, base_url=base_url)
if base_url:
session.channel.config.base_url = base_url
session.channel._save_state()
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
await self._close_channel(session.channel) await self._close_channel(session.channel)
return { return {
"session_id": session_id, "session_id": session_id,
"status": "succeeded", "status": "succeeded",
"message": "WeChat is connected.", "message": "WeChat is connected.",
"account": str(status_data.get("ilink_user_id", "") or ""), "account": str(status_payload.get("ilink_user_id", "") or ""),
} }
if status == "scaned_but_redirect": if status == "scaned_but_redirect":
redirect_host = str(status_data.get("redirect_host", "") or "").strip() redirect_host = str(status_payload.get("redirect_host", "") or "").strip()
if redirect_host: if redirect_host:
session.current_poll_base_url = ( session.current_poll_base_url = (
redirect_host redirect_host
@@ -182,7 +170,9 @@ class WeixinConnectStore:
"message": "This WeChat QR code expired. Start again.", "message": "This WeChat QR code expired. Start again.",
} }
try: try:
session.qrcode_id, session.qr_url = await session.channel._fetch_qr_code() session.qrcode_id, session.qr_url = (
await session.channel.connect_fetch_qr_code()
)
except Exception as exc: except Exception as exc:
self._sessions.pop(session_id, None) self._sessions.pop(session_id, None)
await self._close_channel(session.channel) await self._close_channel(session.channel)
@@ -191,7 +181,7 @@ class WeixinConnectStore:
"status": "failed", "status": "failed",
"message": f"Could not refresh WeChat QR code: {exc}", "message": f"Could not refresh WeChat QR code: {exc}",
} }
session.current_poll_base_url = session.channel.config.base_url session.current_poll_base_url = session.channel.connect_base_url
return self._pending_payload(session) return self._pending_payload(session)
return self._pending_payload(session) return self._pending_payload(session)
@@ -219,27 +209,22 @@ class WeixinConnectStore:
await self._close_channel(session.channel) await self._close_channel(session.channel)
@staticmethod @staticmethod
def _build_channel() -> Any: def _build_channel() -> WeixinChannel:
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.weixin.runtime import WeixinChannel from nanobot.channels.weixin.runtime import WeixinChannel
section = getattr(load_config().channels, "weixin", None) section = getattr(load_config().channels, "weixin", None)
if hasattr(section, "model_dump"): if section is not None and hasattr(section, "model_dump"):
config = section.model_dump(mode="json", by_alias=True) config = section.model_dump(mode="json", by_alias=True)
elif isinstance(section, dict): elif isinstance(section, dict):
config = dict(section) config = dict(cast(dict[str, Any], section))
else: else:
config = {} config = {}
return WeixinChannel(config, MessageBus()) return WeixinChannel(config, MessageBus())
@staticmethod @staticmethod
async def _close_channel(channel: Any) -> None: async def _close_channel(channel: WeixinChannel) -> None:
channel._running = False await channel.connect_close_client()
client = getattr(channel, "_client", None)
if client is not None:
with suppress(Exception):
await client.aclose()
channel._client = None
@staticmethod @staticmethod
def _start_payload(session: WeixinConnectSession) -> dict[str, Any]: def _start_payload(session: WeixinConnectSession) -> dict[str, Any]:
+164 -55
View File
@@ -21,7 +21,7 @@ import uuid
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
@@ -168,10 +168,10 @@ class WeixinChannel(BaseChannel):
self._processed_ids: OrderedDict[str, None] = OrderedDict() self._processed_ids: OrderedDict[str, None] = OrderedDict()
self._state_dir: Path | None = None self._state_dir: Path | None = None
self._token: str = "" self._token: str = ""
self._poll_task: asyncio.Task | None = None self._poll_task: asyncio.Task[None] | None = None
self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S self._next_poll_timeout_s: int = DEFAULT_LONG_POLL_TIMEOUT_S
self._session_pause_until: float = 0.0 self._session_pause_until: float = 0.0
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task[None]] = {}
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]] = {}
@@ -201,14 +201,14 @@ class WeixinChannel(BaseChannel):
if not state_file.exists(): if not state_file.exists():
return False return False
try: try:
data = json.loads(state_file.read_text()) data = cast(dict[str, Any], json.loads(state_file.read_text()))
self._token = data.get("token", "") self._token = data.get("token", "")
self._get_updates_buf = data.get("get_updates_buf", "") self._get_updates_buf = data.get("get_updates_buf", "")
context_tokens = data.get("context_tokens", {}) context_tokens = data.get("context_tokens", {})
if isinstance(context_tokens, dict): if isinstance(context_tokens, dict):
self._context_tokens = { self._context_tokens = {
str(user_id): str(token) str(user_id): str(token)
for user_id, token in context_tokens.items() for user_id, token in cast(dict[object, object], context_tokens).items()
if str(user_id).strip() and str(token).strip() if str(user_id).strip() and str(token).strip()
} }
else: else:
@@ -216,8 +216,8 @@ class WeixinChannel(BaseChannel):
typing_tickets = data.get("typing_tickets", {}) typing_tickets = data.get("typing_tickets", {})
if isinstance(typing_tickets, dict): if isinstance(typing_tickets, dict):
self._typing_tickets = { self._typing_tickets = {
str(user_id): ticket str(user_id): cast(dict[str, Any], ticket)
for user_id, ticket in typing_tickets.items() for user_id, ticket in cast(dict[object, object], typing_tickets).items()
if str(user_id).strip() and isinstance(ticket, dict) if str(user_id).strip() and isinstance(ticket, dict)
} }
else: else:
@@ -230,9 +230,30 @@ class WeixinChannel(BaseChannel):
self.logger.error("Failed to load Weixin account state", exc_info=True) self.logger.error("Failed to load Weixin account state", exc_info=True)
return False return False
def _save_state(self) -> None: def _save_state(self, *, force: bool = False) -> None:
state_file = self._get_state_dir() / "account.json" state_file = self._get_state_dir() / "account.json"
with suppress(Exception): with suppress(Exception):
if not force and state_file.exists():
persisted: object = None
try:
persisted = json.loads(state_file.read_text())
except Exception:
persisted = None
persisted_token = ""
if isinstance(persisted, dict):
persisted_mapping = cast(dict[str, object], persisted)
persisted_token = str(persisted_mapping.get("token", "") or "")
configured_token_is_authoritative: bool = bool(self.config.token) and (
self._token == self.config.token
)
if (
persisted_token
and persisted_token != self._token
and not configured_token_is_authoritative
):
# A concurrent QR login may have committed a newer token.
# Never let an older runtime snapshot overwrite it.
return
data = { data = {
"token": self._token, "token": self._token,
"get_updates_buf": self._get_updates_buf, "get_updates_buf": self._get_updates_buf,
@@ -276,18 +297,22 @@ class WeixinChannel(BaseChannel):
if isinstance(err, httpx.TimeoutException | httpx.TransportError): if isinstance(err, httpx.TimeoutException | httpx.TransportError):
return True return True
if isinstance(err, httpx.HTTPStatusError): if isinstance(err, httpx.HTTPStatusError):
status_code = err.response.status_code if err.response is not None else 0 status_code = (
err.response.status_code
if cast(object, err.response) is not None
else 0
)
return status_code >= 500 return status_code >= 500
return False return False
async def _api_get( async def _api_get(
self, self,
endpoint: str, endpoint: str,
params: dict | None = None, params: dict[str, Any] | None = None,
*, *,
auth: bool = True, auth: bool = True,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
) -> dict: ) -> dict[str, Any]:
assert self._client is not None assert self._client is not None
url = f"{self.config.base_url}/{endpoint}" url = f"{self.config.base_url}/{endpoint}"
hdrs = self._make_headers(auth=auth) hdrs = self._make_headers(auth=auth)
@@ -295,17 +320,17 @@ class WeixinChannel(BaseChannel):
hdrs.update(extra_headers) hdrs.update(extra_headers)
resp = await self._client.get(url, params=params, headers=hdrs) resp = await self._client.get(url, params=params, headers=hdrs)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
async def _api_get_with_base( async def _api_get_with_base(
self, self,
*, *,
base_url: str, base_url: str,
endpoint: str, endpoint: str,
params: dict | None = None, params: dict[str, Any] | None = None,
auth: bool = True, auth: bool = True,
extra_headers: dict[str, str] | None = None, extra_headers: dict[str, str] | None = None,
) -> dict: ) -> dict[str, Any]:
"""GET helper that allows overriding base_url for QR redirect polling.""" """GET helper that allows overriding base_url for QR redirect polling."""
assert self._client is not None assert self._client is not None
url = f"{base_url.rstrip('/')}/{endpoint}" url = f"{base_url.rstrip('/')}/{endpoint}"
@@ -314,15 +339,15 @@ class WeixinChannel(BaseChannel):
hdrs.update(extra_headers) hdrs.update(extra_headers)
resp = await self._client.get(url, params=params, headers=hdrs) resp = await self._client.get(url, params=params, headers=hdrs)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
async def _api_post( async def _api_post(
self, self,
endpoint: str, endpoint: str,
body: dict | None = None, body: dict[str, Any] | None = None,
*, *,
auth: bool = True, auth: bool = True,
) -> dict: ) -> dict[str, Any]:
assert self._client is not None assert self._client is not None
url = f"{self.config.base_url}/{endpoint}" url = f"{self.config.base_url}/{endpoint}"
payload = body or {} payload = body or {}
@@ -330,7 +355,7 @@ class WeixinChannel(BaseChannel):
payload["base_info"] = BASE_INFO payload["base_info"] = BASE_INFO
resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth)) resp = await self._client.post(url, json=payload, headers=self._make_headers(auth=auth))
resp.raise_for_status() resp.raise_for_status()
return resp.json() return cast(dict[str, Any], resp.json())
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# QR Code Login (matches login-qr.ts) # QR Code Login (matches login-qr.ts)
@@ -343,8 +368,8 @@ class WeixinChannel(BaseChannel):
params={"bot_type": "3"}, params={"bot_type": "3"},
auth=False, auth=False,
) )
qrcode_img_content = data.get("qrcode_img_content", "") qrcode_img_content = cast(str, data.get("qrcode_img_content", ""))
qrcode_id = data.get("qrcode", "") qrcode_id = cast(str, data.get("qrcode", ""))
if not qrcode_id: if not qrcode_id:
raise RuntimeError(f"Failed to get QR code from WeChat API: {data}") raise RuntimeError(f"Failed to get QR code from WeChat API: {data}")
return qrcode_id, (qrcode_img_content or qrcode_id) return qrcode_id, (qrcode_img_content or qrcode_id)
@@ -371,7 +396,7 @@ class WeixinChannel(BaseChannel):
continue continue
raise raise
if not isinstance(status_data, dict): if not isinstance(cast(object, status_data), dict):
await asyncio.sleep(1) await asyncio.sleep(1)
continue continue
@@ -431,15 +456,73 @@ class WeixinChannel(BaseChannel):
if isinstance(err, httpx.TimeoutException | httpx.TransportError): if isinstance(err, httpx.TimeoutException | httpx.TransportError):
return True return True
if isinstance(err, httpx.HTTPStatusError): if isinstance(err, httpx.HTTPStatusError):
status_code = err.response.status_code if err.response is not None else 0 status_code = (
err.response.status_code
if cast(object, err.response) is not None
else 0
)
if status_code >= 500: if status_code >= 500:
return True return True
return False return False
@property
def connect_base_url(self) -> str:
"""Base URL currently selected for the interactive connection flow."""
return self.config.base_url
def connect_reset_pending_credentials(self) -> None:
"""Clear only in-memory credentials while a replacement QR login is pending."""
self._token = ""
self._get_updates_buf = ""
def connect_load_state(self) -> bool:
"""Load an existing account for the interactive connection flow."""
return self._load_state()
def connect_open_client(self) -> None:
"""Open the short-lived HTTP client used by WebUI QR login."""
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(60, connect=30),
follow_redirects=True,
)
self._running = True
async def connect_fetch_qr_code(self) -> tuple[str, str]:
return await self._fetch_qr_code()
async def connect_poll_qr_code(
self,
*,
base_url: str,
qrcode_id: str,
) -> dict[str, Any]:
return await self._api_get_with_base(
base_url=base_url,
endpoint="ilink/bot/get_qrcode_status",
params={"qrcode": qrcode_id},
auth=False,
)
def connect_poll_error_is_retryable(self, err: Exception) -> bool:
return self._is_retryable_qr_poll_error(err)
def connect_commit_account(self, *, token: str, base_url: str) -> None:
self._token = token
if base_url:
self.config.base_url = base_url
self._save_state(force=True)
async def connect_close_client(self) -> None:
self._running = False
if self._client is not None:
with suppress(Exception):
await self._client.aclose()
self._client = None
@staticmethod @staticmethod
def _print_qr_code(url: str) -> None: def _print_qr_code(url: str) -> None:
try: try:
import qrcode as qr_lib import qrcode as qr_lib # pyright: ignore[reportMissingModuleSource]
qr = qr_lib.QRCode(border=1) qr = qr_lib.QRCode(border=1)
qr.add_data(url) qr.add_data(url)
@@ -551,6 +634,8 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
if remaining > 0: if remaining > 0:
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return return
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -596,7 +681,7 @@ class WeixinChannel(BaseChannel):
self._save_state() self._save_state()
# Process messages (WeixinMessage[] from types.ts) # Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or [] msgs = cast(list[dict[str, Any]], data.get("msgs", []) or [])
for msg in msgs: for msg in msgs:
try: try:
await self._process_message(msg) await self._process_message(msg)
@@ -607,7 +692,7 @@ class WeixinChannel(BaseChannel):
# Inbound message processing (matches inbound.ts + process-message.ts) # Inbound message processing (matches inbound.ts + process-message.ts)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _process_message(self, msg: dict) -> None: async def _process_message(self, msg: dict[str, Any]) -> None:
"""Process a single WeixinMessage from getUpdates.""" """Process a single WeixinMessage from getUpdates."""
# Skip bot's own messages (message_type 2 = BOT) # Skip bot's own messages (message_type 2 = BOT)
if msg.get("message_type") == MESSAGE_TYPE_BOT: if msg.get("message_type") == MESSAGE_TYPE_BOT:
@@ -679,7 +764,7 @@ class WeixinChannel(BaseChannel):
self._save_state() self._save_state()
# Parse item_list (WeixinMessage.item_list — types.ts:161) # Parse item_list (WeixinMessage.item_list — types.ts:161)
item_list: list[dict] = msg.get("item_list") or [] item_list = cast(list[dict[str, Any]], msg.get("item_list") or [])
content_parts: list[str] = [] content_parts: list[str] = []
media_paths: list[str] = [] media_paths: list[str] = []
has_top_level_downloadable_media = False has_top_level_downloadable_media = False
@@ -688,12 +773,16 @@ class WeixinChannel(BaseChannel):
item_type = item.get("type", 0) item_type = item.get("type", 0)
if item_type == ITEM_TEXT: if item_type == ITEM_TEXT:
text = (item.get("text_item") or {}).get("text", "") text_item = cast(dict[str, Any], item.get("text_item") or {})
text = cast(str, text_item.get("text", ""))
if text: if text:
# Handle quoted/ref messages (inbound.ts:86-98) # Handle quoted/ref messages (inbound.ts:86-98)
ref = item.get("ref_msg") ref = cast(dict[str, Any] | None, item.get("ref_msg"))
if ref: if ref:
ref_item = ref.get("message_item") ref_item = cast(
dict[str, Any] | None,
ref.get("message_item"),
)
# If quoted message is media, just pass the text # If quoted message is media, just pass the text
if ref_item and ref_item.get("type", 0) in ( if ref_item and ref_item.get("type", 0) in (
ITEM_IMAGE, ITEM_IMAGE,
@@ -705,9 +794,13 @@ class WeixinChannel(BaseChannel):
else: else:
parts: list[str] = [] parts: list[str] = []
if ref.get("title"): if ref.get("title"):
parts.append(ref["title"]) parts.append(cast(str, ref["title"]))
if ref_item: if ref_item:
ref_text = (ref_item.get("text_item") or {}).get("text", "") ref_text_item = cast(
dict[str, Any],
ref_item.get("text_item") or {},
)
ref_text = cast(str, ref_text_item.get("text", ""))
if ref_text: if ref_text:
parts.append(ref_text) parts.append(ref_text)
if parts: if parts:
@@ -718,7 +811,7 @@ class WeixinChannel(BaseChannel):
content_parts.append(text) content_parts.append(text)
elif item_type == ITEM_IMAGE: elif item_type == ITEM_IMAGE:
image_item = item.get("image_item") or {} image_item = cast(dict[str, Any], item.get("image_item") or {})
if _has_downloadable_media_locator(image_item.get("media")): if _has_downloadable_media_locator(image_item.get("media")):
has_top_level_downloadable_media = True has_top_level_downloadable_media = True
file_path = await self._download_media_item(image_item, "image") file_path = await self._download_media_item(image_item, "image")
@@ -729,9 +822,9 @@ class WeixinChannel(BaseChannel):
content_parts.append("[image]") content_parts.append("[image]")
elif item_type == ITEM_VOICE: elif item_type == ITEM_VOICE:
voice_item = item.get("voice_item") or {} voice_item = cast(dict[str, Any], item.get("voice_item") or {})
# Voice-to-text provided by WeChat (inbound.ts:101-103) # Voice-to-text provided by WeChat (inbound.ts:101-103)
voice_text = voice_item.get("text", "") voice_text = cast(str, voice_item.get("text", ""))
if voice_text: if voice_text:
content_parts.append(f"[voice] {voice_text}") content_parts.append(f"[voice] {voice_text}")
else: else:
@@ -749,10 +842,10 @@ class WeixinChannel(BaseChannel):
content_parts.append("[voice]") content_parts.append("[voice]")
elif item_type == ITEM_FILE: elif item_type == ITEM_FILE:
file_item = item.get("file_item") or {} file_item = cast(dict[str, Any], item.get("file_item") or {})
if _has_downloadable_media_locator(file_item.get("media")): if _has_downloadable_media_locator(file_item.get("media")):
has_top_level_downloadable_media = True has_top_level_downloadable_media = True
file_name = file_item.get("file_name", "unknown") file_name = cast(str, file_item.get("file_name", "unknown"))
file_path = await self._download_media_item( file_path = await self._download_media_item(
file_item, file_item,
"file", "file",
@@ -765,7 +858,7 @@ class WeixinChannel(BaseChannel):
content_parts.append(f"[file: {file_name}]") content_parts.append(f"[file: {file_name}]")
elif item_type == ITEM_VIDEO: elif item_type == ITEM_VIDEO:
video_item = item.get("video_item") or {} video_item = cast(dict[str, Any], item.get("video_item") or {})
if _has_downloadable_media_locator(video_item.get("media")): if _has_downloadable_media_locator(video_item.get("media")):
has_top_level_downloadable_media = True has_top_level_downloadable_media = True
file_path = await self._download_media_item(video_item, "video") file_path = await self._download_media_item(video_item, "video")
@@ -783,8 +876,8 @@ class WeixinChannel(BaseChannel):
for item in item_list: for item in item_list:
if item.get("type", 0) != ITEM_TEXT: if item.get("type", 0) != ITEM_TEXT:
continue continue
ref = item.get("ref_msg") or {} ref = cast(dict[str, Any], item.get("ref_msg") or {})
candidate = ref.get("message_item") or {} candidate = cast(dict[str, Any], ref.get("message_item") or {})
if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO): if candidate.get("type", 0) in (ITEM_IMAGE, ITEM_VOICE, ITEM_FILE, ITEM_VIDEO):
ref_media_item = candidate ref_media_item = candidate
break break
@@ -792,13 +885,19 @@ class WeixinChannel(BaseChannel):
if ref_media_item: if ref_media_item:
ref_type = ref_media_item.get("type", 0) ref_type = ref_media_item.get("type", 0)
if ref_type == ITEM_IMAGE: if ref_type == ITEM_IMAGE:
image_item = ref_media_item.get("image_item") or {} image_item = cast(
dict[str, Any],
ref_media_item.get("image_item") or {},
)
file_path = await self._download_media_item(image_item, "image") file_path = await self._download_media_item(image_item, "image")
if file_path: if file_path:
content_parts.append(f"[image]\n[Image: source: {file_path}]") content_parts.append(f"[image]\n[Image: source: {file_path}]")
media_paths.append(file_path) media_paths.append(file_path)
elif ref_type == ITEM_VOICE: elif ref_type == ITEM_VOICE:
voice_item = ref_media_item.get("voice_item") or {} voice_item = cast(
dict[str, Any],
ref_media_item.get("voice_item") or {},
)
file_path = await self._download_media_item(voice_item, "voice") file_path = await self._download_media_item(voice_item, "voice")
if file_path: if file_path:
transcription = await self.transcribe_audio(file_path) transcription = await self.transcribe_audio(file_path)
@@ -808,14 +907,20 @@ class WeixinChannel(BaseChannel):
content_parts.append(f"[voice]\n[Audio: source: {file_path}]") content_parts.append(f"[voice]\n[Audio: source: {file_path}]")
media_paths.append(file_path) media_paths.append(file_path)
elif ref_type == ITEM_FILE: elif ref_type == ITEM_FILE:
file_item = ref_media_item.get("file_item") or {} file_item = cast(
file_name = file_item.get("file_name", "unknown") dict[str, Any],
ref_media_item.get("file_item") or {},
)
file_name = cast(str, file_item.get("file_name", "unknown"))
file_path = await self._download_media_item(file_item, "file", file_name) file_path = await self._download_media_item(file_item, "file", file_name)
if file_path: if file_path:
content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]") content_parts.append(f"[file: {file_name}]\n[File: source: {file_path}]")
media_paths.append(file_path) media_paths.append(file_path)
elif ref_type == ITEM_VIDEO: elif ref_type == ITEM_VIDEO:
video_item = ref_media_item.get("video_item") or {} video_item = cast(
dict[str, Any],
ref_media_item.get("video_item") or {},
)
file_path = await self._download_media_item(video_item, "video") file_path = await self._download_media_item(video_item, "video")
if file_path: if file_path:
content_parts.append(f"[video]\n[Video: source: {file_path}]") content_parts.append(f"[video]\n[Video: source: {file_path}]")
@@ -848,13 +953,13 @@ class WeixinChannel(BaseChannel):
async def _download_media_item( async def _download_media_item(
self, self,
typed_item: dict, typed_item: dict[str, Any],
media_type: str, media_type: str,
filename: str | None = None, filename: str | None = None,
) -> str | None: ) -> str | None:
"""Download + AES-decrypt a media item. Returns local path or None.""" """Download + AES-decrypt a media item. Returns local path or None."""
try: try:
media = typed_item.get("media") or {} media = cast(dict[str, Any], typed_item.get("media") or {})
encrypt_query_param = str(media.get("encrypt_query_param", "") or "") encrypt_query_param = str(media.get("encrypt_query_param", "") or "")
full_url = str(media.get("full_url", "") or "").strip() full_url = str(media.get("full_url", "") or "").strip()
@@ -865,8 +970,8 @@ class WeixinChannel(BaseChannel):
# image_item.aeskey is a raw hex string (16 bytes as 32 hex chars). # image_item.aeskey is a raw hex string (16 bytes as 32 hex chars).
# media.aes_key is always base64-encoded. # media.aes_key is always base64-encoded.
# For images, prefer image_item.aeskey; for others use media.aes_key. # For images, prefer image_item.aeskey; for others use media.aes_key.
raw_aeskey_hex = typed_item.get("aeskey", "") raw_aeskey_hex = cast(str, typed_item.get("aeskey", ""))
media_aes_key_b64 = media.get("aes_key", "") media_aes_key_b64 = cast(str, media.get("aes_key", ""))
aes_key_b64: str = "" aes_key_b64: str = ""
if raw_aeskey_hex: if raw_aeskey_hex:
@@ -1160,7 +1265,7 @@ class WeixinChannel(BaseChannel):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING) await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_TYPING)
typing_keepalive_stop = asyncio.Event() typing_keepalive_stop = asyncio.Event()
typing_keepalive_task: asyncio.Task | None = None typing_keepalive_task: asyncio.Task[None] | None = None
if typing_ticket: if typing_ticket:
typing_keepalive_task = asyncio.create_task( typing_keepalive_task = asyncio.create_task(
self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop) self._typing_keepalive_loop(msg.chat_id, typing_ticket, typing_keepalive_stop)
@@ -1183,7 +1288,7 @@ class WeixinChannel(BaseChannel):
except httpx.HTTPStatusError as http_err: except httpx.HTTPStatusError as http_err:
status_code = ( status_code = (
http_err.response.status_code http_err.response.status_code
if http_err.response is not None if cast(object, http_err.response) is not None
else 0 else 0
) )
if status_code >= 500: if status_code >= 500:
@@ -1192,7 +1297,7 @@ class WeixinChannel(BaseChannel):
"Server error ({} {}) sending media {}", "Server error ({} {}) sending media {}",
status_code, status_code,
http_err.response.reason_phrase http_err.response.reason_phrase
if http_err.response is not None if cast(object, http_err.response) is not None
else "", else "",
media_path, media_path,
) )
@@ -1342,7 +1447,7 @@ class WeixinChannel(BaseChannel):
"""Send a text message matching the exact protocol from send.ts.""" """Send a text message matching the exact protocol from send.ts."""
client_id = f"nanobot-{uuid.uuid4().hex[:12]}" client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
item_list: list[dict] = [] item_list: list[dict[str, Any]] = []
if text: if text:
item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}}) item_list.append({"type": ITEM_TEXT, "text_item": {"text": text}})
@@ -1496,7 +1601,9 @@ class WeixinChannel(BaseChannel):
# Send each media item as its own message (matching reference plugin) # Send each media item as its own message (matching reference plugin)
client_id = f"nanobot-{uuid.uuid4().hex[:12]}" client_id = f"nanobot-{uuid.uuid4().hex[:12]}"
item_list: list[dict] = [{"type": item_type, item_key: media_item}] item_list: list[dict[str, Any]] = [
{"type": item_type, item_key: media_item}
]
weixin_msg: dict[str, Any] = { weixin_msg: dict[str, Any] = {
"from_user_id": "", "from_user_id": "",
@@ -1565,7 +1672,8 @@ def _encrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
with suppress(ImportError): with suppress(ImportError):
from Crypto.Cipher import AES from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_ECB) aes_module = cast(Any, AES)
cipher = aes_module.new(key, aes_module.MODE_ECB)
return cipher.encrypt(padded) return cipher.encrypt(padded)
try: try:
@@ -1595,7 +1703,8 @@ def _decrypt_aes_ecb(data: bytes, aes_key_b64: str) -> bytes:
with suppress(ImportError): with suppress(ImportError):
from Crypto.Cipher import AES from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_ECB) aes_module = cast(Any, AES)
cipher = aes_module.new(key, aes_module.MODE_ECB)
decrypted = cipher.decrypt(data) decrypted = cipher.decrypt(data)
if decrypted is None: if decrypted is None:
@@ -98,6 +98,80 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
assert restored._context_tokens == {"wx-user": "ctx-1"} assert restored._context_tokens == {"wx-user": "ctx-1"}
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
replacement = {
"token": "new-token",
"base_url": "https://new.example",
"get_updates_buf": "",
"context_tokens": {},
"typing_tickets": {},
}
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
channel._get_updates_buf = "stale-cursor"
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == replacement
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
channel.connect_commit_account(token="new-token", base_url="https://new.example")
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "new-token"
assert saved["base_url"] == "https://new.example"
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
channel._get_updates_buf = "current-cursor"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
encoding="utf-8",
)
channel._save_state()
saved = json.loads((tmp_path / "account.json").read_text())
assert saved["token"] == "configured-token"
assert saved["get_updates_buf"] == "current-cursor"
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
channel._save_state()
assert json.loads((tmp_path / "account.json").read_text()) == persisted
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None: async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel() channel, bus = _make_channel()
@@ -462,6 +536,56 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
assert channel._session_pause_remaining_s() > 0 assert channel._session_pause_remaining_s() > 0
@pytest.mark.asyncio
async def test_poll_once_reloads_refreshed_state_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
MessageBus(),
)
channel._token = "old-token"
channel._save_state()
(tmp_path / "account.json").write_text(
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "new-token"
assert channel.config.base_url == "https://new.example"
@pytest.mark.asyncio
async def test_poll_once_keeps_explicit_token_after_session_pause(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
channel = WeixinChannel(
WeixinConfig(
enabled=True,
allow_from=["*"],
token="configured-token",
state_dir=str(tmp_path),
),
MessageBus(),
)
channel._token = "configured-token"
(tmp_path / "account.json").write_text(
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
encoding="utf-8",
)
channel._session_pause_until = time.time() + 10
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
await channel._poll_once()
assert channel._token == "configured-token"
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds( async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay, no_qr_poll_delay,
+102 -13
View File
@@ -1,3 +1,4 @@
# pyright: reportConstantRedefinition=false, reportMissingTypeStubs=false, reportUnusedFunction=false
"""WhatsApp channel implementation using neonize.""" """WhatsApp channel implementation using neonize."""
from __future__ import annotations from __future__ import annotations
@@ -10,8 +11,10 @@ import time
from collections import OrderedDict from collections import OrderedDict
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import Any, Literal, NamedTuple from typing import Any, Literal, NamedTuple, cast
from urllib.parse import urlparse
import httpx
from pydantic import Field from pydantic import Field
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -19,6 +22,7 @@ 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
from nanobot.config.schema import Base from nanobot.config.schema import Base
from nanobot.security.network import PinnedDNSAsyncTransport
class WhatsAppConfig(Base): class WhatsAppConfig(Base):
@@ -38,6 +42,8 @@ class _NeonizeAPI(NamedTuple):
MessageEv: Any MessageEv: Any
PairStatusEv: Any PairStatusEv: Any
build_jid: Any build_jid: Any
detect_mime: Any
detect_buffer: Any
class _MediaInfo(NamedTuple): class _MediaInfo(NamedTuple):
@@ -51,6 +57,15 @@ class _MediaInfo(NamedTuple):
_NEONIZE_API: _NeonizeAPI | None = None _NEONIZE_API: _NeonizeAPI | None = None
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$") _JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token") _LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
_REMOTE_MEDIA_MAX_BYTES = 32 * 1024 * 1024
_REMOTE_MEDIA_MAX_REDIRECTS = 5
_REMOTE_MEDIA_TIMEOUT_SECONDS = 120.0
# OGG is intentionally excluded: WhatsApp accepts only mono Opus, which MIME sniffing cannot prove.
_DIRECT_AUDIO_MIMETYPES = {"audio/aac", "audio/amr", "audio/mp4", "audio/mpeg"}
_MIMETYPE_ALIASES = {
"audio/x-hx-aac-adts": "audio/aac",
"audio/x-m4a": "audio/mp4",
}
def _default_database_path() -> Path: def _default_database_path() -> Path:
@@ -67,9 +82,15 @@ def _load_neonize() -> _NeonizeAPI:
return _NEONIZE_API return _NEONIZE_API
try: try:
import magic
from neonize.aioze.client import NewAClient from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.jid import build_jid from neonize.utils.jid import build_jid
detect_mime = getattr(magic, "from_file", None)
detect_buffer = getattr(magic, "from_buffer", None)
if not callable(detect_mime) or not callable(detect_buffer):
raise ImportError("python-magic does not expose from_file/from_buffer")
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp" "WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
@@ -82,6 +103,8 @@ def _load_neonize() -> _NeonizeAPI:
MessageEv=MessageEv, MessageEv=MessageEv,
PairStatusEv=PairStatusEv, PairStatusEv=PairStatusEv,
build_jid=build_jid, build_jid=build_jid,
detect_mime=detect_mime,
detect_buffer=detect_buffer,
) )
return _NEONIZE_API return _NEONIZE_API
@@ -100,7 +123,8 @@ def _has_field(message: Any, name: str) -> bool:
list_fields = getattr(message, "ListFields", None) list_fields = getattr(message, "ListFields", None)
if callable(list_fields): if callable(list_fields):
try: try:
return any(getattr(field, "name", "") == name for field, _ in list_fields()) fields = cast(list[tuple[Any, Any]], list_fields())
return any(getattr(field, "name", "") == name for field, _ in fields)
except Exception: except Exception:
pass pass
@@ -277,7 +301,10 @@ class WhatsAppChannel(BaseChannel):
return WhatsAppConfig().model_dump(by_alias=True) return WhatsAppConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
legacy_bridge_fields = _legacy_bridge_config_fields(config) if isinstance(config, dict) else [] legacy_bridge_fields = (
_legacy_bridge_config_fields(cast(dict[str, Any], config))
if isinstance(config, dict) else []
)
if isinstance(config, dict): if isinstance(config, dict):
config = WhatsAppConfig.model_validate(config) config = WhatsAppConfig.model_validate(config)
super().__init__(config, bus) super().__init__(config, bus)
@@ -412,23 +439,84 @@ class WhatsAppChannel(BaseChannel):
return api.build_jid(user, server) return api.build_jid(user, server)
async def _send_media(self, client: Any, to: Any, media_path: str) -> None: async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
path = str(Path(media_path).expanduser()) source: str | bytes
mime, _ = mimetypes.guess_type(path) if media_path.startswith(("http://", "https://")):
mimetype = mime or "application/octet-stream" source = await self._fetch_remote_media(media_path)
filename = Path(urlparse(media_path).path).name or "attachment"
else:
source = str(Path(media_path).expanduser())
filename = Path(source).name
mimetype = self._detect_mimetype(source)
if mimetype.startswith("image/"): if mimetype.startswith("image/"):
await client.send_image(to, path) await client.send_image(to, source)
elif mimetype.startswith("video/"): elif mimetype.startswith("video/"):
await client.send_video(to, path) await client.send_video(to, source)
elif mimetype.startswith("audio/"): elif mimetype in _DIRECT_AUDIO_MIMETYPES:
await client.send_audio(to, path) await client.send_audio(to, source)
else: else:
await client.send_document( await client.send_document(
to, to,
path, source,
filename=Path(path).name, filename=filename,
mimetype=mimetype, mimetype=mimetype,
) )
async def _fetch_remote_media(self, url: str) -> bytes:
timeout = httpx.Timeout(_REMOTE_MEDIA_TIMEOUT_SECONDS, connect=10.0)
async with httpx.AsyncClient(
transport=PinnedDNSAsyncTransport(),
follow_redirects=True,
max_redirects=_REMOTE_MEDIA_MAX_REDIRECTS,
timeout=timeout,
trust_env=False,
) as http:
async with http.stream("GET", url) as response:
response.raise_for_status()
declared_size = response.headers.get("content-length")
if (
declared_size
and declared_size.isdigit()
and int(declared_size) > _REMOTE_MEDIA_MAX_BYTES
):
raise ValueError(
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
)
chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > _REMOTE_MEDIA_MAX_BYTES:
raise ValueError(
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
)
chunks.append(chunk)
return b"".join(chunks)
def _detect_mimetype(self, source: str | bytes) -> str:
try:
api = _load_neonize()
detected = (
api.detect_buffer(source, mime=True)
if isinstance(source, bytes)
else api.detect_mime(source, mime=True)
)
except Exception as exc:
label = f"{len(source)} downloaded bytes" if isinstance(source, bytes) else source
self.logger.debug("Failed to inspect WhatsApp media {}: {}", label, exc)
detected = None
if isinstance(detected, str) and "/" in detected:
mimetype = detected.partition(";")[0].strip().lower()
return _MIMETYPE_ALIASES.get(mimetype, mimetype)
if isinstance(source, bytes):
return "application/octet-stream"
guessed, _ = mimetypes.guess_type(source)
return guessed or "application/octet-stream"
def _register_handlers( def _register_handlers(
self, self,
client: Any, client: Any,
@@ -649,12 +737,13 @@ class WhatsAppChannel(BaseChannel):
if not self._self_jids: if not self._self_jids:
return False return False
for context in _context_infos(message): for context in _context_infos(message):
mentioned = ( raw_mentioned: Any = (
_safe_attr(context, "mentionedJID") _safe_attr(context, "mentionedJID")
or _safe_attr(context, "mentionedJid") or _safe_attr(context, "mentionedJid")
or _safe_attr(context, "mentioned_jid") or _safe_attr(context, "mentioned_jid")
or [] or []
) )
mentioned: list[Any] = cast(list[Any], raw_mentioned)
for jid in mentioned: for jid in mentioned:
normalized = _normalize_jid(jid) normalized = _normalize_jid(jid)
if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids: if normalized in self._self_jids or _bare_jid(normalized) in self._self_jids:
@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import mimetypes
import sys import sys
import types import types
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
import nanobot.channels.whatsapp.runtime as whatsapp_module import nanobot.channels.whatsapp.runtime as whatsapp_module
@@ -78,7 +80,21 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel:
return ch return ch
def _patch_neonize_api(monkeypatch) -> None: def _make_send_client() -> SimpleNamespace:
return SimpleNamespace(
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> None:
detect_mime = detect_mime or (
lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream"
)
detect_buffer = detect_buffer or (lambda data, *, mime: "application/octet-stream")
monkeypatch.setattr( monkeypatch.setattr(
whatsapp_module, whatsapp_module,
"_NEONIZE_API", "_NEONIZE_API",
@@ -89,6 +105,8 @@ def _patch_neonize_api(monkeypatch) -> None:
MessageEv=object(), MessageEv=object(),
PairStatusEv=object(), PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server), build_jid=lambda user, server="s.whatsapp.net": (user, server),
detect_mime=detect_mime,
detect_buffer=detect_buffer,
), ),
) )
@@ -178,13 +196,7 @@ async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None: async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
_patch_neonize_api(monkeypatch) _patch_neonize_api(monkeypatch)
client = SimpleNamespace( client = _make_send_client()
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel() ch = _make_channel()
ch._client = client ch._client = client
ch._connected = True ch._connected = True
@@ -197,13 +209,7 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch) _patch_neonize_api(monkeypatch)
client = SimpleNamespace( client = _make_send_client()
send_message=AsyncMock(),
send_image=AsyncMock(),
send_video=AsyncMock(),
send_audio=AsyncMock(),
send_document=AsyncMock(),
)
ch = _make_channel() ch = _make_channel()
ch._client = client ch._client = client
ch._connected = True ch._connected = True
@@ -213,14 +219,14 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
channel="whatsapp", channel="whatsapp",
chat_id="12345@s.whatsapp.net", chat_id="12345@s.whatsapp.net",
content="", content="",
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"], media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"],
) )
) )
jid = ("12345", "s.whatsapp.net") jid = ("12345", "s.whatsapp.net")
client.send_image.assert_awaited_once_with(jid, "photo.jpg") client.send_image.assert_awaited_once_with(jid, "photo.jpg")
client.send_video.assert_awaited_once_with(jid, "clip.mp4") client.send_video.assert_awaited_once_with(jid, "clip.mp4")
client.send_audio.assert_awaited_once_with(jid, "voice.ogg") client.send_audio.assert_awaited_once_with(jid, "voice.mp3")
client.send_document.assert_awaited_once_with( client.send_document.assert_awaited_once_with(
jid, jid,
"report.pdf", "report.pdf",
@@ -229,6 +235,191 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
) )
@pytest.mark.asyncio
async def test_send_mislabeled_audio_as_document(monkeypatch) -> None:
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/x-wav")
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["recording.mpeg"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
"recording.mpeg",
filename="recording.mpeg",
mimetype="audio/x-wav",
)
client.send_video.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_mislabeled_audio_as_document(monkeypatch) -> None:
payload = b"remote wav payload"
media_url = "https://cdn.example/recording.mpeg?token=secret"
def handle_request(request: httpx.Request) -> httpx.Response:
assert str(request.url) == media_url
return httpx.Response(200, content=payload)
monkeypatch.setattr(
whatsapp_module,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(handle_request),
)
def detect_buffer(data: bytes, *, mime: bool) -> str:
assert data == payload
assert mime is True
return "audio/x-wav"
_patch_neonize_api(
monkeypatch,
detect_buffer=detect_buffer,
)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=[media_url],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
payload,
filename="recording.mpeg",
mimetype="audio/x-wav",
)
client.send_video.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_media_blocks_private_url(monkeypatch) -> None:
_patch_neonize_api(monkeypatch)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
with pytest.raises(httpx.RequestError, match="private/internal"):
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["http://127.0.0.1/recording.mpeg"],
)
)
client.send_video.assert_not_awaited()
client.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_remote_media_enforces_download_limit(monkeypatch) -> None:
monkeypatch.setattr(whatsapp_module, "_REMOTE_MEDIA_MAX_BYTES", 3)
monkeypatch.setattr(
whatsapp_module,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, content=b"1234")),
)
_patch_neonize_api(monkeypatch)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
with pytest.raises(ValueError, match="exceeds the 3-byte limit"):
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["https://cdn.example/recording.mpeg"],
)
)
client.send_video.assert_not_awaited()
client.send_document.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=["voice.ogg"],
)
)
jid = ("12345", "s.whatsapp.net")
client.send_document.assert_awaited_once_with(
jid,
"voice.ogg",
filename="voice.ogg",
mimetype="audio/ogg",
)
client.send_audio.assert_not_awaited()
@pytest.mark.parametrize(
("detected_mimetype", "filename"),
[
("audio/x-m4a", "recording.m4a"),
("audio/x-hx-aac-adts", "recording.aac"),
],
)
@pytest.mark.asyncio
async def test_send_supported_audio_magic_aliases_inline(
monkeypatch, detected_mimetype: str, filename: str
) -> None:
_patch_neonize_api(
monkeypatch,
detect_mime=lambda path, *, mime: detected_mimetype,
)
client = _make_send_client()
ch = _make_channel()
ch._client = client
ch._connected = True
await ch.send(
OutboundMessage(
channel="whatsapp",
chat_id="12345@s.whatsapp.net",
content="",
media=[filename],
)
)
client.send_audio.assert_awaited_once_with(("12345", "s.whatsapp.net"), filename)
client.send_document.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_when_disconnected_raises() -> None: async def test_send_when_disconnected_raises() -> None:
ch = _make_channel() ch = _make_channel()
+352
View File
@@ -0,0 +1,352 @@
"""Direct and interactive agent CLI command."""
import asyncio
import signal
import sys
from collections.abc import Awaitable, Callable
from types import FrameType
from typing import Any
import typer
from rich.console import Console
from nanobot import __logo__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.bus.outbound_events import (
StreamDeltaEvent,
StreamedResponseEvent,
StreamEndEvent,
outbound_event_from_message,
)
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.log_control import _set_nanobot_logs
from nanobot.cli.runtime_config import (
_load_runtime_config,
_migrate_cron_store,
_model_display,
_print_agent_start_error,
)
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.config.paths import is_default_workspace
from nanobot.utils.helpers import (
sanitize_surrogates as _sanitize_surrogates,
)
from nanobot.utils.helpers import (
sync_workspace_templates,
)
from nanobot.utils.restart import (
consume_restart_notice_from_env,
format_restart_completed_message,
should_show_cli_restart_notice,
)
console = Console()
def agent(
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
markdown: bool = typer.Option(
True,
"--markdown/--no-markdown",
help="Render assistant output as Markdown",
),
logs: bool = typer.Option(
False,
"--logs/--no-logs",
help="Show nanobot runtime logs during chat",
),
):
"""Interact with the agent directly."""
from nanobot.bus.queue import MessageBus
from nanobot.cron.service import CronService
from nanobot.providers.factory import make_provider
from nanobot.providers.image_generation import image_gen_provider_configs
runtime_config = _load_runtime_config(config, workspace)
try:
provider = make_provider(runtime_config)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
sync_workspace_templates(runtime_config.workspace_path)
bus = MessageBus()
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(runtime_config.workspace_path):
_migrate_cron_store(runtime_config)
# Create cron service with workspace-scoped store
cron_store_path = runtime_config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
_set_nanobot_logs(logs)
try:
agent_loop = AgentLoop.from_config(
runtime_config,
bus,
provider=provider,
cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
)
except ValueError as exc:
_print_agent_start_error(exc)
raise typer.Exit(1) from exc
restart_notice = consume_restart_notice_from_env()
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
cli_terminal._print_agent_response(
format_restart_completed_message(restart_notice.started_at_raw),
render_markdown=False,
)
# Shared reference for progress callbacks
_thinking: ThinkingSpinner | None = None
def _make_progress(
renderer: StreamRenderer | None = None,
) -> Callable[..., Awaitable[None]]:
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _cli_progress(
content: str,
*,
tool_hint: bool = False,
reasoning: bool = False,
**_kwargs: Any,
) -> None:
ch = agent_loop.channels_config
if _kwargs.get("reasoning_end"):
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
else:
cli_terminal._flush_cli_reasoning(reasoning_buffer, _thinking, renderer)
return
if reasoning:
if ch and not ch.show_reasoning:
reasoning_buffer.clear()
return
text = reasoning_buffer.add(content)
if text:
cli_terminal._print_cli_reasoning(text, _thinking, renderer)
return
if ch and tool_hint and not ch.send_tool_hints:
return
if ch and not tool_hint and not ch.send_progress:
return
cli_terminal._print_cli_progress_line(content, _thinking, renderer)
return _cli_progress
if message:
# Single message mode — direct call, no bus needed
async def run_once() -> None:
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
response = await agent_loop.process_direct(
message,
session_id,
on_progress=_make_progress(renderer),
on_stream=renderer.on_delta,
on_stream_end=renderer.on_end,
)
if not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
response.content if response else "",
render_markdown=markdown,
metadata=response.metadata if response else None,
**print_kwargs,
)
await agent_loop.close_mcp()
asyncio.run(run_once())
else:
# Interactive mode — route through bus like other channels
from nanobot.bus.events import InboundMessage
cli_terminal._init_prompt_session()
_model, _preset_tag = _model_display(runtime_config)
_icon = runtime_config.agents.defaults.bot_icon or __logo__
console.print(
f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} "
"— type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n"
)
if ":" in session_id:
cli_channel, cli_chat_id = session_id.split(":", 1)
else:
cli_channel, cli_chat_id = "cli", session_id
def _handle_signal(signum: int, _frame: FrameType | None) -> None:
sig_name = signal.Signals(signum).name
cli_terminal._restore_terminal()
console.print(f"\nReceived {sig_name}, goodbye!")
sys.exit(0)
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
# SIGHUP is not available on Windows
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, _handle_signal)
# Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
# SIGPIPE is not available on Windows
if hasattr(signal, "SIGPIPE"):
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
async def run_interactive() -> None:
bus_task = asyncio.create_task(agent_loop.run())
turn_done = asyncio.Event()
turn_done.set()
turn_response: list[Any] = []
renderer: StreamRenderer | None = None
reasoning_buffer = cli_terminal._ReasoningBuffer()
async def _consume_outbound() -> None:
while True:
try:
msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0)
event = outbound_event_from_message(msg)
if isinstance(event, StreamDeltaEvent):
if renderer:
await renderer.on_delta(msg.content)
continue
if isinstance(event, StreamEndEvent):
if renderer:
await renderer.on_end(
resuming=event.resuming,
)
continue
if isinstance(event, StreamedResponseEvent):
if msg.content and renderer and not renderer.streamed:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
**print_kwargs,
)
turn_done.set()
continue
if await cli_terminal._maybe_print_interactive_progress(
msg,
None,
agent_loop.channels_config,
renderer,
reasoning_buffer,
):
continue
if not turn_done.is_set():
if msg.content:
turn_response.append(msg)
turn_done.set()
elif msg.content:
await cli_terminal._print_interactive_response(
msg.content,
render_markdown=markdown,
metadata=msg.metadata,
)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
outbound_task = asyncio.create_task(_consume_outbound())
try:
while True:
try:
cli_terminal._flush_pending_tty_input()
# Stop spinner before user input to avoid prompt_toolkit conflicts
if renderer:
renderer.stop_for_input()
user_input = _sanitize_surrogates(
await cli_terminal._read_interactive_input_async()
)
command = user_input.strip()
if not command:
continue
if cli_terminal._is_exit_command(command):
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
turn_done.clear()
turn_response.clear()
reasoning_buffer.clear()
renderer = StreamRenderer(
render_markdown=markdown,
bot_name=runtime_config.agents.defaults.bot_name,
bot_icon=runtime_config.agents.defaults.bot_icon,
)
await bus.publish_inbound(
InboundMessage(
channel=cli_channel,
sender_id="user",
chat_id=cli_chat_id,
content=user_input,
metadata={"_wants_stream": True},
)
)
await turn_done.wait()
if turn_response:
response_msg = turn_response[0]
content = response_msg.content
meta = response_msg.metadata
if content and not isinstance(
response_msg.event,
StreamedResponseEvent,
):
if renderer:
await renderer.close()
print_kwargs: dict[str, Any] = {}
if renderer and renderer.header_printed:
print_kwargs["show_header"] = False
cli_terminal._print_agent_response(
content,
render_markdown=markdown,
metadata=meta,
**print_kwargs,
)
elif renderer and not renderer.streamed:
await renderer.close()
except KeyboardInterrupt:
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
except EOFError:
cli_terminal._restore_terminal()
console.print("\nGoodbye!")
break
finally:
agent_loop.stop()
outbound_task.cancel()
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
await agent_loop.close_mcp()
asyncio.run(run_interactive())
+57 -2596
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,7 @@
"""Typer commands for foreground and background gateway control.""" """Typer commands for foreground and background gateway control."""
# pyright: reportUnusedFunction=false
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
+828
View File
@@ -0,0 +1,828 @@
"""Foreground gateway runtime and lifecycle helpers."""
import asyncio
import signal
from collections.abc import Awaitable, Callable, Coroutine, Iterable
from contextlib import suppress
from pathlib import Path
from typing import Any, cast
import typer
from loguru import logger
from rich.console import Console
from nanobot import __logo__, __version__
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.runtime_config import _migrate_cron_store
from nanobot.cli.webui_support import (
_gateway_health_bind_note,
_gateway_health_url,
_host_for_local_browser,
_prepare_webui_bundle_for_gateway,
_print_foreground_port_conflict,
_tcp_endpoint_reachable,
_webui_browser_url,
_webui_channel_enabled,
_webui_endpoint_reachable,
)
from nanobot.config.paths import is_default_workspace
from nanobot.config.schema import Config
from nanobot.security.network import is_loopback_host
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
from nanobot.utils.helpers import sync_workspace_templates
from nanobot.webui.build import BuildMode
from nanobot.webui.sidebar_state import read_webui_sidebar_state
__all__ = ["_run_gateway"]
console = Console()
def _signal_name(signum: int) -> str:
with suppress(ValueError):
return signal.Signals(signum).name
return f"signal {signum}"
def _install_gateway_shutdown_handlers(
loop: asyncio.AbstractEventLoop,
shutdown_event: asyncio.Event,
tasks: list[asyncio.Task[Any]],
print_status: Callable[[str], None],
) -> Callable[[], None]:
"""Install foreground gateway signal handlers and return a restore callback."""
loop_signals: list[int] = []
previous_handlers: list[tuple[int, Any]] = []
shutdown_requested = False
def request_shutdown(signum: int) -> None:
nonlocal shutdown_requested
sig_name = _signal_name(signum)
if shutdown_requested:
logger.warning("Forcing gateway shutdown after repeated {}", sig_name)
for task in tasks:
if not task.done():
task.cancel()
return
shutdown_requested = True
logger.info("Gateway shutdown requested by {}", sig_name)
print_status("\nShutting down... Press Ctrl+C again to force.")
shutdown_event.set()
for signum in (signal.SIGINT, signal.SIGTERM):
try:
loop.add_signal_handler(signum, request_shutdown, signum)
except (NotImplementedError, RuntimeError, ValueError):
try:
previous = signal.getsignal(signum)
signal.signal(signum, lambda sig, _frame: request_shutdown(sig))
except (RuntimeError, ValueError):
logger.debug("Could not install gateway handler for {}", _signal_name(signum))
continue
previous_handlers.append((signum, previous))
else:
loop_signals.append(signum)
def restore() -> None:
for signum in loop_signals:
with suppress(NotImplementedError, RuntimeError, ValueError):
loop.remove_signal_handler(signum)
for signum, handler in previous_handlers:
with suppress(RuntimeError, ValueError):
signal.signal(signum, handler)
return restore
def _advance_dream_cursor_if_behind(memory: Any) -> None:
latest = memory.get_latest_cursor()
if memory.get_last_dream_cursor() < latest:
memory.set_last_dream_cursor(latest)
def _commit_dream_changes(memory: Any) -> str | None:
"""Commit durable Dream edits, without entering the commit path for a no-op run."""
if not memory.git.is_initialized():
return None
diff_body = memory.dream_content_diff()
if not diff_body:
return None
message = memory.build_dream_commit_message(
"dream: periodic memory consolidation",
diff_body,
)
return memory.git.auto_commit(message)
_HEARTBEAT_PREAMBLE = (
"[Your response will be delivered directly to the user's messaging app. "
"Output ONLY the final user-facing message. Never reference internal "
"files (HEARTBEAT.md, AWARENESS.md, etc.), your instructions, or your "
"decision process. If nothing needs reporting, respond with just "
"'All clear.' and nothing else.]\n\n"
)
def _heartbeat_has_active_tasks(content: str) -> bool:
"""True if HEARTBEAT.md has task lines, ignoring headers, blanks and comments."""
in_comment = False
in_active_section: bool = False
for line in content.splitlines():
stripped = line.strip()
if in_comment:
if "-->" in stripped:
in_comment = False
continue
if not stripped or stripped.startswith("#"):
if stripped.startswith("##") and not stripped.startswith("###"):
heading = stripped.lstrip("#").strip().lower()
in_active_section = heading.startswith("active tasks")
continue
if stripped.startswith("<!--"):
if "-->" not in stripped[4:]:
in_comment = True
continue
if in_active_section is False:
continue
return True
return False
def _pick_heartbeat_target_from_sessions(
*,
enabled_channels: Iterable[str],
sessions: Iterable[dict[str, Any]],
archived_keys: Iterable[str],
unified_session_metadata: dict[str, Any] | None = None,
) -> tuple[str, str]:
enabled = set(enabled_channels)
archived = set(archived_keys)
for item in sessions:
key = item.get("key") or ""
if key in archived:
continue
if key == UNIFIED_SESSION_KEY:
route = last_channel_from_metadata(unified_session_metadata)
if route is not None:
channel, chat_id = route
if channel not in {"cli", "system"} and channel in enabled:
return channel, chat_id
continue
if ":" not in key:
continue
channel, chat_id = key.split(":", 1)
if channel in {"cli", "system"}:
continue
if channel in enabled and chat_id:
return channel, chat_id
return "cli", "direct"
_GATEWAY_HEALTH_MAX_CONNECTIONS = 64
_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS = 2.0
def _print_gateway_health_endpoint(host: str, port: int) -> None:
"""Print a usable health URL and make non-loopback binds explicit."""
console.print(
f"[green]✓[/green] Health endpoint: {_gateway_health_url(host, port)}"
f"{_gateway_health_bind_note(host)}"
)
if is_loopback_host(host):
return
console.print(
"[yellow]Warning: the unauthenticated health endpoint is listening beyond loopback "
"and may be reachable from other devices. "
f"Keep port {port} private or protect it with a firewall or reverse proxy.[/yellow]"
)
def _run_gateway(
config: Config,
*,
port: int | None = None,
open_browser_url: str | None = None,
webui_static_dist: bool = True,
webui_bundle_mode: BuildMode = "warn",
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
unconfigured_provider_error: str | None = None,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.model_presets import load_model_preset_catalog
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.turn_delivery import TurnDeliveryFactory
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.channels.manager import ChannelManager
from nanobot.config.watcher import watch_config_file
from nanobot.cron.bound_runner import run_bound_cron_job
from nanobot.cron.service import CronJobSkippedError, CronService
from nanobot.cron.session_turns import is_bound_cron_job
from nanobot.cron.types import CronJob
from nanobot.providers.factory import (
ProviderSnapshot,
build_provider_snapshot,
build_unconfigured_provider_snapshot,
load_provider_snapshot,
)
from nanobot.providers.fallback_provider import FallbackProvider
from nanobot.providers.image_generation import image_gen_provider_configs
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import (
WebuiTurnCoordinator,
WebuiTurnRoutePolicy,
build_webui_fallback_model_observer,
)
from nanobot.triggers.local_runner import run_local_trigger_queue
from nanobot.triggers.local_store import LocalTriggerStore
from nanobot.webui.token_usage import TokenUsageHook
port = port if port is not None else config.gateway.port
webui_url = _webui_browser_url(config)
gateway_host_for_browser = _host_for_local_browser(config.gateway.host)
if health_server_enabled and _tcp_endpoint_reachable(gateway_host_for_browser, port):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
if _webui_channel_enabled(config) and _webui_endpoint_reachable(webui_url):
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=config.gateway.host,
gateway_port=port,
)
raise typer.Exit(1)
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
_prepare_webui_bundle_for_gateway(
config,
mode=webui_bundle_mode,
webui_static_dist=webui_static_dist,
)
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
runtime_events = RuntimeEventBus()
fallback_model_observer = build_webui_fallback_model_observer(bus)
def _observe_fallback_models(snapshot: ProviderSnapshot) -> ProviderSnapshot:
if isinstance(snapshot.provider, FallbackProvider):
snapshot.provider.set_fallback_model_observer(fallback_model_observer)
return snapshot
def _load_gateway_provider_snapshot(
*args: Any,
**kwargs: Any,
) -> ProviderSnapshot:
try:
return _observe_fallback_models(load_provider_snapshot(*args, **kwargs))
except ValueError as exc:
if unconfigured_provider_error is None:
raise
return build_unconfigured_provider_snapshot(config, str(exc))
if unconfigured_provider_error is not None:
provider_snapshot = build_unconfigured_provider_snapshot(
config,
unconfigured_provider_error,
)
else:
try:
provider_snapshot = _observe_fallback_models(build_provider_snapshot(config))
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
session_manager = SessionManager(config.workspace_path)
# Self-heal the gateway state file with the current PID after any restart.
from nanobot.config.loader import get_config_path
from nanobot.gateway.runtime import GatewayRuntime, GatewayRuntimePaths
config_path = str(get_config_path().resolve(strict=False))
GatewayRuntime.refresh_state_pid(
paths=GatewayRuntimePaths.for_instance(
workspace=str(config.workspace_path)
if not is_default_workspace(config.workspace_path)
else None,
config_path=config_path,
)
)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path):
_migrate_cron_store(config)
# Create cron service with workspace-scoped store
cron_store_path = config.workspace_path / "cron" / "jobs.json"
cron = CronService(cron_store_path)
trigger_store = LocalTriggerStore(config.workspace_path)
turn_delivery_factory = TurnDeliveryFactory(
bus,
runtime_events,
route_policy=WebuiTurnRoutePolicy(session_manager),
)
# Create agent with cron service
agent = AgentLoop.from_config(
config, bus,
provider=provider_snapshot.provider,
model=provider_snapshot.model,
context_window_tokens=provider_snapshot.context_window_tokens,
cron_service=cron,
session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(config),
provider_snapshot_loader=_load_gateway_provider_snapshot,
preset_catalog_loader=load_model_preset_catalog,
runtime_events=runtime_events,
turn_delivery_factory=turn_delivery_factory,
provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
)
def _schedule_webui_background(awaitable: Awaitable[None]) -> None:
agent.schedule_background(cast(Coroutine[Any, Any, None], awaitable))
webui_turn_coordinator = WebuiTurnCoordinator(
bus=bus,
sessions=session_manager,
schedule_background=_schedule_webui_background,
)
webui_turn_coordinator.subscribe(runtime_events)
from nanobot.bus.events import OutboundMessage
from nanobot.session.keys import session_key_for_channel
def _channel_session_key(channel: str, chat_id: str) -> str:
return session_key_for_channel(
channel,
chat_id,
unified_session=config.agents.defaults.unified_session,
)
async def _deliver_to_channel(
msg: OutboundMessage, *, record: bool = False, session_key: str | None = None,
) -> None:
"""Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False))
if metadata != (msg.metadata or {}):
msg = OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=msg.content,
reply_to=msg.reply_to,
media=msg.media,
metadata=metadata,
buttons=msg.buttons,
)
if (
record
and msg.channel != "cli"
and msg.content.strip()
and hasattr(session_manager, "get_or_create")
and hasattr(session_manager, "save")
):
key = session_key or _channel_session_key(msg.channel, msg.chat_id)
session = session_manager.get_or_create(key)
extra: dict[str, Any] = {"_channel_delivery": True}
if msg.media:
extra["media"] = list(msg.media)
session.add_message("assistant", msg.content, **extra)
session_manager.save(session)
await bus.publish_outbound(msg)
message_tool = agent.tools.get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_send_callback(_deliver_to_channel)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
async def _silent(*_args: Any, **_kwargs: Any) -> None:
pass
# Dream is an internal job — run directly, not through the agent loop.
if job.name == "dream":
from nanobot.agent.memory import DreamRunProgress, MemoryStore
dream_session_key = MemoryStore.dream_session_key
prune_dream_sessions = MemoryStore.prune_dream_sessions
store = agent.context.memory
progress = DreamRunProgress()
resp = None
diff_body = ""
try:
result = store.build_dream_prompt()
if result is None:
logger.info("Dream: nothing to process")
return None
prompt, last_cursor = result
key = dream_session_key()
dream_runtime = agent.dream_runtime()
resp = await agent.process_direct(
prompt,
session_key=key,
ephemeral=True,
tools=store.build_dream_tools(),
on_progress=progress,
runtime=dream_runtime,
)
# The real file delta grounds the audit record; clean completion
# decides whether this history batch has finished processing.
diff_body = store.dream_content_diff()
completed = MemoryStore.dream_run_completed(
resp,
had_tool_errors=progress.had_tool_errors,
)
if completed:
store.set_last_dream_cursor(last_cursor)
if diff_body:
logger.info(
"Dream cron job completed, cursor advanced to {}",
last_cursor,
)
else:
logger.info(
"Dream cron job completed with no memory changes; "
"cursor advanced to {}",
last_cursor,
)
else:
logger.warning(
"Dream cron job did not complete; cursor remains at {}",
store.get_last_dream_cursor(),
)
except Exception:
logger.exception("Dream cron job failed")
finally:
from nanobot.webui.token_usage import record_response_token_usage
record_response_token_usage(
resp,
source="dream",
timezone_name=config.agents.defaults.timezone,
)
sha = _commit_dream_changes(store)
if sha:
logger.info("Dream commit: {}", sha)
store.compact_history()
prune_dream_sessions(agent.sessions.sessions_dir)
return None
# Heartbeat is a system job that checks HEARTBEAT.md for active tasks.
if job.name == "heartbeat":
heartbeat_file = config.workspace_path / "HEARTBEAT.md"
try:
content = heartbeat_file.read_text(encoding="utf-8")
except OSError:
logger.debug("Heartbeat: HEARTBEAT.md missing")
return None
if not _heartbeat_has_active_tasks(content):
logger.debug("Heartbeat: HEARTBEAT.md has no active tasks")
return None
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return None
prompt = (
_HEARTBEAT_PREAMBLE
+ f"You are executing periodic heartbeat tasks. Read the active tasks below, perform each one, and report what you did:\n\n{content}"
)
# Internal check: funnel all output through the post-run gate so the
# turn can't deliver directly via the message tool and skip it.
suppress_token = None
if isinstance(message_tool, MessageTool):
suppress_token = message_tool.set_suppress_delivery(True)
try:
resp = await agent.process_direct(
prompt,
session_key="heartbeat",
channel=channel,
chat_id=chat_id,
on_progress=_silent,
)
finally:
if isinstance(message_tool, MessageTool) and suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token)
# Keep a small tail of heartbeat history so the loop stays bounded.
session = agent.sessions.get_or_create("heartbeat")
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
agent.sessions.save(session)
if not resp or not resp.content:
return
response = resp.content
evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
# Fail closed: stay silent on evaluator failure instead of notifying.
should_notify = await evaluate_response(
response=response,
task_context=prompt,
provider=agent.provider,
model=agent.model,
evaluator_prompt=evaluator_prompt,
default_notify=False,
)
if should_notify:
logger.info("Heartbeat: completed, delivering response")
await _deliver_to_channel(
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
record=True,
)
else:
logger.info("Heartbeat: silenced by post-run evaluation")
return response
if is_bound_cron_job(job):
return await run_bound_cron_job(job, agent=agent, cron=cron)
reason = "unbound agent cron job must be recreated from a chat session"
logger.warning(
"Cron: skipped unbound agent job '{}' ({}): {}",
job.name,
job.id,
reason,
)
raise CronJobSkippedError(reason)
cron.on_job = on_cron_job
def _webui_runtime_model_name() -> str | None:
return agent.model.strip() or None
def _webui_skill_state_action(disabled_skills: set[str]) -> None:
config.agents.defaults.disabled_skills = sorted(disabled_skills)
agent.context.skills.disabled_skills = set(disabled_skills)
agent.subagents.disabled_skills = set(disabled_skills)
# Create channel manager (forwards SessionManager so the WebSocket channel
# can serve the embedded webui's REST surface).
channels = ChannelManager(
config,
bus,
session_manager=session_manager,
cron_service=cron,
local_trigger_store=trigger_store,
webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
webui_skill_state_action=_webui_skill_state_action,
)
def _pick_heartbeat_target() -> tuple[str, str]:
"""Pick a routable channel/chat target for heartbeat-triggered messages."""
sidebar_state = read_webui_sidebar_state()
unified_metadata = None
if config.agents.defaults.unified_session:
record = session_manager.read_session_metadata(UNIFIED_SESSION_KEY)
if isinstance(record, dict) and isinstance(record.get("metadata"), dict):
unified_metadata = record["metadata"]
return _pick_heartbeat_target_from_sessions(
enabled_channels=channels.enabled_channels,
sessions=session_manager.list_sessions(),
archived_keys=sidebar_state.get("archived_keys", []),
unified_session_metadata=unified_metadata,
)
if channels.enabled_channels:
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
else:
console.print("[yellow]Warning: No channels enabled[/yellow]")
cron_status = cron.status()
cron_job_count = cast(int, cron_status["jobs"])
if cron_job_count > 0:
console.print(f"[green]✓[/green] Cron: {cron_job_count} scheduled jobs")
hb_cfg = config.gateway.heartbeat
if hb_cfg.enabled:
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
else:
console.print("[yellow]✗[/yellow] Heartbeat: disabled")
async def _health_server(host: str, health_port: int) -> None:
"""Lightweight HTTP health endpoint on the gateway port."""
import json as _json
connection_slots = asyncio.Semaphore(_GATEWAY_HEALTH_MAX_CONNECTIONS)
async def handle(
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
if connection_slots.locked():
writer.close()
return
async with connection_slots:
try:
data = await asyncio.wait_for(
reader.read(4096),
timeout=_GATEWAY_HEALTH_READ_TIMEOUT_SECONDS,
)
request_line = data.split(b"\r\n", 1)[0].decode(
"utf-8", errors="replace",
)
method, path = "", ""
parts = request_line.split(" ")
if len(parts) >= 2:
method, path = parts[0], parts[1]
if method == "GET" and path == "/health":
body = _json.dumps({"status": "ok"})
status = "200 OK"
content_type = "application/json"
else:
body = "Not Found"
status = "404 Not Found"
content_type = "text/plain"
resp = (
f"HTTP/1.0 {status}\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n"
f"\r\n{body}"
)
writer.write(resp.encode())
await writer.drain()
except (asyncio.TimeoutError, ConnectionError):
pass
finally:
writer.close()
server = await asyncio.start_server(handle, host, health_port)
_print_gateway_health_endpoint(host, health_port)
async with server:
await server.serve_forever()
# Register Dream system job (idempotent on restart)
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
dream_cfg = config.agents.defaults.dream
if dream_cfg.enabled:
cron.register_system_job(CronJob(
id="dream",
name="dream",
schedule=dream_cfg.build_schedule(config.agents.defaults.timezone),
payload=CronPayload(kind="system_event"),
))
console.print(f"[green]✓[/green] Dream: {dream_cfg.describe_schedule()}")
else:
console.print("[yellow]○[/yellow] Dream: disabled")
_advance_dream_cursor_if_behind(agent.context.memory)
# Register Heartbeat system job (idempotent on restart)
if hb_cfg.enabled:
cron.register_system_job(CronJob(
id="heartbeat",
name="heartbeat",
schedule=CronSchedule(
kind="every",
every_ms=hb_cfg.interval_s * 1000,
tz=config.agents.defaults.timezone,
),
payload=CronPayload(kind="system_event"),
))
async def _open_browser_when_ready() -> None:
"""Wait for the gateway to bind, then point the user's browser at the webui."""
if not open_browser_url:
return
import webbrowser
from urllib.parse import urlparse
parsed = urlparse(open_browser_url)
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
target_port = parsed.port or port
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
_reader, writer = await asyncio.open_connection(
target_host,
target_port,
)
writer.close()
with suppress(Exception):
await writer.wait_closed()
break
except OSError:
await asyncio.sleep(0.1)
try:
webbrowser.open(open_browser_url)
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
async def run() -> None:
tasks: list[asyncio.Task[Any]] = []
shutdown_task: asyncio.Task[Any] | None = None
runtime_tasks: asyncio.Future[list[Any]] | None = None
runtime_tasks_drained = False
shutdown_event = asyncio.Event()
cli_terminal._ensure_interactive_tty_mode()
restore_shutdown_handlers = _install_gateway_shutdown_handlers(
asyncio.get_running_loop(),
shutdown_event,
tasks,
console.print,
)
try:
await cron.start()
# Re-read once on first admission to close the watcher subscription window.
agent.runtime_resolver.invalidate()
tasks = [
asyncio.create_task(
watch_config_file(
Path(config_path),
lambda: agent.invalidate_runtime_config(),
),
name="nanobot-config-watcher",
),
asyncio.create_task(agent.run(), name="nanobot-agent-loop"),
asyncio.create_task(channels.start_all(), name="nanobot-channels"),
asyncio.create_task(
run_local_trigger_queue(
store=trigger_store,
submit_turn=agent.submit_local_trigger_turn,
is_channel_enabled=lambda name: channels.get_channel(name) is not None,
),
name="nanobot-local-triggers",
),
]
if health_server_enabled:
tasks.append(asyncio.create_task(
_health_server(config.gateway.host, port),
name="nanobot-health-server",
))
if open_browser_url:
tasks.append(asyncio.create_task(
_open_browser_when_ready(),
name="nanobot-open-browser",
))
runtime_tasks = asyncio.gather(*tasks)
shutdown_task = asyncio.create_task(
shutdown_event.wait(),
name="nanobot-gateway-shutdown",
)
done, _pending = await asyncio.wait(
{runtime_tasks, shutdown_task},
return_when=asyncio.FIRST_COMPLETED,
)
if runtime_tasks in done:
runtime_tasks_drained = True
await runtime_tasks
else:
runtime_tasks.cancel()
except KeyboardInterrupt:
console.print("\nShutting down...")
except Exception:
import traceback
console.print("\n[red]Error: Gateway crashed unexpectedly[/red]")
console.print(traceback.format_exc())
finally:
try:
if shutdown_task and not shutdown_task.done():
shutdown_task.cancel()
with suppress(asyncio.CancelledError):
await shutdown_task
cron.stop()
agent.stop()
# Some SDKs swallow task cancellation while attempting to reconnect.
# Close channel transports before waiting for their runners to exit.
await channels.stop_all()
for task in tasks:
if not task.done():
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
if runtime_tasks is not None and not runtime_tasks_drained:
with suppress(asyncio.CancelledError, Exception):
await runtime_tasks
# Flush all cached sessions to durable storage before exit.
# This prevents data loss on filesystems with write-back
# caching (rclone VFS, NFS, FUSE mounts, etc.).
flushed = agent.sessions.flush_all()
if flushed:
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
finally:
restore_shutdown_handlers()
asyncio.run(run())
+12
View File
@@ -0,0 +1,12 @@
"""Runtime log visibility controls shared by CLI commands."""
from loguru import logger
__all__ = ["_set_nanobot_logs"]
def _set_nanobot_logs(enabled: bool) -> None:
if enabled:
logger.enable("nanobot")
else:
logger.disable("nanobot")
+116 -69
View File
@@ -1,19 +1,27 @@
"""Interactive onboarding questionnaire for nanobot.""" """Interactive onboarding questionnaire for nanobot."""
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
import asyncio import asyncio
import json import json
import types import types
from collections.abc import Callable, Iterable, Sized
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from typing import Any, Literal, NamedTuple, get_args, get_origin from typing import Any, Literal, NamedTuple, TypeVar, cast, get_args, get_origin
try: try:
import questionary import questionary
except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps except ModuleNotFoundError: # pragma: no cover - exercised in environments without wizard deps
questionary = None questionary = None
from loguru import logger from loguru import logger
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
from prompt_toolkit.document import Document
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from pydantic import BaseModel from pydantic import BaseModel
from pydantic.fields import FieldInfo
from rich.console import Console from rich.console import Console
from rich.markup import escape from rich.markup import escape
from rich.panel import Panel from rich.panel import Panel
@@ -29,6 +37,8 @@ from nanobot.config.schema import Config, ModelPresetConfig
console = Console() console = Console()
_ModelT = TypeVar("_ModelT", bound=BaseModel)
@dataclass @dataclass
class OnboardResult: class OnboardResult:
@@ -119,14 +129,14 @@ _CHANNEL_LOGIN_CHOICE = "Login with QR/link"
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings" _CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
def _get_questionary(): def _get_questionary() -> Any:
"""Return questionary or raise a clear error when wizard deps are unavailable.""" """Return questionary or raise a clear error when wizard deps are unavailable."""
if questionary is None: if questionary is None:
raise RuntimeError( raise RuntimeError(
"Interactive onboarding requires the optional 'questionary' dependency. " "Interactive onboarding requires the optional 'questionary' dependency. "
"Install project dependencies and rerun with --wizard." "Install project dependencies and rerun with --wizard."
) )
return questionary return cast(Any, questionary)
def _select_with_back( def _select_with_back(
@@ -147,7 +157,6 @@ def _select_with_back(
import shutil import shutil
from prompt_toolkit.application import Application from prompt_toolkit.application import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.keys import Keys from prompt_toolkit.keys import Keys
from prompt_toolkit.layout import Layout from prompt_toolkit.layout import Layout
from prompt_toolkit.layout.containers import HSplit, Window from prompt_toolkit.layout.containers import HSplit, Window
@@ -170,8 +179,8 @@ def _select_with_back(
visible_count = min(len(choices), max(1, terminal_lines - 3)) visible_count = min(len(choices), max(1, terminal_lines - 3))
# Build menu items (uses closure over selected_index) # Build menu items (uses closure over selected_index)
def get_menu_text(): def get_menu_text() -> list[tuple[str, str]]:
items = [] items: list[tuple[str, str]] = []
start, end = _choice_viewport(selected_index, len(choices), visible_count) start, end = _choice_viewport(selected_index, len(choices), visible_count)
for i in range(start, end): for i in range(start, end):
choice = choices[i] choice = choices[i]
@@ -182,14 +191,14 @@ def _select_with_back(
return items return items
# Create layout # Create layout
menu_control = FormattedTextControl(get_menu_text, show_cursor=False) menu_control = FormattedTextControl(cast(Any, get_menu_text), show_cursor=False)
menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True) menu_window = Window(content=menu_control, height=visible_count, always_hide_cursor=True)
def get_prompt_text(): def get_prompt_text() -> list[tuple[str, str]]:
suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else "" suffix = f" ({selected_index + 1}/{len(choices)})" if len(choices) > visible_count else ""
return [("class:question", f"{prompt}{suffix}")] return [("class:question", f"{prompt}{suffix}")]
prompt_control = FormattedTextControl(get_prompt_text, show_cursor=False) prompt_control = FormattedTextControl(cast(Any, get_prompt_text), show_cursor=False)
prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True) prompt_window = Window(content=prompt_control, height=1, always_hide_cursor=True)
layout = Layout(HSplit([prompt_window, menu_window])) layout = Layout(HSplit([prompt_window, menu_window]))
@@ -198,34 +207,34 @@ def _select_with_back(
bindings = KeyBindings() bindings = KeyBindings()
@bindings.add(Keys.Up) @bindings.add(Keys.Up)
def _up(event): def _up(event: KeyPressEvent) -> None:
nonlocal selected_index nonlocal selected_index
selected_index = (selected_index - 1) % len(choices) selected_index = (selected_index - 1) % len(choices)
event.app.invalidate() event.app.invalidate()
@bindings.add(Keys.Down) @bindings.add(Keys.Down)
def _down(event): def _down(event: KeyPressEvent) -> None:
nonlocal selected_index nonlocal selected_index
selected_index = (selected_index + 1) % len(choices) selected_index = (selected_index + 1) % len(choices)
event.app.invalidate() event.app.invalidate()
@bindings.add(Keys.Enter) @bindings.add(Keys.Enter)
def _enter(event): def _enter(event: KeyPressEvent) -> None:
state["result"] = choices[selected_index] state["result"] = choices[selected_index]
event.app.exit() event.app.exit()
@bindings.add("escape") @bindings.add("escape")
def _escape(event): def _escape(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED state["result"] = _BACK_PRESSED
event.app.exit() event.app.exit()
@bindings.add(Keys.Left) @bindings.add(Keys.Left)
def _left(event): def _left(event: KeyPressEvent) -> None:
state["result"] = _BACK_PRESSED state["result"] = _BACK_PRESSED
event.app.exit() event.app.exit()
@bindings.add(Keys.ControlC) @bindings.add(Keys.ControlC)
def _ctrl_c(event): def _ctrl_c(event: KeyPressEvent) -> None:
state["result"] = None state["result"] = None
event.app.exit() event.app.exit()
@@ -235,7 +244,7 @@ def _select_with_back(
"question": f"fg:{_UI_TEXT}", "question": f"fg:{_UI_TEXT}",
}) })
app = Application(layout=layout, key_bindings=bindings, style=style) app = Application[object](layout=layout, key_bindings=bindings, style=style)
app.ttimeoutlen = 0.05 app.ttimeoutlen = 0.05
app.timeoutlen = 0.05 app.timeoutlen = 0.05
try: try:
@@ -268,7 +277,7 @@ class FieldTypeInfo(NamedTuple):
inner_type: Any inner_type: Any
def _get_field_type_info(field_info) -> FieldTypeInfo: def _get_field_type_info(field_info: FieldInfo) -> FieldTypeInfo:
"""Extract field type info from Pydantic field.""" """Extract field type info from Pydantic field."""
annotation = field_info.annotation annotation = field_info.annotation
if annotation is None: if annotation is None:
@@ -285,10 +294,11 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
args = get_args(annotation) args = get_args(annotation)
_simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"} _simple_types: dict[type, str] = {bool: "bool", int: "int", float: "float"}
origin_name = getattr(origin, "__name__", None)
if origin is list or (hasattr(origin, "__name__") and origin.__name__ == "List"): if origin is list or origin_name == "List":
return FieldTypeInfo("list", args[0] if args else str) return FieldTypeInfo("list", args[0] if args else str)
if origin is dict or (hasattr(origin, "__name__") and origin.__name__ == "Dict"): if origin is dict or origin_name == "Dict":
return FieldTypeInfo("dict", None) return FieldTypeInfo("dict", None)
for py_type, name in _simple_types.items(): for py_type, name in _simple_types.items():
if annotation is py_type: if annotation is py_type:
@@ -300,7 +310,7 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
return FieldTypeInfo("str", None) return FieldTypeInfo("str", None)
def _get_field_display_name(field_key: str, field_info) -> str: def _get_field_display_name(field_key: str, field_info: FieldInfo | None) -> str:
"""Get display name for a field.""" """Get display name for a field."""
if field_info and field_info.description: if field_info and field_info.description:
return field_info.description return field_info.description
@@ -349,22 +359,30 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
masked = _mask_value(value) masked = _mask_value(value)
return f"[dim]{masked}[/dim]" if rich else masked return f"[dim]{masked}[/dim]" if rich else masked
if isinstance(value, BaseModel): if isinstance(value, BaseModel):
parts = [] model_parts: list[str] = []
for fname, _finfo in type(value).model_fields.items(): for fname, _finfo in type(value).model_fields.items():
fval = getattr(value, fname, None) fval = getattr(value, fname, None)
formatted = _format_value(fval, rich=False, field_name=fname) formatted = _format_value(fval, rich=False, field_name=fname)
if formatted != "[not set]": if formatted != "[not set]":
parts.append(f"{fname}={formatted}") model_parts.append(f"{fname}={formatted}")
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]") return (
", ".join(model_parts)
if model_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
if isinstance(value, list): if isinstance(value, list):
return ", ".join(str(v) for v in value) return ", ".join(str(v) for v in cast(list[Any], value))
if isinstance(value, dict): if isinstance(value, dict):
# Handle dicts containing BaseModel instances # Handle dicts containing BaseModel instances
parts = [] mapping_parts: list[str] = []
for k, v in value.items(): for k, v in cast(dict[Any, Any], value).items():
formatted = _format_value(v, rich=False, field_name=str(k)) formatted = _format_value(v, rich=False, field_name=str(k))
parts.append(f"{k}: {formatted}") mapping_parts.append(f"{k}: {formatted}")
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]") return (
", ".join(mapping_parts)
if mapping_parts
else ("[dim]not set[/dim]" if rich else "[not set]")
)
return str(value) return str(value)
@@ -373,13 +391,13 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
if value is None or value == "": if value is None or value == "":
return "" return ""
if field_type == "list" and isinstance(value, list): if field_type == "list" and isinstance(value, list):
return ",".join(str(v) for v in value) return ",".join(str(v) for v in cast(list[Any], value))
if field_type == "dict" and isinstance(value, dict): if field_type == "dict" and isinstance(value, dict):
return json.dumps(value) return json.dumps(value)
return str(value) return str(value)
def _validate_field_constraint(value: Any, field_info) -> str | None: def _validate_field_constraint(value: Any, field_info: FieldInfo | None) -> str | None:
"""Validate a value against Pydantic Field constraints. """Validate a value against Pydantic Field constraints.
Returns an error message string if validation fails, None if valid. Returns an error message string if validation fails, None if valid.
@@ -388,7 +406,8 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
if field_info is None or not hasattr(field_info, "metadata"): if field_info is None or not hasattr(field_info, "metadata"):
return None return None
for m in field_info.metadata: for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge") and isinstance(value, (int, float)): if hasattr(m, "ge") and isinstance(value, (int, float)):
if value < m.ge: if value < m.ge:
return f"Value must be >= {m.ge}" return f"Value must be >= {m.ge}"
@@ -402,16 +421,16 @@ def _validate_field_constraint(value: Any, field_info) -> str | None:
if value >= m.lt: if value >= m.lt:
return f"Value must be < {m.lt}" return f"Value must be < {m.lt}"
if hasattr(m, "min_length") and hasattr(value, "__len__"): if hasattr(m, "min_length") and hasattr(value, "__len__"):
if len(value) < m.min_length: if len(cast(Sized, value)) < m.min_length:
return f"Length must be >= {m.min_length}" return f"Length must be >= {m.min_length}"
if hasattr(m, "max_length") and hasattr(value, "__len__"): if hasattr(m, "max_length") and hasattr(value, "__len__"):
if len(value) > m.max_length: if len(cast(Sized, value)) > m.max_length:
return f"Length must be <= {m.max_length}" return f"Length must be <= {m.max_length}"
return None return None
def _get_constraint_hint(field_info) -> str: def _get_constraint_hint(field_info: FieldInfo | None) -> str:
"""Derive a human-readable constraint hint from field metadata. """Derive a human-readable constraint hint from field metadata.
Returns a string like " - 0-10" or " - >= 0" to append to field display names. Returns a string like " - 0-10" or " - >= 0" to append to field display names.
@@ -421,7 +440,8 @@ def _get_constraint_hint(field_info) -> str:
ge_val = None ge_val = None
le_val = None le_val = None
for m in field_info.metadata: for metadata in field_info.metadata:
m = metadata
if hasattr(m, "ge"): if hasattr(m, "ge"):
ge_val = m.ge ge_val = m.ge
if hasattr(m, "le"): if hasattr(m, "le"):
@@ -439,7 +459,11 @@ def _get_constraint_hint(field_info) -> str:
# --- Rich UI Components --- # --- Rich UI Components ---
def _show_config_panel(display_name: str, model: BaseModel, fields: list) -> None: def _show_config_panel(
display_name: str,
model: BaseModel,
fields: list[tuple[str, FieldInfo]],
) -> None:
"""Display current configuration as a rich table.""" """Display current configuration as a rich table."""
table = Table(show_header=False, box=None, padding=(0, 2)) table = Table(show_header=False, box=None, padding=(0, 2))
table.add_column("Field", style=_UI_ACCENT) table.add_column("Field", style=_UI_ACCENT)
@@ -504,20 +528,18 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
).ask() ).ask()
def _input_back_key_bindings(): def _input_back_key_bindings() -> KeyBindings:
"""Return key bindings that make Escape behave like a local back action.""" """Return key bindings that make Escape behave like a local back action."""
from prompt_toolkit.key_binding import KeyBindings
bindings = KeyBindings() bindings = KeyBindings()
@bindings.add("escape") @bindings.add("escape")
def _escape(event): def _escape(event: KeyPressEvent) -> None:
event.app.exit(result=_BACK_PRESSED) event.app.exit(result=_BACK_PRESSED)
return bindings return bindings
def _ask_prompt(prompt): def _ask_prompt(prompt: Any) -> Any:
"""Ask a questionary prompt with responsive Escape handling.""" """Ask a questionary prompt with responsive Escape handling."""
app = getattr(prompt, "application", None) app = getattr(prompt, "application", None)
if app is not None: if app is not None:
@@ -528,7 +550,12 @@ def _ask_prompt(prompt):
return prompt.ask() return prompt.ask()
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any: def _input_text(
display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any:
"""Get text input and parse based on field type.""" """Get text input and parse based on field type."""
default = _format_value_for_input(current, field_type) default = _format_value_for_input(current, field_type)
@@ -591,7 +618,10 @@ def _input_secret(display_name: str) -> str | None | object:
def _input_with_existing( def _input_with_existing(
display_name: str, current: Any, field_type: str, field_info=None display_name: str,
current: Any,
field_type: str,
field_info: FieldInfo | None = None,
) -> Any: ) -> Any:
"""Handle input with 'keep existing' option for non-empty values.""" """Handle input with 'keep existing' option for non-empty values."""
has_existing = current is not None and current != "" and current != {} and current != [] has_existing = current is not None and current != "" and current != {} and current != []
@@ -624,8 +654,6 @@ def _input_model_with_autocomplete(
"""Get model input with autocomplete suggestions. """Get model input with autocomplete suggestions.
""" """
from prompt_toolkit.completion import Completer, Completion
default = str(current) if current else "" default = str(current) if current else ""
class DynamicModelCompleter(Completer): class DynamicModelCompleter(Completer):
@@ -634,7 +662,12 @@ def _input_model_with_autocomplete(
def __init__(self, provider_name: str): def __init__(self, provider_name: str):
self.provider = provider_name self.provider = provider_name
def get_completions(self, document, _complete_event): def get_completions(
self,
document: Document,
complete_event: CompleteEvent,
) -> Iterable[Completion]:
_ = complete_event
text = document.text_before_cursor text = document.text_before_cursor
suggestions = get_model_suggestions(text, provider=self.provider, limit=50) suggestions = get_model_suggestions(text, provider=self.provider, limit=50)
for model in suggestions: for model in suggestions:
@@ -735,7 +768,7 @@ def _handle_model_field(
return return
if new_value is not None and new_value != current_value: if new_value is not None and new_value != current_value:
setattr(working_model, field_name, new_value) setattr(working_model, field_name, new_value)
_try_auto_fill_context_window(working_model, new_value) _try_auto_fill_context_window(working_model, cast(str, new_value))
def _handle_context_window_field( def _handle_context_window_field(
@@ -794,7 +827,11 @@ def _handle_fallback_models_field(
"""Handle the 'fallback_models' field with preset-aware list management.""" """Handle the 'fallback_models' field with preset-aware list management."""
from nanobot.config.schema import InlineFallbackConfig from nanobot.config.schema import InlineFallbackConfig
items: list[Any] = list(current_value) if isinstance(current_value, list) else [] items: list[Any] = (
list(cast(list[Any], current_value))
if isinstance(current_value, list)
else []
)
preset_names = sorted(_MODEL_PRESET_CACHE) preset_names = sorted(_MODEL_PRESET_CACHE)
while True: while True:
@@ -888,11 +925,11 @@ def _is_str_or_none(annotation: Any) -> bool:
def _configure_pydantic_model( def _configure_pydantic_model(
model: BaseModel, model: _ModelT,
display_name: str, display_name: str,
*, *,
skip_fields: set[str] | None = None, skip_fields: set[str] | None = None,
) -> BaseModel | None: ) -> _ModelT | None:
"""Configure a Pydantic model interactively. """Configure a Pydantic model interactively.
Returns the updated model when the user selects "Done" or navigates back. Returns the updated model when the user selects "Done" or navigates back.
@@ -901,7 +938,7 @@ def _configure_pydantic_model(
skip_fields = skip_fields or set() skip_fields = skip_fields or set()
working_model = model.model_copy(deep=True) working_model = model.model_copy(deep=True)
fields = [ fields: list[tuple[str, FieldInfo]] = [
(name, info) (name, info)
for name, info in type(working_model).model_fields.items() for name, info in type(working_model).model_fields.items()
if name not in skip_fields if name not in skip_fields
@@ -911,7 +948,7 @@ def _configure_pydantic_model(
return working_model return working_model
def get_choices() -> list[str]: def get_choices() -> list[str]:
items = [] items: list[str] = []
for fname, finfo in fields: for fname, finfo in fields:
value = getattr(working_model, fname, None) value = getattr(working_model, fname, None)
display = _get_field_display_name(fname, finfo) display = _get_field_display_name(fname, finfo)
@@ -1057,6 +1094,10 @@ def _sync_preset_cache(config: Config) -> None:
_MODEL_PRESET_CACHE.update(config.model_presets.keys()) _MODEL_PRESET_CACHE.update(config.model_presets.keys())
def _validate_nonempty_name(text: str) -> bool | str:
return True if text and text.strip() else "Name cannot be empty"
def _configure_model_presets(config: Config) -> None: def _configure_model_presets(config: Config) -> None:
"""Configure model presets (CRUD).""" """Configure model presets (CRUD)."""
_sync_preset_cache(config) _sync_preset_cache(config)
@@ -1099,7 +1140,7 @@ def _configure_model_presets(config: Config) -> None:
if answer == "[+] Add new preset": if answer == "[+] Add new preset":
name_input = _get_questionary().text( name_input = _get_questionary().text(
"Preset name:", "Preset name:",
validate=lambda t: True if t and t.strip() else "Name cannot be empty", validate=_validate_nonempty_name,
).ask() ).ask()
if not name_input: if not name_input:
continue continue
@@ -1218,7 +1259,7 @@ def _configure_providers(config: Config) -> None:
def get_provider_choices() -> list[str]: def get_provider_choices() -> list[str]:
"""Build provider choices with config status indicators.""" """Build provider choices with config status indicators."""
choices = [] choices: list[str] = []
for name, display in _get_provider_names().items(): for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None) provider = getattr(config.providers, name, None)
if provider and provider.api_key: if provider and provider.api_key:
@@ -1427,7 +1468,7 @@ _SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}), "Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
} }
_SETTINGS_GETTER = { _SETTINGS_GETTER: dict[str, Callable[[Config], BaseModel]] = {
"Agent Settings": lambda c: c.agents.defaults, "Agent Settings": lambda c: c.agents.defaults,
"Channel Common": lambda c: c.channels, "Channel Common": lambda c: c.channels,
"API Server": lambda c: c.api, "API Server": lambda c: c.api,
@@ -1435,7 +1476,7 @@ _SETTINGS_GETTER = {
"Tools": lambda c: c.tools, "Tools": lambda c: c.tools,
} }
_SETTINGS_SETTER = { _SETTINGS_SETTER: dict[str, Callable[[Config, BaseModel], None]] = {
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v), "Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
"Channel Common": lambda c, v: setattr(c, "channels", v), "Channel Common": lambda c, v: setattr(c, "channels", v),
"API Server": lambda c, v: setattr(c, "api", v), "API Server": lambda c, v: setattr(c, "api", v),
@@ -1449,7 +1490,7 @@ def _configure_general_settings(config: Config, section: str) -> None:
meta = _SETTINGS_SECTIONS.get(section) meta = _SETTINGS_SECTIONS.get(section)
if not meta: if not meta:
return return
display_name, subtitle, skip = meta display_name, _subtitle, skip = meta
model = _SETTINGS_GETTER[section](config) model = _SETTINGS_GETTER[section](config)
updated = _configure_pydantic_model(model, display_name, skip_fields=skip) updated = _configure_pydantic_model(model, display_name, skip_fields=skip)
if updated is not None: if updated is not None:
@@ -1495,7 +1536,7 @@ def _show_summary(config: Config) -> None:
console.print() console.print()
# Providers # Providers
provider_rows = [] provider_rows: list[tuple[str, str]] = []
for name, display in _get_provider_names().items(): for name, display in _get_provider_names().items():
provider = getattr(config.providers, name, None) provider = getattr(config.providers, name, None)
status = ( status = (
@@ -1507,12 +1548,12 @@ def _show_summary(config: Config) -> None:
_print_summary_panel(provider_rows, "LLM Providers") _print_summary_panel(provider_rows, "LLM Providers")
# Channels # Channels
channel_rows = [] channel_rows: list[tuple[str, str]] = []
for name, display in _get_channel_names().items(): for name, display in _get_channel_names().items():
channel = getattr(config.channels, name, None) channel = getattr(config.channels, name, None)
if channel: if channel:
enabled = ( enabled = (
channel.get("enabled", False) cast(dict[str, Any], channel).get("enabled", False)
if isinstance(channel, dict) if isinstance(channel, dict)
else getattr(channel, "enabled", False) else getattr(channel, "enabled", False)
) )
@@ -1523,7 +1564,7 @@ def _show_summary(config: Config) -> None:
_print_summary_panel(channel_rows, "Chat Channels") _print_summary_panel(channel_rows, "Chat Channels")
# Model Presets # Model Presets
preset_rows = [] preset_rows: list[tuple[str, str]] = []
for name, preset in config.model_presets.items(): for name, preset in config.model_presets.items():
preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}")) preset_rows.append((name, f"{preset.model} - ctx {preset.context_window_tokens}"))
_print_summary_panel(preset_rows, "Model Presets") _print_summary_panel(preset_rows, "Model Presets")
@@ -1562,7 +1603,7 @@ def _set_primary_quick_start_preset(config: Config, provider_name: str, model: s
def _show_quick_start_progress(active_step: int) -> None: def _show_quick_start_progress(active_step: int) -> None:
"""Render a compact step tracker for Quick Start.""" """Render a compact step tracker for Quick Start."""
parts = [] parts: list[str] = []
for idx, label in enumerate(_QUICK_START_STEPS, 1): for idx, label in enumerate(_QUICK_START_STEPS, 1):
if idx < active_step: if idx < active_step:
parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]") parts.append(f"[{_UI_SUCCESS}]{idx}. {label}[/]")
@@ -1755,7 +1796,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
continue continue
if api_base_result is None: if api_base_result is None:
return False return False
api_base, base_was_prompted = api_base_result api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
api_key: str | None = None api_key: str | None = None
if _quick_start_requires_api_key(provider_name, provider_info): if _quick_start_requires_api_key(provider_name, provider_info):
@@ -1778,7 +1822,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
continue continue
if api_base_result is None: if api_base_result is None:
return False return False
api_base, base_was_prompted = api_base_result api_base, base_was_prompted = cast(
tuple[str, bool],
api_base_result,
)
provider_config = getattr(config.providers, provider_name, None) provider_config = getattr(config.providers, provider_name, None)
if provider_config is None: if provider_config is None:
@@ -1792,7 +1839,7 @@ def _configure_quick_start_provider(config: Config) -> bool | object:
) )
if model is _BACK_PRESSED: if model is _BACK_PRESSED:
continue continue
model = (model or "").strip() model = cast(str, model or "").strip()
if not model: if not model:
console.print("[yellow]! Model ID is required for Quick Start[/yellow]") console.print("[yellow]! Model ID is required for Quick Start[/yellow]")
return False return False
@@ -1850,7 +1897,7 @@ def _enable_quick_start_websocket_defaults(config: Config) -> bool:
console.print("[red]No configuration class found for websocket[/red]") console.print("[red]No configuration class found for websocket[/red]")
return False return False
current = getattr(config.channels, "websocket", None) or {} current: Any = getattr(config.channels, "websocket", None) or {}
model = config_cls.model_validate(current) model = config_cls.model_validate(current)
if hasattr(model, "enabled"): if hasattr(model, "enabled"):
setattr(model, "enabled", True) setattr(model, "enabled", True)
@@ -1997,7 +2044,7 @@ def _configure_advanced_settings(config: Config) -> None:
if answer is _BACK_PRESSED or answer is None or answer == "<- Back": if answer is _BACK_PRESSED or answer is None or answer == "<- Back":
break break
_advanced_dispatch = { _advanced_dispatch: dict[str, Callable[[], None]] = {
"[P] LLM Provider": lambda: _configure_providers(config), "[P] LLM Provider": lambda: _configure_providers(config),
"[M] Model Presets": lambda: _configure_model_presets(config), "[M] Model Presets": lambda: _configure_model_presets(config),
"[C] Chat Channel": lambda: _configure_channels(config), "[C] Chat Channel": lambda: _configure_channels(config),
@@ -2008,9 +2055,9 @@ def _configure_advanced_settings(config: Config) -> None:
"[T] Tools": lambda: _configure_general_settings(config, "Tools"), "[T] Tools": lambda: _configure_general_settings(config, "Tools"),
"[V] View Configuration Summary": lambda: _show_summary(config), "[V] View Configuration Summary": lambda: _show_summary(config),
} }
action_fn = _advanced_dispatch.get(answer) action_fn = _advanced_dispatch.get(cast(str, answer))
if action_fn: if action_fn:
last_choice = answer last_choice = cast(str, answer)
action_fn() action_fn()
+372
View File
@@ -0,0 +1,372 @@
"""Typer commands for OAuth provider authentication."""
from __future__ import annotations
from collections.abc import Callable
from contextlib import suppress
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, cast
import typer
from rich.console import Console
from nanobot import __logo__
if TYPE_CHECKING:
from nanobot.providers.registry import ProviderSpec
console = Console()
provider_app = typer.Typer(help="Manage providers")
_PROVIDER_DISPLAY: dict[str, str] = {
"openai_codex": "OpenAI Codex",
"xai_grok": "xAI Grok",
"github_copilot": "GitHub Copilot",
}
_OAUTH_PROVIDER_DEFAULT_MODELS: dict[str, str] = {
"openai_codex": "openai-codex/gpt-5.6-sol",
"xai_grok": "xai-grok/grok-4.5",
"github_copilot": "github-copilot/gpt-5.4-mini",
}
class _OAuthToken(Protocol):
access: str | None
account_id: str | None
class _GetOAuthToken(Protocol):
def __call__(self, *, proxy: str | None = None) -> _OAuthToken | None: ...
class _LoginOAuthInteractive(Protocol):
def __call__(
self,
*,
print_fn: Callable[[str], None],
prompt_fn: Callable[[str], str],
proxy: str | None = None,
) -> _OAuthToken | None: ...
class _OAuthProviderConfig(Protocol):
token_filename: str
class _TokenStorage(Protocol):
def get_token_path(self) -> Path: ...
class _FileTokenStorageFactory(Protocol):
def __call__(self, *, token_filename: str) -> _TokenStorage: ...
def _required_module_attribute(module_name: str, attribute: str) -> object:
"""Load an optional dependency attribute with import-compatible errors."""
module = import_module(module_name)
try:
return getattr(module, attribute)
except AttributeError as exc:
raise ImportError(f"{module_name}.{attribute} is unavailable") from exc
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
"""Load the optional untyped OAuth client behind a typed boundary."""
return (
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
cast(
_LoginOAuthInteractive,
_required_module_attribute("oauth_cli_kit", "login_oauth_interactive"),
),
)
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
"""Load the optional untyped OAuth storage API behind a typed boundary."""
return (
cast(
_OAuthProviderConfig,
_required_module_attribute(
"oauth_cli_kit.providers",
"OPENAI_CODEX_PROVIDER",
),
),
cast(
_FileTokenStorageFactory,
_required_module_attribute("oauth_cli_kit.storage", "FileTokenStorage"),
),
)
def _resolve_oauth_provider(provider: str) -> ProviderSpec:
"""Resolve and validate an OAuth provider configuration."""
from nanobot.providers.registry import PROVIDERS
key = provider.replace("-", "_")
spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None)
if not spec:
names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth)
console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}")
raise typer.Exit(1)
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 and get_config_path() != resolved_config_path:
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
if provider_name == "xai_grok" and selected_model == "xai-grok/grok-4.5":
config.agents.defaults.context_window_tokens = 500_000
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")
def provider_login(
provider: str = typer.Argument(
...,
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', '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."""
spec = _resolve_oauth_provider(provider)
handler = _LOGIN_HANDLERS.get(spec.name)
if not handler:
console.print(f"[red]Login not implemented for {spec.label}[/red]")
raise typer.Exit(1)
if config:
from nanobot.config.loader import set_config_path
resolved_config_path = Path(config).expanduser().resolve()
set_config_path(resolved_config_path)
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler()
if set_main or model:
_set_oauth_provider_as_main(spec.name, model=model, config_path=config)
@provider_app.command("logout")
def provider_logout(
provider: str = typer.Argument(
...,
help="OAuth provider (e.g. 'openai-codex', 'xai-grok', 'github-copilot')",
),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Log out from an OAuth provider."""
spec = _resolve_oauth_provider(provider)
handler = _LOGOUT_HANDLERS.get(spec.name)
if not handler:
console.print(f"[red]Logout not implemented for {spec.label}[/red]")
raise typer.Exit(1)
if config:
from nanobot.config.loader import set_config_path
resolved_config_path = Path(config).expanduser().resolve()
set_config_path(resolved_config_path)
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
console.print(f"{__logo__} OAuth Logout - {spec.label}\n")
handler()
def _login_openai_codex() -> None:
try:
from nanobot.config.loader import load_config, resolve_config_env_vars
get_token, login_oauth_interactive = _load_openai_oauth_client()
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
with suppress(Exception):
token = get_token(proxy=proxy)
if not (token and token.access):
console.print("[cyan]Starting interactive OAuth login...[/cyan]\n")
token = login_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
proxy=proxy,
)
if not (token and token.access):
console.print("[red]✗ Authentication failed[/red]")
raise typer.Exit(1)
console.print(
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
)
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
def _logout_openai_codex() -> None:
"""Clear local OAuth credentials for OpenAI Codex."""
try:
provider_config, storage_factory = _load_openai_oauth_storage()
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
storage = storage_factory(token_filename=provider_config.token_filename)
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
def _login_xai_grok() -> None:
"""Authenticate with xAI using the Grok subscription OAuth contract."""
from nanobot.config.loader import load_config, resolve_config_env_vars
from nanobot.providers.xai_oauth import get_xai_oauth_token, login_xai_oauth
try:
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1) from exc
token = None
with suppress(Exception):
token = get_xai_oauth_token(proxy=proxy)
if not (token and token.access):
console.print(
"[cyan]Starting xAI browser sign-in for your X Premium / Grok subscription...[/cyan]\n"
)
try:
token = login_xai_oauth(
print_fn=lambda message: console.print(message),
prompt_fn=lambda prompt: typer.prompt(prompt),
proxy=proxy,
)
except Exception as exc:
console.print(f"[red]Authentication error: {exc}[/red]")
raise typer.Exit(1) from exc
account = token.account_id or "xAI account"
console.print(f"[green]✓ Authenticated with xAI[/green] [dim]{account}[/dim]")
console.print(
"[dim]Hosted X Search is enabled automatically when the selected model supports it.[/dim]"
)
def _logout_xai_grok() -> None:
"""Clear local xAI OAuth credentials for this nanobot instance."""
from nanobot.providers.xai_oauth import get_xai_oauth_storage_path, logout_xai_oauth
token_path = get_xai_oauth_storage_path()
provider_label = _PROVIDER_DISPLAY["xai_grok"]
if logout_xai_oauth():
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
console.print(f"[dim]Removed: {token_path}[/dim]")
else:
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
def _logout_github_copilot() -> None:
"""Clear local OAuth credentials for GitHub Copilot."""
try:
from nanobot.providers.github_copilot_provider import get_storage
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
raise typer.Exit(1)
storage = get_storage()
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
"""Delete OAuth token and lock files, reporting the result."""
removed_paths: list[Path] = []
skipped: list[tuple[Path, OSError]] = []
for path in (token_path, token_path.with_suffix(".lock")):
try:
path.unlink()
except FileNotFoundError:
continue
except OSError as exc:
skipped.append((path, exc))
continue
removed_paths.append(path)
if not removed_paths and not skipped:
console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
return
if removed_paths:
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
for path in removed_paths:
console.print(f"[dim]Removed: {path}[/dim]")
for path, exc in skipped:
console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]")
def _login_github_copilot() -> None:
try:
from nanobot.providers.github_copilot_provider import login_github_copilot
console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n")
token = login_github_copilot(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
)
account = token.account_id or "GitHub"
console.print(
f"[green]✓ Authenticated with GitHub Copilot[/green] [dim]{account}[/dim]"
)
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {
"openai_codex": _login_openai_codex,
"xai_grok": _login_xai_grok,
"github_copilot": _login_github_copilot,
}
_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {
"openai_codex": _logout_openai_codex,
"xai_grok": _logout_xai_grok,
"github_copilot": _logout_github_copilot,
}
+185
View File
@@ -0,0 +1,185 @@
"""Configuration loading and diagnostics shared by CLI commands."""
from pathlib import Path
import typer
from pydantic import ValidationError
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from nanobot.config.schema import Config
__all__ = [
"_load_config_for_cli",
"_load_inspection_config",
"_load_runtime_config",
"_migrate_cron_store",
"_model_display",
"_print_agent_start_error",
"_print_config_error",
"_print_model_setup_steps",
"_print_runtime_config_validation_error",
"_provider_setup_error",
]
console = Console()
def _model_display(config: Config) -> tuple[str, str]:
"""Return (resolved_model_name, preset_tag) for display strings."""
resolved = config.resolve_preset()
name = config.agents.defaults.model_preset
tag = f" (preset: {name})" if name else ""
return resolved.model, tag
def _print_config_error(error: Exception) -> None:
"""Render a configuration failure without exposing traceback internals."""
from nanobot.config.errors import ConfigLoadError
console.print(Text(str(error), style="red"))
if isinstance(error, ConfigLoadError):
command = _status_command(error.path)
console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
def _print_runtime_config_validation_error(
error: ValidationError,
*,
config_path: Path,
summary: str,
path_prefix: tuple[str | int, ...],
retry_command: str,
) -> None:
"""Render a runtime-owned Pydantic config error without exposing input values."""
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
issues = tuple(
ConfigIssue(
path=(*path_prefix, *issue.path),
message=issue.message,
)
for issue in validation_issues(error)
)
diagnostic = ConfigLoadError(
config_path,
kind="invalid_schema",
summary=summary,
issues=issues,
)
console.print(Text(str(diagnostic), style="red"))
console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
def _status_command(config_path: Path) -> str:
return f'nanobot status --config "{config_path}"'
def _print_model_setup_steps(config_path: Path) -> None:
"""Show the shortest setup routes shared by Status and Agent startup."""
config_arg = f'--config "{config_path}"'
console.print(
f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
"then open Settings → Models"
)
console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
def _print_agent_start_error(error: ValueError) -> None:
from nanobot.config.loader import get_config_path
console.print(Text(f"Agent cannot start: {error}", style="red"))
console.print("Complete provider/model setup:")
_print_model_setup_steps(get_config_path())
def _load_config_for_cli(
config_path: Path | None = None,
*,
resolve_env: bool = False,
) -> Config:
"""Load CLI configuration and turn expected failures into a clean exit."""
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import load_config, resolve_config_env_vars
try:
loaded = load_config(config_path)
if resolve_env:
loaded = resolve_config_env_vars(loaded)
return loaded
except ConfigLoadError as exc:
_print_config_error(exc)
raise typer.Exit(1) from exc
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
"""Load config and optionally override the active workspace."""
from nanobot.config.loader import set_config_path
config_path = None
if config:
config_path = Path(config).expanduser().resolve()
if not config_path.exists():
console.print(f"[red]Error: Config file not found: {config_path}[/red]")
raise typer.Exit(1)
set_config_path(config_path)
console.print(f"[dim]Using config: {config_path}[/dim]")
loaded = _load_config_for_cli(config_path, resolve_env=True)
if workspace:
loaded.agents.defaults.workspace = workspace
return loaded
def _load_inspection_config(
config: str | None = None,
workspace: str | None = None,
) -> tuple[Path, Config]:
"""Load config for diagnostic commands without resolving secret env refs."""
from nanobot.config.errors import ConfigLoadError
from nanobot.config.loader import get_config_path, load_config, set_config_path
config_path = None
if config:
config_path = Path(config).expanduser().resolve(strict=False)
set_config_path(config_path)
console.print(f"[dim]Using config: {config_path}[/dim]")
display_path = config_path or get_config_path()
try:
loaded = load_config(config_path)
except ConfigLoadError as exc:
_print_config_error(exc)
raise typer.Exit(1) from exc
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
if workspace:
loaded.agents.defaults.workspace = workspace
return display_path, loaded
def _migrate_cron_store(config: "Config") -> None:
"""One-time migration: move legacy global cron store into the workspace."""
from nanobot.config.paths import get_cron_dir
legacy_path = get_cron_dir() / "jobs.json"
new_path = config.workspace_path / "cron" / "jobs.json"
if legacy_path.is_file() and not new_path.exists():
new_path.parent.mkdir(parents=True, exist_ok=True)
import shutil
shutil.move(str(legacy_path), str(new_path))
def _provider_setup_error(config: Config) -> str | None:
"""Return a local provider/model configuration error, or None."""
from nanobot.providers.factory import validate_provider_setup
try:
validate_provider_setup(config)
except ValueError as exc:
return str(exc)
return None
+4 -3
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import sys import sys
from contextlib import contextmanager, nullcontext from contextlib import contextmanager, nullcontext
from typing import Literal
from rich.console import Console from rich.console import Console
from rich.live import Live from rich.live import Live
@@ -51,12 +52,12 @@ class ThinkingSpinner:
self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots") self._spinner = c.status(f"[dim]{bot_name} is thinking...[/dim]", spinner="dots")
self._active = False self._active = False
def __enter__(self): def __enter__(self) -> ThinkingSpinner:
self._spinner.start() self._spinner.start()
self._active = True self._active = True
return self return self
def __exit__(self, *exc): def __exit__(self, *exc: object) -> Literal[False]:
self._active = False self._active = False
self._spinner.stop() self._spinner.stop()
_clear_current_line(self._console) _clear_current_line(self._console)
@@ -110,7 +111,7 @@ class StreamRenderer:
self._header_printed = False self._header_printed = False
self._start_spinner() self._start_spinner()
def _renderable(self): def _renderable(self) -> Markdown | Text:
"""Create a renderable from the current buffer.""" """Create a renderable from the current buffer."""
if self._md and self._buf: if self._md and self._buf:
return Markdown(self._buf) return Markdown(self._buf)
+428
View File
@@ -0,0 +1,428 @@
"""Terminal input and rendering helpers for the interactive CLI."""
from __future__ import annotations
import os
import select
import sys
from collections.abc import Callable
from contextlib import nullcontext, suppress
from typing import Any, Literal, cast
from loguru import logger
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import run_in_terminal
from prompt_toolkit.formatted_text import ANSI, HTML
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
from prompt_toolkit.keys import Keys
from prompt_toolkit.patch_stdout import patch_stdout
from rich.console import Console
from rich.markdown import Markdown
from rich.text import Text
from nanobot import __logo__
from nanobot.bus.outbound_events import (
ProgressEvent,
RetryWaitEvent,
outbound_event_from_message,
)
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner
from nanobot.utils.helpers import sanitize_surrogates as _sanitize_surrogates
__all__ = [
"_ReasoningBuffer",
"_ensure_interactive_tty_mode",
"_flush_cli_reasoning",
"_flush_pending_tty_input",
"_init_prompt_session",
"_is_exit_command",
"_maybe_print_interactive_progress",
"_print_agent_response",
"_print_cli_progress_line",
"_print_cli_reasoning",
"_print_interactive_response",
"_read_interactive_input_async",
"_restore_terminal",
]
console = Console()
EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"}
_REASONING_SENTENCE_ENDINGS = (".", "!", "?", "", "", "")
_REASONING_FLUSH_CHARS = 60
_prompt_session: PromptSession[str] | None = None
_saved_term_attrs: list[Any] | None = None
def _ensure_interactive_tty_mode() -> None:
"""Restore interactive line input after a raw-mode TTY leak."""
try:
fd = sys.stdin.fileno()
if not os.isatty(fd):
return
except Exception:
return
with suppress(Exception):
import termios
attrs = termios.tcgetattr(fd)
required_lflag = termios.ISIG | termios.ICANON | termios.ECHO
blocked_input_flags = getattr(termios, "IGNCR", 0) | getattr(termios, "INLCR", 0)
if (
(attrs[3] & required_lflag) == required_lflag
and attrs[0] & termios.ICRNL
and not attrs[0] & blocked_input_flags
):
return
attrs[0] = (attrs[0] | termios.ICRNL) & ~blocked_input_flags
attrs[3] |= required_lflag
termios.tcsetattr(fd, termios.TCSANOW, attrs)
termios.tcflush(fd, termios.TCIFLUSH)
logger.debug("Restored foreground gateway TTY mode")
class SafeFileHistory(FileHistory):
"""FileHistory subclass that sanitizes surrogate characters on write.
On Windows, special Unicode input (emoji, mixed-script) can produce
surrogate characters that crash prompt_toolkit's file write.
See issue #2846.
"""
def store_string(self, string: str) -> None:
super().store_string(_sanitize_surrogates(string))
def _flush_pending_tty_input() -> None:
"""Drop unread keypresses typed while the model was generating output."""
try:
fd = sys.stdin.fileno()
if not os.isatty(fd):
return
except Exception:
return
with suppress(Exception):
import termios
termios.tcflush(fd, termios.TCIFLUSH)
return
with suppress(Exception):
while True:
ready, _, _ = select.select([fd], [], [], 0)
if not ready:
break
if not os.read(fd, 4096):
break
def _restore_terminal() -> None:
"""Restore terminal to its original state (echo, line buffering, etc.)."""
if _saved_term_attrs is None:
return
with suppress(Exception):
import termios
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _saved_term_attrs)
def _build_cli_key_bindings() -> KeyBindings:
"""Key bindings for the interactive prompt.
Behaviour:
* Enter -> submit the current input (keeps the familiar
single-line Enter-to-send feel even though the buffer
is multiline-capable).
* Alt+Enter -> insert a newline for multi-line input.
* Shift+Enter -> insert a newline on terminals that emit the CSI-u
(kitty / fixterms) keyboard-protocol encoding for it.
"""
# prompt_toolkit does not recognize CSI-u, so register its Shift+Enter
# sequence as a best-effort addition without overriding existing mappings.
with suppress(Exception):
from prompt_toolkit.input import ansi_escape_sequences as _aes
_aes.ANSI_SEQUENCES.setdefault("\x1b[13;2u", Keys.ControlF3)
kb = KeyBindings()
@kb.add("enter")
def _(event: KeyPressEvent) -> None:
event.current_buffer.validate_and_handle()
@kb.add("escape", "enter") # Alt+Enter / Meta+Enter (ESC + CR, "\x1b\r")
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
# LF-as-Enter terminals send Alt+Enter as ESC + LF rather than ESC + CR.
@kb.add("escape", Keys.ControlJ) # Alt+Enter on LF-as-Enter terminals
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
@kb.add(Keys.ControlF3) # Shift+Enter on CSI-u capable terminals
def _(event: KeyPressEvent) -> None:
event.current_buffer.insert_text("\n")
return kb
def _init_prompt_session() -> None:
"""Create the prompt_toolkit session with persistent file history."""
global _prompt_session, _saved_term_attrs
# Save terminal state so we can restore it on exit
with suppress(Exception):
import termios
_saved_term_attrs = termios.tcgetattr(sys.stdin.fileno())
from nanobot.config.paths import get_cli_history_path
history_file = get_cli_history_path()
history_file.parent.mkdir(parents=True, exist_ok=True)
_prompt_session = PromptSession(
history=SafeFileHistory(str(history_file)),
enable_open_in_editor=False,
# Multiline-capable buffer; Enter still submits via the custom key
# bindings, while Alt+Enter adds a newline.
multiline=True,
key_bindings=_build_cli_key_bindings(),
)
def _make_console() -> Console:
return Console(file=sys.stdout)
def _render_interactive_ansi(render_fn: Callable[[Console], None]) -> str:
"""Render Rich output to ANSI so prompt_toolkit can print it safely."""
ansi_console = Console(
force_terminal=sys.stdout.isatty(),
color_system=cast(
Literal["auto", "standard", "256", "truecolor", "windows"],
console.color_system or "standard",
),
width=console.width,
)
with ansi_console.capture() as capture:
render_fn(ansi_console)
return capture.get()
def _print_agent_response(
response: str,
render_markdown: bool,
metadata: dict[str, Any] | None = None,
show_header: bool = True,
) -> None:
"""Render assistant response with consistent terminal styling."""
console = _make_console()
content = response or ""
body = _response_renderable(content, render_markdown, metadata)
if show_header:
console.print()
console.print(f"[cyan]{__logo__} nanobot[/cyan]")
console.print(body)
console.print()
def _response_renderable(
content: str, render_markdown: bool, metadata: dict[str, Any] | None = None
) -> Text | Markdown:
"""Render plain-text command output without markdown collapsing newlines."""
if not render_markdown:
return Text(content)
if (metadata or {}).get("render_as") == "text":
return Text(content)
return Markdown(content)
async def _print_interactive_line(text: str) -> None:
"""Print async interactive updates with prompt_toolkit-safe Rich styling."""
def _write() -> None:
ansi = _render_interactive_ansi(lambda c: c.print(f" [dim]↳ {text}[/dim]"))
print_formatted_text(ANSI(ansi), end="")
await run_in_terminal(_write)
async def _print_interactive_response(
response: str,
render_markdown: bool,
metadata: dict[str, Any] | None = None,
) -> None:
"""Print async interactive replies with prompt_toolkit-safe Rich styling."""
def _write() -> None:
content = response or ""
def _render(target: Console) -> None:
target.print()
target.print(f"[cyan]{__logo__} nanobot[/cyan]")
target.print(_response_renderable(content, render_markdown, metadata))
target.print()
ansi = _render_interactive_ansi(_render)
print_formatted_text(ANSI(ansi), end="")
await run_in_terminal(_write)
def _print_cli_progress_line(
text: str,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
"""Print a CLI progress line, pausing the spinner if needed."""
if not text.strip():
return
target = renderer.console if renderer else console
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
with pause:
if renderer:
renderer.ensure_header()
target.print(f" [dim]↳ {text}[/dim]")
class _ReasoningBuffer:
def __init__(self) -> None:
self._text = ""
def add(self, text: str) -> str | None:
if not text:
return None
self._text += text
if self._should_flush(text):
return self.flush()
return None
def flush(self) -> str | None:
text = self._text.strip()
self._text = ""
return text or None
def clear(self) -> None:
self._text = ""
def _should_flush(self, text: str) -> bool:
stripped = text.rstrip()
return (
"\n" in text
or stripped.endswith(_REASONING_SENTENCE_ENDINGS)
or len(self._text) >= _REASONING_FLUSH_CHARS
)
def _print_cli_reasoning(
text: str,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
"""Print reasoning/thinking content in a distinct style."""
if not text.strip():
return
target = renderer.console if renderer else console
pause = renderer.pause_spinner() if renderer else (thinking.pause() if thinking else nullcontext())
with pause:
if renderer:
renderer.ensure_header()
target.print(f"[dim italic]✻ {text}[/dim italic]")
def _flush_cli_reasoning(
reasoning_buffer: _ReasoningBuffer,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
text = reasoning_buffer.flush()
if text:
_print_cli_reasoning(text, thinking, renderer)
async def _print_interactive_progress_line(
text: str,
thinking: ThinkingSpinner | None,
renderer: StreamRenderer | None = None,
) -> None:
"""Print an interactive progress line, pausing the spinner if needed."""
if not text.strip():
return
if renderer:
with renderer.pause_spinner():
renderer.ensure_header()
renderer.console.print(f" [dim]↳ {text}[/dim]")
else:
with thinking.pause() if thinking else nullcontext():
await _print_interactive_line(text)
async def _maybe_print_interactive_progress(
msg: Any,
thinking: ThinkingSpinner | None,
channels_config: Any,
renderer: StreamRenderer | None = None,
reasoning_buffer: _ReasoningBuffer | None = None,
) -> bool:
event = outbound_event_from_message(msg)
if isinstance(event, RetryWaitEvent):
await _print_interactive_progress_line(msg.content, thinking, renderer)
return True
if not isinstance(event, ProgressEvent):
return False
reasoning_buffer = reasoning_buffer or _ReasoningBuffer()
if event.reasoning_end:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
else:
_flush_cli_reasoning(reasoning_buffer, thinking, renderer)
return True
is_tool_hint = event.tool_hint
is_reasoning = event.reasoning or event.reasoning_delta
if is_reasoning:
if channels_config and not channels_config.show_reasoning:
reasoning_buffer.clear()
return True
text = reasoning_buffer.add(msg.content)
if text:
_print_cli_reasoning(text, thinking, renderer)
return True
if channels_config and is_tool_hint and not channels_config.send_tool_hints:
return True
if channels_config and not is_tool_hint and not channels_config.send_progress:
return True
await _print_interactive_progress_line(msg.content, thinking, renderer)
return True
def _is_exit_command(command: str) -> bool:
"""Return True when input should end interactive chat."""
return command.lower() in EXIT_COMMANDS
async def _read_interactive_input_async() -> str:
"""Read user input using prompt_toolkit (handles paste, history, display).
prompt_toolkit natively handles:
- Multiline paste (bracketed paste mode)
- History navigation (up/down arrows)
- Clean display (no ghost characters or artifacts)
"""
if _prompt_session is None:
raise RuntimeError("Call _init_prompt_session() first")
try:
with patch_stdout():
return await _prompt_session.prompt_async(
HTML("<b fg='ansiblue'>You:</b> "),
)
except EOFError as exc:
raise KeyboardInterrupt from exc
+261
View File
@@ -0,0 +1,261 @@
"""WebUI CLI command."""
from pathlib import Path
import typer
from pydantic import ValidationError
from rich.console import Console
from nanobot.cli import terminal as cli_terminal
from nanobot.cli.gateway_runtime import _run_gateway
from nanobot.cli.runtime_config import (
_load_runtime_config,
_print_config_error,
_print_runtime_config_validation_error,
_provider_setup_error,
)
from nanobot.cli.webui_support import (
_attach_to_background_gateway,
_confirm_webui_action,
_ensure_local_webui_channel,
_gateway_health_bind_note,
_gateway_health_ready,
_gateway_health_url,
_gateway_instance_command,
_host_for_local_browser,
_load_webui_setup_config,
_open_webui_browser,
_prepare_webui_bundle_for_gateway,
_print_foreground_port_conflict,
_print_webui_foreground_lifecycle,
_resolve_webui_config_path,
_run_quick_start_for_webui,
_tcp_endpoint_reachable,
_warn_webui_bind_scope,
_webui_browser_url,
_webui_build_mode_for_interactive,
_webui_display_url,
_webui_endpoint_reachable,
)
from nanobot.config.paths import get_workspace_path
from nanobot.utils.helpers import sync_workspace_templates
console = Console()
def webui(
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
gateway_port: int | None = typer.Option(
None,
"--gateway-port",
help="Gateway health port",
),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
background: bool = typer.Option(
False,
"--background",
help="Keep the gateway running after this command exits",
),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
"--yes",
"-y",
help="Apply safe local WebUI defaults without prompting",
),
) -> None:
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
from nanobot.config.loader import resolve_config_env_vars, save_config
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
cli_terminal._ensure_interactive_tty_mode()
config_path = _resolve_webui_config_path(config)
created_config = not config_path.exists()
if created_config:
console.print(f"[yellow]No config found at {config_path}.[/yellow]")
_confirm_webui_action("Create a nanobot config and workspace now?", yes=yes)
setup_config = _load_webui_setup_config(config_path)
if workspace:
setup_config.agents.defaults.workspace = workspace
try:
resolved_setup_config = resolve_config_env_vars(
setup_config.model_copy(deep=True),
config_path=config_path,
)
except ValueError as exc:
_print_config_error(exc)
raise typer.Exit(1) from exc
provider_error = _provider_setup_error(resolved_setup_config)
settings_setup_error = provider_error if provider_error and created_config else None
if settings_setup_error:
console.print(f"[yellow]Model setup is incomplete: {provider_error}[/yellow]")
console.print("Configure a provider and model in WebUI Settings → Models.")
if background:
console.print(
"[red]First-time WebUI setup must run in the foreground. "
"Run `nanobot webui` without --background.[/red]"
)
raise typer.Exit(1)
elif provider_error:
console.print(f"[dim]Provider check: {provider_error}[/dim]")
setup_config = _run_quick_start_for_webui(
setup_config,
yes=yes,
config_path=config_path,
)
if workspace:
setup_config.agents.defaults.workspace = workspace
try:
changed_webui, generated_bootstrap_secret = _ensure_local_webui_channel(
setup_config,
port=port,
yes=yes,
)
_warn_webui_bind_scope(setup_config)
webui_url = _webui_browser_url(setup_config)
except ValidationError as exc:
retry_command = f'nanobot webui --config "{config_path}"'
_print_runtime_config_validation_error(
exc,
config_path=config_path,
summary="WebUI configuration is invalid.",
path_prefix=("channels", "websocket"),
retry_command=retry_command,
)
raise typer.Exit(1) from exc
except ValueError as exc:
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
raise typer.Exit(1) from exc
if created_config or provider_error or changed_webui or workspace:
save_config(setup_config, config_path)
console.print(f"[green]✓[/green] Saved config: {config_path}")
workspace_path = get_workspace_path(setup_config.workspace_path)
workspace_path.mkdir(parents=True, exist_ok=True)
sync_workspace_templates(workspace_path)
runtime_config = _load_runtime_config(str(config_path), workspace)
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
console.print()
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
gateway_health_url = _gateway_health_url(
runtime_config.gateway.host,
effective_gateway_port,
)
console.print(
f"Gateway health: [cyan]{gateway_health_url}[/cyan]"
f"{_gateway_health_bind_note(runtime_config.gateway.host)}"
)
if no_open:
console.print("[dim]Browser opening disabled by --no-open.[/dim]")
if generated_bootstrap_secret:
console.print(
"[yellow]A WebUI bootstrap secret was generated and saved in this config.[/yellow]"
)
console.print(
"[dim]Open the WebUI and enter channels.websocket.tokenIssueSecret from "
f"{config_path}, or rerun without --no-open to open the authenticated URL.[/dim]"
)
webui_bundle_mode = _webui_build_mode_for_interactive(yes=yes)
config_arg = str(config_path)
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_arg,
config_path=config_arg,
)
)
start_options = GatewayStartOptions(
port=effective_gateway_port,
workspace=workspace_arg,
config_path=config_arg,
)
if background:
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
result = runtime.start_background(start_options)
restarted = False
restart_attempted = False
if not result.ok and result.message == "gateway_already_running" and changed_webui:
restart_attempted = True
console.print("[yellow]WebUI config changed; restarting the background gateway.[/yellow]")
result = runtime.restart(start_options, timeout_s=20)
restarted = result.ok
if not result.ok and (restart_attempted or result.message != "gateway_already_running"):
action = "restarted" if restart_attempted else "started"
console.print(f"[yellow]Gateway was not {action}: {result.message}[/yellow]")
console.print(f"Logs: {result.status.log_path}")
raise typer.Exit(1)
if restarted:
console.print("[green]Gateway restarted in the background.[/green]")
elif result.ok:
console.print("[green]Gateway started in the background.[/green]")
else:
console.print("[yellow]Gateway is already running in the background.[/yellow]")
console.print(
"Manage this instance: "
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
)
console.print(
"View logs: "
f"[cyan]{_gateway_instance_command('logs', config_path=config_path, workspace=workspace)}[/cyan]"
)
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print(
"Stop nanobot: "
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url)
return
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
return
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
_host_for_local_browser(runtime_config.gateway.host),
effective_gateway_port,
)
webui_port_taken = webui_ready
if gateway_port_taken or webui_port_taken:
_print_foreground_port_conflict(
webui_url=webui_url,
gateway_host=runtime_config.gateway.host,
gateway_port=effective_gateway_port,
)
raise typer.Exit(1)
_print_webui_foreground_lifecycle(attached=False)
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else webui_url,
webui_bundle_mode=webui_bundle_mode,
unconfigured_provider_error=settings_setup_error,
)
+498
View File
@@ -0,0 +1,498 @@
"""Shared WebUI setup, URL, health, and browser helpers."""
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
import typer
from pydantic import ValidationError
from rich.console import Console
from rich.markup import escape
from rich.text import Text
from nanobot.cli.runtime_config import (
_load_config_for_cli,
_print_model_setup_steps,
_print_runtime_config_validation_error,
_provider_setup_error,
)
from nanobot.config.schema import Config
from nanobot.security.network import is_loopback_host
from nanobot.webui.build import (
BuildMode,
WebUIBuildError,
ensure_webui_bundle,
)
if TYPE_CHECKING:
from nanobot.gateway.runtime import GatewayRuntime
__all__ = [
"_attach_to_background_gateway",
"_confirm_webui_action",
"_ensure_local_webui_channel",
"_gateway_health_bind_note",
"_gateway_health_ready",
"_gateway_health_url",
"_gateway_instance_command",
"_host_for_local_browser",
"_load_webui_setup_config",
"_open_webui_browser",
"_prepare_webui_bundle_for_gateway",
"_print_foreground_port_conflict",
"_print_webui_foreground_lifecycle",
"_resolve_webui_config_path",
"_run_quick_start_for_webui",
"_tcp_endpoint_reachable",
"_validate_gateway_startup",
"_warn_webui_bind_scope",
"_webui_browser_url",
"_webui_build_mode_for_interactive",
"_webui_channel_enabled",
"_webui_display_url",
"_webui_endpoint_reachable",
]
console = Console()
def _confirm_webui_action(message: str, *, yes: bool) -> None:
"""Confirm a WebUI first-run mutation or fail clearly in non-interactive shells."""
if yes:
return
if not _cli_can_prompt():
console.print(
"[red]Error: WebUI setup needs confirmation. Re-run with --yes or use "
"`nanobot onboard --wizard`.[/red]"
)
raise typer.Exit(1)
if not typer.confirm(message, default=True):
console.print("[yellow]WebUI setup cancelled.[/yellow]")
raise typer.Exit(1)
def _cli_can_prompt() -> bool:
try:
return sys.stdin.isatty()
except Exception:
return False
def _webui_build_mode_for_interactive(*, yes: bool = False) -> BuildMode:
if yes:
return "auto"
return "prompt" if _cli_can_prompt() else "warn"
def _resolve_webui_config_path(config: str | None) -> Path:
"""Resolve the config path used by ``nanobot webui`` and bind loader state."""
from nanobot.config.loader import get_config_path, set_config_path
if not config:
return get_config_path()
config_path = Path(config).expanduser().resolve(strict=False)
set_config_path(config_path)
console.print(f"[dim]Using config: {config_path}[/dim]")
return config_path
def _load_webui_setup_config(config_path: Path) -> Config:
"""Load config for first-run mutation without resolving env-var placeholders."""
return _load_config_for_cli(config_path)
def _webui_config_dict(config: Config) -> dict[str, Any]:
"""Return the current WebSocket config as a mutable alias-key dictionary."""
from nanobot.channels.websocket.runtime import WebSocketConfig
current: Any = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
return model.model_dump(by_alias=True, exclude_none=True)
def _webui_channel_enabled(config: Config) -> bool:
from nanobot.channels.websocket.runtime import WebSocketConfig
current: Any = getattr(config.channels, "websocket", None) or {}
return bool(WebSocketConfig.model_validate(current).enabled)
def _validate_gateway_startup(config: Config) -> str | None:
"""Validate gateway startup and return a provider error recoverable through WebUI."""
from nanobot.config.loader import get_config_path
config_path = get_config_path()
try:
webui_config = _webui_config_dict(config)
except ValidationError as exc:
retry_command = f'nanobot gateway --config "{config_path}"'
_print_runtime_config_validation_error(
exc,
config_path=config_path,
summary="Gateway configuration is invalid.",
path_prefix=("channels", "websocket"),
retry_command=retry_command,
)
raise typer.Exit(1) from exc
provider_error = _provider_setup_error(config)
if not provider_error:
return None
if bool(webui_config["enabled"]):
console.print(
Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
)
console.print(
"Gateway will start so you can configure a provider and model "
"in WebUI Settings → Models."
)
browser_url = _webui_browser_url(config)
webui_url = browser_url.split("/#/", 1)[0]
console.print(Text(f"WebUI: {webui_url}", style="cyan"))
if browser_url != webui_url:
secret_key = (
"tokenIssueSecret"
if str(webui_config.get("tokenIssueSecret") or "").strip()
else "token"
)
console.print(
Text(
f"If prompted, enter the configured channels.websocket.{secret_key} "
f"value (see {config_path}).",
style="dim",
)
)
return provider_error
console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
console.print("Complete provider/model setup:")
_print_model_setup_steps(config_path)
raise typer.Exit(1)
def _prepare_webui_bundle_for_gateway(
config: Config,
*,
mode: BuildMode,
webui_static_dist: bool = True,
) -> None:
"""Refresh or warn about stale bundled WebUI assets before gateway startup."""
if not webui_static_dist or not _webui_channel_enabled(config):
return
def _print(message: str) -> None:
console.print(f"[yellow]{escape(message)}[/yellow]")
def _confirm(message: str) -> bool:
return typer.confirm(message, default=True)
try:
ensure_webui_bundle(
mode=mode,
confirm=_confirm if mode == "prompt" else None,
output=_print,
)
except WebUIBuildError as exc:
if mode == "warn":
console.print(f"[yellow]Warning: {escape(str(exc))}[/yellow]")
return
console.print(f"[red]Error: {escape(str(exc))}[/red]")
raise typer.Exit(1) from exc
def _host_for_local_browser(host: str) -> str:
"""Map bind hosts to a browser-openable local host."""
if host in {"0.0.0.0", ""}:
return "127.0.0.1"
if host == "::":
return "[::1]"
if ":" in host and not host.startswith("["):
return f"[{host}]"
return host
def _gateway_health_url(host: str, port: int) -> str:
"""Return a health URL that can be opened from this device."""
return f"http://{_host_for_local_browser(host)}:{port}/health"
def _gateway_health_bind_note(host: str) -> str:
"""Describe a non-local bind without presenting it as a usable URL."""
return "" if is_loopback_host(host) else f" [dim](listening on {host})[/dim]"
def _webui_bootstrap_secret(config: Config) -> str:
ws_cfg = _webui_config_dict(config)
return str(ws_cfg.get("tokenIssueSecret") or ws_cfg.get("token") or "").strip()
def _webui_browser_url(config: Config) -> str:
from urllib.parse import quote
ws_cfg = _webui_config_dict(config)
host = _host_for_local_browser(str(ws_cfg.get("host") or "127.0.0.1"))
port = int(ws_cfg.get("port") or 8765)
base_url = f"http://{host}:{port}"
secret = _webui_bootstrap_secret(config)
if not secret:
return base_url
return f"{base_url}/#/?bootstrapSecret={quote(secret, safe='')}"
def _webui_display_url(url: str) -> str:
marker = "bootstrapSecret="
if marker not in url:
return url
prefix, _ = url.split(marker, 1)
return f"{prefix}{marker}<redacted>"
def _ensure_local_webui_channel(
config: Config,
*,
port: int | None,
yes: bool,
) -> tuple[bool, bool]:
"""Enable the local WebUI channel with safe localhost defaults."""
from nanobot.channels.websocket.runtime import WebSocketConfig
current: Any = getattr(config.channels, "websocket", None) or {}
model = WebSocketConfig.model_validate(current)
changed = False
generated_secret = False
needs_enable = not model.enabled
needs_port = port is not None and model.port != port
needs_secret = not model.token_issue_secret.strip() and not model.token.strip()
if not needs_enable and not needs_port and not needs_secret:
return False, False
target_port = port if port is not None else model.port
console.print()
console.print("[bold]Local WebUI setup[/bold]")
console.print(f" URL: [cyan]http://127.0.0.1:{target_port}[/cyan]")
console.print(" Bind: [cyan]127.0.0.1 only[/cyan] (not exposed to your LAN)")
console.print(" Auth: generated WebUI bootstrap secret stored in config")
console.print(
" LAN access requires an explicit host change plus a WebUI password in config."
)
_confirm_webui_action("Update the local WebUI channel in this config?", yes=yes)
if not model.enabled:
model.enabled = True
changed = True
if model.host != "127.0.0.1":
model.host = "127.0.0.1"
changed = True
if port is not None and model.port != port:
model.port = port
changed = True
if not model.websocket_requires_token:
model.websocket_requires_token = True
changed = True
if needs_secret:
import secrets
model.token_issue_secret = secrets.token_urlsafe(32)
changed = True
generated_secret = True
setattr(config.channels, "websocket", model.model_dump(by_alias=True, exclude_none=True))
return changed, generated_secret
def _warn_webui_bind_scope(config: Config) -> None:
ws_cfg = _webui_config_dict(config)
host = str(ws_cfg.get("host") or "127.0.0.1")
if host in {"127.0.0.1", "localhost", "::1"}:
return
console.print(
"[yellow]Warning: WebUI is configured to bind outside localhost. "
"Keep tokenIssueSecret set and use this only on trusted networks.[/yellow]"
)
def _wait_for_webui(url: str, *, timeout_s: float = 5.0) -> None:
"""Best-effort wait for the WebUI listener before opening a browser."""
import time
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _tcp_endpoint_reachable(host, port, timeout_s=0.2):
return
time.sleep(0.1)
def _tcp_endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.25) -> bool:
"""Return whether a local TCP endpoint accepts connections."""
import socket
try:
with socket.create_connection((host, port), timeout=timeout_s):
return True
except OSError:
return False
def _gateway_health_ready(host: str, port: int, *, timeout_s: float = 0.4) -> bool:
"""Return whether the nanobot gateway health endpoint responds OK."""
import json
import urllib.error
import urllib.request
browser_host = _host_for_local_browser(host)
try:
with urllib.request.urlopen(
f"http://{browser_host}:{port}/health",
timeout=timeout_s,
) as response:
if response.status != 200:
return False
body = response.read(1024)
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
return False
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return False
return payload.get("status") == "ok"
def _webui_endpoint_reachable(url: str, *, timeout_s: float = 0.25) -> bool:
"""Return whether the WebUI URL's TCP endpoint is already listening."""
from urllib.parse import urlparse
parsed = urlparse(url)
host = parsed.hostname or "127.0.0.1"
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return _tcp_endpoint_reachable(host, port, timeout_s=timeout_s)
def _print_foreground_port_conflict(
*,
webui_url: str,
gateway_host: str,
gateway_port: int,
) -> None:
console.print(
"[red]Error: nanobot cannot start because one of its local ports is already in use.[/red]"
)
console.print(f" WebUI: [cyan]{webui_url}[/cyan]")
console.print(
f" Gateway health: "
f"[cyan]http://{_host_for_local_browser(gateway_host)}:{gateway_port}/health[/cyan]"
)
console.print()
console.print("If this is an existing nanobot instance, use it or stop it first:")
console.print(" [cyan]nanobot gateway status[/cyan]")
console.print(" [cyan]nanobot gateway stop[/cyan]")
console.print(
"Or choose different ports with [cyan]--port[/cyan] "
"and [cyan]--gateway-port[/cyan]."
)
def _open_webui_browser(url: str, *, wait: bool = True) -> None:
"""Open the WebUI in the user's default browser, with a copyable fallback."""
import webbrowser
if wait:
_wait_for_webui(url)
display_url = _webui_display_url(url)
try:
webbrowser.open(url)
console.print(f"[green]✓[/green] Opened WebUI: [cyan]{display_url}[/cyan]")
except Exception as exc:
console.print(f"[yellow]Could not open browser ({exc}); visit {display_url}[/yellow]")
def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
"""Explain how the browser and gateway lifecycles differ."""
console.print()
if attached:
console.print("[green]nanobot is attached to the existing gateway.[/green]")
else:
console.print("[green]nanobot is running in this terminal.[/green]")
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping nanobot...[/yellow]")
result = runtime.stop()
if result.ok or result.message == "gateway_not_running":
console.print("[green]Gateway stopped.[/green]")
return
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
raise typer.Exit(1)
console.print("[yellow]Gateway stopped.[/yellow]")
def _gateway_instance_command(
subcommand: str,
*,
config_path: Path,
workspace: str | None,
) -> str:
"""Return a copyable gateway command for the same config/workspace instance."""
import shlex
parts = ["nanobot", "gateway", subcommand, "--config", str(config_path)]
if workspace:
workspace_path = str(Path(workspace).expanduser().resolve(strict=False))
parts.extend(["--workspace", workspace_path])
return " ".join(shlex.quote(part) for part in parts)
def _run_quick_start_for_webui(
config: Config,
*,
yes: bool,
config_path: Path,
) -> Config:
"""Offer the existing Quick Start flow when provider setup is missing."""
if yes:
console.print(
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
"provider credentials.[/red]"
)
console.print("Complete provider/model setup:")
_print_model_setup_steps(config_path)
raise typer.Exit(1)
console.print()
console.print("[yellow]Model provider setup is not ready.[/yellow]")
console.print(
"Quick Start will ask for provider, API key/base URL, model, and WebUI password."
)
_confirm_webui_action("Run Quick Start now?", yes=False)
from nanobot.cli.onboard import run_quick_start_onboard
try:
result = run_quick_start_onboard(config)
except RuntimeError as exc:
console.print(f"[red]Error: {exc}[/red]")
console.print(
"[yellow]Run `nanobot onboard --wizard` "
"after installing wizard dependencies.[/yellow]"
)
raise typer.Exit(1) from exc
if not result.should_save:
console.print("[yellow]Quick Start cancelled. No changes were saved.[/yellow]")
raise typer.Exit(1)
return result.config
+48 -39
View File
@@ -9,16 +9,20 @@ import sys
import time import time
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import TYPE_CHECKING, Any, Literal, cast
from nanobot import __version__ from nanobot import __version__
from nanobot.agent.goal_permission import goal_mutation_permission
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text from nanobot.command.router import CommandContext, CommandRouter, normalize_command_text
from nanobot.utils.helpers import build_status_content from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env from nanobot.utils.restart import set_restart_notice_to_env
from nanobot.utils.workspace_prompts import initialize_workspace_prompt from nanobot.utils.workspace_prompts import initialize_workspace_prompt
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
from nanobot.session.manager import Session
from nanobot.utils.gitstore import CommitInfo
# WebUI protocol contract for how a slash command participates in turn state: # WebUI protocol contract for how a slash command participates in turn state:
# - side_channel: returns control text without starting or ending an agent turn. # - side_channel: returns control text without starting or ending an agent turn.
# - finalize_active_turn: side-channel command that also closes the active UI turn. # - finalize_active_turn: side-channel command that also closes the active UI turn.
@@ -199,9 +203,9 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session.""" """Cancel all active tasks and subagents for the session."""
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) # pyright: ignore[reportPrivateUsage]
# Also drain pending queue to prevent mid-turn injection deadlock # Also drain pending queue to prevent mid-turn injection deadlock
pending = loop._pending_queues.pop(ctx.key, None) pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
if pending is not None: if pending is not None:
while not pending.empty(): while not pending.empty():
try: try:
@@ -228,14 +232,14 @@ 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:] argv = [sys.executable, "-m", "nanobot"] + sys.argv[1:]
mode = getattr(ctx.loop, "restart_mode", "auto") or "auto" mode = ctx.loop.restart_mode or "auto"
if mode == "auto": if mode == "auto":
mode = "spawn" if sys.platform == "win32" else "exec" mode = "spawn" if sys.platform == "win32" else "exec"
if mode == "exec": if mode == "exec":
os.execv(sys.executable, argv) os.execv(sys.executable, argv)
return return
if mode == "spawn": if mode == "spawn":
kwargs = {} kwargs: dict[str, Any] = {}
if sys.platform == "win32": if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(argv, **kwargs) subprocess.Popen(argv, **kwargs)
@@ -260,21 +264,20 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
runtime=runtime, runtime=runtime,
) )
if ctx_est <= 0: if ctx_est <= 0:
ctx_est = loop._last_usage.get("prompt_tokens", 0) ctx_est = loop._last_usage.get("prompt_tokens", 0) # pyright: ignore[reportPrivateUsage]
# Fetch web search provider usage (best-effort, never blocks the response) # Fetch web search provider usage (best-effort, never blocks the response)
search_usage_text: str | None = None search_usage_text: str | None = None
# Never let usage fetch break /status # Never let usage fetch break /status
with suppress(Exception): with suppress(Exception):
from nanobot.utils.searchusage import fetch_search_usage from nanobot.utils.searchusage import fetch_search_usage
web_cfg = getattr(loop, "web_config", None) search_cfg = loop.web_config.search
search_cfg = getattr(web_cfg, "search", None) if web_cfg else None usage = await fetch_search_usage(
if search_cfg is not None: provider=search_cfg.provider,
provider = getattr(search_cfg, "provider", "duckduckgo") api_key=search_cfg.api_key or None,
api_key = getattr(search_cfg, "api_key", "") or None )
usage = await fetch_search_usage(provider=provider, api_key=api_key) search_usage_text = usage.format()
search_usage_text = usage.format() active_tasks = loop._active_tasks.get(ctx.key, []) # pyright: ignore[reportPrivateUsage]
active_tasks = loop._active_tasks.get(ctx.key, [])
task_count = sum(1 for t in active_tasks if not t.done()) task_count = sum(1 for t in active_tasks if not t.done())
with suppress(Exception): with suppress(Exception):
task_count += loop.subagents.get_running_count_by_session(ctx.key) task_count += loop.subagents.get_running_count_by_session(ctx.key)
@@ -283,7 +286,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
chat_id=ctx.msg.chat_id, chat_id=ctx.msg.chat_id,
content=build_status_content( content=build_status_content(
version=__version__, model=runtime.model, version=__version__, model=runtime.model,
start_time=loop._start_time, last_usage=loop._last_usage, start_time=loop._start_time, last_usage=loop._last_usage, # pyright: ignore[reportPrivateUsage]
context_window_tokens=runtime.context_window_tokens, context_window_tokens=runtime.context_window_tokens,
session_msg_count=len(session.get_history(max_messages=0)), session_msg_count=len(session.get_history(max_messages=0)),
context_tokens_estimate=ctx_est, context_tokens_estimate=ctx_est,
@@ -298,17 +301,18 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
async def cmd_new(ctx: CommandContext) -> OutboundMessage: async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Stop active task and start a fresh session.""" """Stop active task and start a fresh session."""
loop = ctx.loop loop = ctx.loop
await loop._cancel_active_tasks(ctx.key) await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
session = ctx.session or loop.sessions.get_or_create(ctx.key) session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:] snapshot = session.messages[session.last_consolidated:]
runtime = None
if snapshot: if snapshot:
runtime = ctx.runtime or loop.runtime_for_session(session) runtime = ctx.runtime or loop.runtime_for_session(session)
session.clear() session.clear()
loop.sessions.save(session) loop.sessions.save(session)
loop.sessions.invalidate(session.key) loop.sessions.invalidate(session.key)
if snapshot: if snapshot and runtime is not None:
loop._schedule_background( loop.schedule_background(
loop.consolidator.archive( loop.consolidator.archive( # pyright: ignore[reportUnknownMemberType]
snapshot, snapshot,
runtime=runtime, runtime=runtime,
session_key=ctx.key, session_key=ctx.key,
@@ -325,7 +329,7 @@ def _format_preset_names(names: list[str]) -> str:
return ", ".join(f"`{name}`" for name in names) if names else "(none configured)" return ", ".join(f"`{name}`" for name in names) if names else "(none configured)"
def _model_preset_names(loop) -> list[str]: def _model_preset_names(loop: AgentLoop) -> list[str]:
names = set(loop.model_presets) names = set(loop.model_presets)
names.add("default") names.add("default")
return ["default", *sorted(name for name in names if name != "default")] return ["default", *sorted(name for name in names if name != "default")]
@@ -335,7 +339,7 @@ def _command_error_message(exc: Exception) -> str:
return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc) return str(exc.args[0]) if isinstance(exc, KeyError) and exc.args else str(exc)
def _model_command_status(loop, session) -> str: def _model_command_status(loop: AgentLoop, session: Session) -> str:
names = _model_preset_names(loop) names = _model_preset_names(loop)
try: try:
runtime = loop.runtime_for_session(session, recover_removed=False) runtime = loop.runtime_for_session(session, recover_removed=False)
@@ -401,8 +405,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage:
f"- Model: `{runtime.model}`", f"- Model: `{runtime.model}`",
f"- Context window: {runtime.context_window_tokens}", f"- Context window: {runtime.context_window_tokens}",
] ]
if max_tokens is not None: lines.append(f"- Max output tokens: {max_tokens}")
lines.append(f"- Max output tokens: {max_tokens}")
return OutboundMessage( return OutboundMessage(
channel=ctx.msg.channel, channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id, chat_id=ctx.msg.chat_id,
@@ -442,8 +445,7 @@ async def cmd_dream(ctx: CommandContext) -> OutboundMessage:
return return
prompt, last_cursor = result prompt, last_cursor = result
key = dream_session_key() key = dream_session_key()
resolve_dream_runtime = getattr(loop, "dream_runtime", None) dream_runtime = loop.dream_runtime()
dream_runtime = resolve_dream_runtime() if callable(resolve_dream_runtime) else None
resp = await loop.process_direct( resp = await loop.process_direct(
prompt, prompt,
session_key=key, session_key=key,
@@ -640,7 +642,12 @@ def _format_changed_files(diff: str) -> str:
_DREAM_COMMIT_PREFIX = "dream:" _DREAM_COMMIT_PREFIX = "dream:"
def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None = None) -> str: def _format_dream_log_content(
commit: CommitInfo,
diff: str,
*,
requested_sha: str | None = None,
) -> str:
files_line = _format_changed_files(diff) files_line = _format_changed_files(diff)
lines = [ lines = [
"## Dream Update", "## Dream Update",
@@ -668,7 +675,7 @@ def _format_dream_log_content(commit, diff: str, *, requested_sha: str | None =
return "\n".join(lines) return "\n".join(lines)
def _format_dream_restore_list(commits: list) -> str: def _format_dream_restore_list(commits: list[CommitInfo]) -> str:
lines = [ lines = [
"## Dream Restore", "## Dream Restore",
"", "",
@@ -806,14 +813,20 @@ _HISTORY_MAX_COUNT = 50
_HISTORY_MAX_CONTENT_CHARS = 200 _HISTORY_MAX_CONTENT_CHARS = 200
def _format_history_message(msg: dict) -> str | None: def _format_history_message(msg: dict[str, Any]) -> str | None:
"""Format a single history message for display. Returns None to skip.""" """Format a single history message for display. Returns None to skip."""
role = msg.get("role") role = msg.get("role")
if role not in ("user", "assistant"): if role not in ("user", "assistant"):
return None return None
content = msg.get("content") or "" content = msg.get("content") or ""
if isinstance(content, list): if isinstance(content, list):
parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"] parts = [
text
for block in cast(list[object], content)
if (item := cast(dict[str, Any], block) if isinstance(block, dict) else None)
and item.get("type") == "text"
and isinstance(text := item.get("text"), str)
]
content = " ".join(parts) content = " ".join(parts)
content = str(content).strip() content = str(content).strip()
if not content: if not content:
@@ -863,6 +876,8 @@ async def cmd_history(ctx: CommandContext) -> OutboundMessage:
async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None: async def cmd_goal(ctx: CommandContext) -> OutboundMessage | None:
"""Mark this turn as an explicit sustained-goal request.""" """Mark this turn as an explicit sustained-goal request."""
from nanobot.agent.goal_permission import goal_mutation_permission
goal = ctx.args.strip() goal = ctx.args.strip()
if not goal: if not goal:
return OutboundMessage( return OutboundMessage(
@@ -923,7 +938,7 @@ async def cmd_skill(ctx: CommandContext) -> OutboundMessage:
else: else:
lines = [f"Available skills ({len(skills)}):", ""] lines = [f"Available skills ({len(skills)}):", ""]
for entry in skills: for entry in skills:
desc = loop.context.skills._get_skill_description(entry["name"]) desc = loop.context.skills.get_skill_description(entry["name"])
lines.append(f"- **{entry['name']}** — {desc}") lines.append(f"- **{entry['name']}** — {desc}")
content = "\n".join(lines) content = "\n".join(lines)
return OutboundMessage( return OutboundMessage(
@@ -951,15 +966,9 @@ async def cmd_trigger(ctx: CommandContext) -> OutboundMessage:
from nanobot.triggers.local_store import LocalTriggerStore from nanobot.triggers.local_store import LocalTriggerStore
loop = ctx.loop loop = ctx.loop
workspace = getattr(loop, "workspace", None) store = loop.local_trigger_store
if workspace is None:
workspace = getattr(getattr(loop, "context", None), "workspace", None)
if workspace is None:
raise RuntimeError("workspace unavailable for trigger creation")
store = getattr(loop, "local_trigger_store", None)
if store is None: if store is None:
store = LocalTriggerStore(workspace) store = LocalTriggerStore(loop.workspace)
from nanobot.session.keys import UNIFIED_SESSION_KEY from nanobot.session.keys import UNIFIED_SESSION_KEY
+2 -1
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Awaitable, Callable from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.session.manager import Session from nanobot.session.manager import Session
from nanobot.utils.llm_runtime import LLMRuntime from nanobot.utils.llm_runtime import LLMRuntime
@@ -44,7 +45,7 @@ class CommandContext:
key: str key: str
raw: str raw: str
args: str = "" args: str = ""
loop: Any = None loop: AgentLoop = field(kw_only=True)
runtime: LLMRuntime | None = None runtime: LLMRuntime | None = None
is_user_turn: bool = False is_user_turn: bool = False
turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list) turn_scopes: list[AbstractContextManager[Any]] = field(default_factory=list)
+58 -24
View File
@@ -4,20 +4,28 @@ import json
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, cast, overload
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from pydantic_settings import SettingsError from pydantic_settings import SettingsError
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
from nanobot.config.schema import Config, _resolve_tool_config_refs from nanobot.config.schema import (
from nanobot.utils.helpers import _write_text_atomic Config,
_resolve_tool_config_refs, # pyright: ignore[reportPrivateUsage]
)
from nanobot.utils.helpers import _write_text_atomic # pyright: ignore[reportPrivateUsage]
# Global variable to store current config path (for multi-instance support) # Global variable to store current config path (for multi-instance support)
_current_config_path: Path | None = None _current_config_path: Path | None = None
_schema_refs_ready = False _schema_refs_ready = False
def _as_config_object(value: object) -> dict[str, Any] | None:
"""Narrow an untrusted JSON configuration value to an object."""
return cast(dict[str, Any], value) if isinstance(value, dict) else None
def set_config_path(path: Path) -> None: def set_config_path(path: Path) -> None:
"""Set the current config path (used to derive data directory).""" """Set the current config path (used to derive data directory)."""
global _current_config_path global _current_config_path
@@ -110,7 +118,7 @@ def load_config(config_path: Path | None = None) -> Config:
), ),
) )
data = _migrate_config(data) data = _migrate_config(cast(dict[str, Any], data))
try: try:
config = Config.model_validate(data) config = Config.model_validate(data)
except ValidationError as exc: except ValidationError as exc:
@@ -164,13 +172,15 @@ def save_config(config: Config, config_path: Path | None = None) -> None:
_write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False)) _write_text_atomic(path, json.dumps(data, indent=2, ensure_ascii=False))
def merge_missing_defaults(existing: Any, defaults: Any) -> Any: def merge_missing_defaults(existing: object, defaults: object) -> object:
"""Recursively add missing defaults without replacing configured values.""" """Recursively add missing defaults without replacing configured values."""
if not isinstance(existing, dict) or not isinstance(defaults, dict): if not isinstance(existing, dict) or not isinstance(defaults, dict):
return existing return cast(object, existing)
merged = dict(existing) existing_dict = cast(dict[str, object], existing)
for key, value in defaults.items(): defaults_dict = cast(dict[str, object], defaults)
merged = dict(existing_dict)
for key, value in defaults_dict.items():
if key not in merged: if key not in merged:
merged[key] = value merged[key] = value
else: else:
@@ -203,7 +213,15 @@ def resolve_config_env_vars(
return _resolve_in_place(config) return _resolve_in_place(config)
def resolve_env_refs(value: str) -> str: @overload
def resolve_env_refs(value: str) -> str: ...
@overload
def resolve_env_refs(value: object) -> object: ...
def resolve_env_refs(value: object) -> object:
"""Resolve ``${VAR}`` references in a single string, leniently. """Resolve ``${VAR}`` references in a single string, leniently.
Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and Unlike :func:`resolve_config_env_vars` (which walks a whole ``Config`` and
@@ -245,11 +263,21 @@ def _resolve_in_place(obj: Any) -> Any:
copy.__pydantic_extra__ = new_extras copy.__pydantic_extra__ = new_extras
return copy return copy
if isinstance(obj, dict): if isinstance(obj, dict):
resolved = {k: _resolve_in_place(v) for k, v in obj.items()} object_dict = cast(dict[str, Any], obj)
return resolved if any(resolved[k] is not obj[k] for k in obj) else obj resolved = {key: _resolve_in_place(value) for key, value in object_dict.items()}
return (
resolved
if any(resolved[key] is not object_dict[key] for key in object_dict)
else cast(object, obj)
)
if isinstance(obj, list): if isinstance(obj, list):
resolved = [_resolve_in_place(v) for v in obj] object_list = cast(list[Any], obj)
return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj resolved = [_resolve_in_place(value) for value in object_list]
return (
resolved
if any(new is not old for new, old in zip(resolved, object_list))
else cast(object, obj)
)
return obj return obj
@@ -270,20 +298,21 @@ def _missing_env_issues(
issues: list[ConfigIssue] = [] issues: list[ConfigIssue] = []
for name, field in type(obj).model_fields.items(): for name, field in type(obj).model_fields.items():
alias = field.serialization_alias or field.alias or name alias = field.serialization_alias or field.alias or name
part = alias if isinstance(alias, str) else name part = alias
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part))) issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
for name, value in (obj.__pydantic_extra__ or {}).items(): for name, value in (obj.__pydantic_extra__ or {}).items():
issues.extend(_missing_env_issues(value, (*path, name))) issues.extend(_missing_env_issues(value, (*path, name)))
return issues return issues
if isinstance(obj, dict): if isinstance(obj, dict):
object_dict = cast(dict[str | int, Any], obj)
issues = [] issues = []
for name, value in obj.items(): for name, value in object_dict.items():
part = name if isinstance(name, (str, int)) else str(name) part = name
issues.extend(_missing_env_issues(value, (*path, part))) issues.extend(_missing_env_issues(value, (*path, part)))
return issues return issues
if isinstance(obj, list): if isinstance(obj, list):
issues = [] issues = []
for index, value in enumerate(obj): for index, value in enumerate(cast(list[Any], obj)):
issues.extend(_missing_env_issues(value, (*path, index))) issues.extend(_missing_env_issues(value, (*path, index)))
return issues return issues
return [] return []
@@ -294,9 +323,12 @@ def _resolve_env_vars(obj: object) -> object:
if isinstance(obj, str): if isinstance(obj, str):
return _ENV_REF_PATTERN.sub(_env_replace, obj) return _ENV_REF_PATTERN.sub(_env_replace, obj)
if isinstance(obj, dict): if isinstance(obj, dict):
return {k: _resolve_env_vars(v) for k, v in obj.items()} return {
key: _resolve_env_vars(value)
for key, value in cast(dict[str, object], obj).items()
}
if isinstance(obj, list): if isinstance(obj, list):
return [_resolve_env_vars(v) for v in obj] return [_resolve_env_vars(value) for value in cast(list[object], obj)]
return obj return obj
@@ -310,15 +342,16 @@ def _env_replace(match: re.Match[str]) -> str:
return value return value
def _migrate_config(data: dict) -> dict: def _migrate_config(data: dict[str, Any]) -> dict[str, Any]:
"""Migrate old config formats to current.""" """Migrate old config formats to current."""
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace # Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
tools = data.get("tools", {}) tools_value = data.get("tools", {})
if not isinstance(tools, dict): if not isinstance(tools_value, dict):
return data return data
exec_cfg = tools.get("exec", {}) tools = cast(dict[str, Any], tools_value)
exec_cfg = _as_config_object(tools.get("exec", {}))
if ( if (
isinstance(exec_cfg, dict) exec_cfg is not None
and "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" in exec_cfg
and "restrictToWorkspace" not in tools and "restrictToWorkspace" not in tools
): ):
@@ -334,6 +367,7 @@ def _migrate_config(data: dict) -> dict:
tools["my"] = my_cfg tools["my"] = my_cfg
if not isinstance(my_cfg, dict): if not isinstance(my_cfg, dict):
return data return data
my_cfg = cast(dict[str, Any], my_cfg)
if "myEnabled" in tools and "enable" not in my_cfg: if "myEnabled" in tools and "enable" not in my_cfg:
my_cfg["enable"] = tools.pop("myEnabled") my_cfg["enable"] = tools.pop("myEnabled")
else: else:
+1 -1
View File
@@ -48,7 +48,7 @@ def get_webui_dir() -> Path:
return get_runtime_subdir("webui") return get_runtime_subdir("webui")
def get_workspace_path(workspace: str | None = None) -> Path: def get_workspace_path(workspace: str | Path | None = None) -> Path:
"""Resolve and ensure the agent workspace path.""" """Resolve and ensure the agent workspace path."""
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
return ensure_dir(path) return ensure_dir(path)
+6 -3
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal from typing import TYPE_CHECKING, Any, ClassVar, Literal
from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator from pydantic import AliasChoices, ConfigDict, Field, field_validator, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
from nanobot.config_base import Base from nanobot.config_base import Base
from nanobot.cron.types import CronSchedule from nanobot.cron.types import CronSchedule
@@ -403,7 +403,7 @@ class ToolsConfig(Base):
"webuiAllowRemotePackageInstall", "webuiAllowRemotePackageInstall",
"webui_allow_remote_package_install", "webui_allow_remote_package_install",
), ),
) # allow non-local WebUI clients to install optional Python packages ) # allow non-local WebUI clients to install optional packages and agent skills
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
@@ -618,7 +618,10 @@ class Config(BaseSettings):
return spec.default_api_base return spec.default_api_base
return None return None
model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") model_config = SettingsConfigDict(
env_prefix="NANOBOT_",
env_nested_delimiter="__",
)
def _resolve_tool_config_refs() -> None: def _resolve_tool_config_refs() -> None:

Some files were not shown because too many files have changed in this diff Show More