Compare commits

..
Author SHA1 Message Date
Xubin Ren 4819c27be0 fix(webui): scope image rewrites to webui chats 2026-05-23 02:16:17 +08:00
Xubin Ren 56465e7017 fix(webui): handle final stream image rewrites 2026-05-23 01:43:48 +08:00
Xubin Ren 7033152a2c fix(webui): render local CLI image artifacts 2026-05-23 01:39:46 +08:00
Xubin Ren 2048c38a22 fix(cli): refresh installed apps after settings changes 2026-05-23 01:02:48 +08:00
Xubin Ren c0d4f012c8 test(cli): cover CLI Apps on Windows CI 2026-05-23 00:47:28 +08:00
Xubin Ren e2d00ffc8f feat: add CLI Apps settings MVP 2026-05-23 00:33:31 +08:00
Xubin Ren a5a956d9af fix(webui): preserve localized chat show-more copy 2026-05-23 00:01:52 +08:00
Stellar鱼andXubin Ren 8c5acea3b0 chore: fill remaining webui locale keys 2026-05-23 00:01:52 +08:00
Xubin Ren 545294c62c fix(web): keep safe fetch preflight streaming 2026-05-22 23:10:13 +08:00
hinotoi-agentandXubin Ren 25d00b1ea4 fix(web): support redirect handling in fake responses 2026-05-22 23:10:13 +08:00
hinotoi-agentandXubin Ren ff173045fe fix(web): validate redirect targets before fetching 2026-05-22 23:10:13 +08:00
yu-xin-candXubin Ren b1140f6aee chore: fill zh-TW and ja locale keys 2026-05-22 22:38:34 +08:00
Xubin RenandGitHub 782d761b81 Merge PR #3929: Unify image provider HTTP handling and document Gemini image base URLs
Unify image provider HTTP handling and document Gemini image base URLs
2026-05-22 22:31:27 +08:00
Xubin Ren c1073f2986 fix(image-generation): keep image presence helper stable 2026-05-22 22:19:32 +08:00
Xubin Ren 143224e25a Merge remote-tracking branch 'origin/main' into codex/review-pr-3929 2026-05-22 22:15:46 +08:00
Yuxin LouandXubin Ren 055c9be359 fix: dedupe Responses replay item ids
Ensure converted Responses API input items use unique replay ids when restoring assistant messages and function calls. This prevents Codex from rejecting resumed conversations with duplicate rs_* item ids while preserving call_id-based tool result linkage.
2026-05-22 22:14:07 +08:00
Xubin RenandGitHub ddfe5c3bdf Merge PR #3946: Add Ollama image generation support
Add Ollama image generation support
2026-05-22 22:06:28 +08:00
Xubin Ren f5534bcaa0 Merge origin/main into fix-ollama-image-generation 2026-05-22 21:15:42 +08:00
Xubin Ren 8c0b2c1a29 fix(image-generation): clamp OpenAI sizes by model family 2026-05-22 17:42:01 +08:00
ZegWeandXubin Ren ffd85a8611 fix image generation provider settings 2026-05-22 17:42:01 +08:00
ZegWeandXubin Ren 65dff4f3a5 fix(providers): preserve codex text deltas 2026-05-22 17:42:01 +08:00
3483141ed7 feat(providers): add OpenAI and OpenAI Codex image generation providers
Add two new image generation providers:

- `openai` — uses the standalone OpenAI Images API
  (`/v1/images/generations`) with an API key. Supports DALL-E
  and gpt-image-* models, with automatic parameter adjustment
  (gpt-image models don't accept response_format or n).

- `openai_codex` — uses the Codex Responses API with the
  `image_generation` tool, authenticated via OAuth subscription
  token. The same mechanism ChatGPT uses internally.

Also remove the API key pre-check in ImageGenerationTool so
providers that handle their own auth fallback (like Codex OAuth)
can work without a configured key.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 17:42:01 +08:00
Xubin Ren b0d3069621 fix(apply-patch): tighten edits-only boundaries 2026-05-22 17:25:45 +08:00
chengyongruandXubin Ren 3d9f50a0cc refactor(apply_patch): remove deprecated patch mode, keep edits-only
Drop the legacy unified-diff patch parameter and all related parsing/
generation logic (_parse_patch, _generate_patch, _apply_hunks, etc.).
The tool now accepts only the structured `edits` array, eliminating the
intermediate diff-string round-trip.

Also update file_edit_events tracking and tests to work exclusively
with edits.

Benchmark (zhipu glm-5.1, edits mode): 15/15 cases passed.
2026-05-22 17:25:45 +08:00
Xubin Ren effc1efd92 fix(webui): avoid misleading file edit counters 2026-05-22 13:58:09 +08:00
A.G. BocsardiandXubin Ren 9b2f452b6e fix: drop redundant reasoning_effort for Kimi thinking models
Moonshot's API rejects requests that carry both 'reasoning_effort'
(top-level kwarg) and 'thinking' (extra_body) at the same time.
After the unified thinking-style injection loop injects the native
'thinking' param for kimi models, pop 'reasoning_effort' from kwargs
since it is redundant and causes a 400 error.

Uses _model_slug() + _KIMI_THINKING_MODELS lookup to stay consistent
with the refactored code (the old _is_kimi_thinking_model helper was
removed in 4f895e63).

Existing kimi tests updated to assert 'reasoning_effort' is absent.
Xiaomi MiMo models are unaffected — their API accepts both params.

Closes #3939
2026-05-22 03:36:28 +08:00
Xubin Ren d660573b18 feat(webui): improve sidebar performance 2026-05-22 03:35:20 +08:00
Xubin Ren cb7daa77db feat(webui): refine collapsible sidebar 2026-05-22 00:34:42 +08:00
Xubin Ren 8281cd1946 test(providers): cover Novita gateway fallback 2026-05-21 16:16:32 +08:00
Alex-wuhuandXubin Ren e5476573f4 test(providers): align Novita provider coverage 2026-05-21 16:16:32 +08:00
Alex-wuhuandXubin Ren 0d1d23b5fb feat: add Novita AI provider 2026-05-21 16:16:32 +08:00
Xubin Ren 835bab5f5a fix(exec): stabilize Windows shell tests 2026-05-21 16:10:09 +08:00
Xubin RenandGitHub ccbc0bb6e3 Merge PR #3923: feat(tools): optimize coding workflows
feat(tools): optimize coding workflows
2026-05-21 15:55:13 +08:00
Xubin Ren 722b760eae feat(webui): stream apply patch edit progress 2026-05-21 15:44:01 +08:00
Xubin Ren 23d5148a57 fix(provider): dedupe repeated tool ids in history 2026-05-21 15:33:49 +08:00
Xubin Ren d29fcaf5d1 refactor(agent): internalize tool contract prompt 2026-05-21 15:21:39 +08:00
Haisam Abbas 84603f4cf2 Add Ollama image generation support 2026-05-21 12:06:08 +05:00
Xubin Ren 581faa34f7 Merge remote-tracking branch 'origin/main' into codex/coding-tooling-optimization 2026-05-21 14:44:56 +08:00
Xubin Ren 7e3af8c38b docs(tools): add general tool workflow contract 2026-05-21 14:44:34 +08:00
Haisam AbbasandXubin Ren e645fbcb34 fix shell guard url path detection 2026-05-21 14:42:11 +08:00
Xubin Ren 4f895e6307 refactor(providers): centralize gateway reasoning control 2026-05-21 14:41:50 +08:00
olgagagaandXubin Ren 0cd2f626c0 fix(providers): inject OpenRouter reasoning.effort for thinking models
Follow-up to #3851: that PR added `extra_body.thinking={type: disabled}`
for MiMo via OpenRouter, but OR doesn't forward provider-specific
thinking shapes to upstream — it strips unknown extra_body fields and
uses its own unified `reasoning` parameter. So MiMo via OR kept
thinking despite the injection (reproduced by @ClearPlume on #3851
with identical kwargs but provider switched from openrouter → xiaomi_mimo).

For known thinking-capable models (Kimi, MiMo) routed via the
openrouter spec, also inject `extra_body.reasoning = {effort: <effort>}`
in OR's documented enum ("none"|"minimal"|"low"|"medium"|"high"|"xhigh").
OR translates this to the upstream model's native shape.

Existing tests updated to expect both fields on the OR path. The direct
xiaomi_mimo and moonshot paths are unchanged (the new branch is gated
on spec.name == "openrouter"). Flash and non-MiMo models on OR continue
to receive no injection.
2026-05-21 14:41:50 +08:00
Xubin Ren 44ef697aac docs(tools): clarify coding tool guidance 2026-05-21 14:28:39 +08:00
chengyongruandXubin Ren e2b51fa5dc fix(weixin): prevent silent message drops from poll exceptions and expired tokens
- Remove suppress(Exception) from poll loop and message processing; add
  logger.exception so inbound errors are visible.
- Check both ret and errcode on send to avoid silent drops when iLink
  returns ret != 0 with errcode == 0.
- Proactively refresh context_token via getconfig before sending if the
  cached token is older than 60s. This prevents message loss on long
  agent turns and cron pushes without relying on complex retry logic.

Refs: openclaw/openclaw#61174, NousResearch/hermes-agent#21011
2026-05-21 13:41:05 +08:00
Xubin Ren 7e122d6e49 chore(tools): merge main and resolve conflicts 2026-05-21 12:53:42 +08:00
hanyuanlingandXubin Ren de0a8f5e41 fix(webui): keep new chat during session refresh 2026-05-21 12:42:56 +08:00
Xubin Ren 3d3ebf1110 test(provider): cover duplicate streaming tool call ids 2026-05-21 12:28:24 +08:00
chengyongruandXubin Ren 77ec55bf8e fix(provider): deduplicate streaming tool_call_ids for parallel calls 2026-05-21 12:28:24 +08:00
Xubin Ren 8141df0d3f fix(tools): stabilize session output test 2026-05-21 01:32:27 +08:00
Xubin Ren 5f0ba05de5 feat(tools): tighten patch and session workflows 2026-05-21 01:25:20 +08:00
chengyongruandXubin Ren 886e7e43d5 fix(signal): bypass base is_allowed for policy-approved messages
Override _handle_message to publish directly to the bus for messages
that have already passed _check_inbound_policy. The denied DM pairing
path calls super()._handle_message() to issue pairing codes via the
base class. This avoids cross-policy leakage where e.g. group open
policy would cause is_allowed to incorrectly allow denied DM senders.

Also includes:
- SSE: strip one optional leading space after 'data:' per spec
- Convert 20+ f-string log calls to loguru lazy formatting
- Add end-to-end tests for DM/group routing through the full chain
- Add cross-policy test (dm allowlist + group open) for pairing
- Add Signal channel documentation to docs/chat-apps.md
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren b3d0d24a52 fix(signal): consult pairing store in is_allowed
BaseChannel.is_allowed ORs is_approved (the pairing store) into the
allow decision; the signal override dropped that step and only looked
at config.allow_from. With the new DM-pairing flow in place, an
approved-via-pairing sender's next message would have failed the
allow check and triggered another pairing code in a loop.

OR in a normalized check against the pairing store: walk each part of
the pipe-joined sender_id through _normalize_signal_id and call
is_approved for each variant, so an approval stored under one form
(phone with/without "+", UUID/ACI) still matches when the next inbound
uses a different form. Mirrors how slack.py:643 handles it.

Also tightens the empty-allowlist warning to only fire when nothing
else granted access, since pairing-store hits are now a valid path.

Not part of the original review, but Comments 2 and 3 turn this latent
gap into a broken round-trip — included so the pairing UX actually
works.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 82dfe8c1f7 fix(signal): join multi-line SSE data with newline per spec
Per the SSE spec, multiple data: lines within a single event must be
joined with \n before parsing. signal-cli emits single-line JSON so
this was latent, but the joining was wrong.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren dc33247671 fix(signal): route denied DMs through _handle_message for pairing code
Previously _check_inbound_policy returned (False, chat_id) for DMs
that failed the allowlist and the caller dropped them — so unapproved
DM senders never saw a pairing code. Mirror Slack: when the policy
gate denies a DM but dm.enabled is true, still call
_handle_message(content="", is_dm=True) so BaseChannel can issue the
pairing reply. Group denials stay a hard drop.

Combined with the previous is_dm forwarding, unapproved DM senders
now receive a pairing code through the standard flow.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren d376ec129d fix(signal): pass is_dm to _handle_message so DM pairing flow runs
BaseChannel._handle_message uses is_dm to decide whether to issue a
pairing code when is_allowed rejects the sender. Without it the base
class treats every denied message as a group message and silently
drops it. Forward is_dm=not is_group_message so unapproved DM users
get a pairing code through the standard flow.

This change only takes effect once denied DMs actually reach
_handle_message (next commit); on its own it is a no-op since the
policy gate still short-circuits before this call.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren d653f23aba fix(signal): raise on signal-cli error response so send is retriable
_send_http_request collapses every exception path into a {"error": ...}
dict, so the if "error" in response branch inside send() is the only
place where send failures surface. Logging-only there meant the
ChannelManager retry mechanism never fired. Raise RuntimeError so the
base-class retry path is exercised; the outer try/except already
re-raises into the caller.

Addresses review comment on PR #3852.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 96767ca179 Cleanup 2026-05-21 01:00:36 +08:00
b300ea495f fix(signal): normalize composite sender_ids in is_allowed too
The base BaseChannel.is_allowed() does a literal ``sender_id in allow_from``
check, but Signal's sender_id is a pipe-joined composite of phone/UUID
parts. After splitting an allowlist entry like ``+phone|uuid`` into two
separate entries, the per-DM gate accepted it but the base gate still
denied because the composite sender string wasn't literally in the list.

Override is_allowed on SignalChannel to delegate to
_sender_matches_allowlist, which already splits both sides on ``|`` and
normalizes each part. _sender_matches_allowlist itself now also splits
allowlist entries on ``|`` so legacy composite entries keep working too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
632f41e418 test(signal): cover markdown adjacency, nesting, and malformed input
The existing markdown suite was strong on UTF-16 offsets and chunk
redistribution but had no coverage for nested or adjacent styles, no test
that an unmatched opener round-trips as plain text, and no test for the
blockquote/inline-code interaction. Add six cases including the
documented contiguous-BOLD output for `# **wrap** me`, which Signal
renders as one visual span.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
9c486b90d5 test(signal): consolidate channel-capture setup into one factory
Two test classes (TestHandleDataMessageDM, TestHandleDataMessageGroup)
plus three TestCommandHandling tests each repeated the same handful of
lines: build a channel, mock _handle_message to record kwargs, replace
_start_typing with a no-op, paper over the assignment with type: ignore.

Hoist the pattern into _make_channel_with_capture and call it from all
five sites. Drops 30+ lines of duplication and 7 type: ignore comments.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
590ac99c8a test(signal): cover SSE receive loop and the empty-phone start guard
Previously the SSE loop and the empty-phone-number short-circuit in start()
had zero coverage. Both now have tests: a fake httpx stream feeds canned
SSE lines, exercising the valid-frame, invalid-JSON, non-200, and
no-http-client paths; start() with an empty phone number is asserted to
return without entering the HTTP loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
7733a7840e refactor(signal): split _handle_data_message into policy and assembly helpers
The receive-path handler was ~165 lines deep into nested DM/group policy
checks, buffer mutations, mention stripping, attachment downloads, and
final bus forwarding. Pull the policy gate out into _check_inbound_policy
(returns (allow, chat_id), still appends to the group buffer once allowed)
and the text+media construction into _assemble_inbound_content. The
top-level method now reads as orchestration only.

Add TestCheckInboundPolicy that exercises the helper directly across the
DM/group policy permutations, including the buffer side effect, so the
new seam is locked in.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
83aed43682 feat(signal): make signal-cli attachments directory configurable
The inbound attachment loop hardcoded ~/.local/share/signal-cli/attachments
as the source path. That is the daemon's default on Linux but not on macOS
or Windows, and breaks if the daemon was launched with XDG_DATA_HOME set.

Add SignalConfig.attachments_dir as an optional override. When unset the
behavior is unchanged; when set the value is run through Path.expanduser()
so ~ is honored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
ad7c1ac381 refactor(signal): wrap top-level receive handler with _safe_handle
Replace the inline try/except at the end of _handle_receive_notification
with a small async context manager that swallows the exception, logs
self.logger.error with the offending payload's repr (bounded to 200 chars),
and attaches the traceback via logger.opt(exception=True).

The previous log line only carried `e`, so diagnosing a bad envelope from
production logs required correlating timestamps. The wrapper is generic so
future receive/dispatch sites can adopt it; for now only this site uses it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
882d4139d7 fix(signal): normalize identifiers when matching DM allowlist
The DM allowlist check split sender_id on '|' and looked for raw membership
in the allow_from list. Senders carry their phone number with a leading
'+' but admins routinely write allowlist entries without it (or vice
versa), and UUID/ACI matches were case-sensitive. Both forms now flow
through _normalize_signal_id, so an entry like 19995550001 matches a
sender +19995550001 and a UUID matches case-insensitively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
ca72f6b6c9 refactor(signal): hygiene cleanups around constants, typing, and config
- Hoist the cell-strip patterns to module level so they match the rest of
  the module's regex style and aren't reparsed on every call.
- Type the markdown transform callback and the mention id walker so the
  inline Callable signature is no longer an untyped Any.
- Add _HTTP_TIMEOUT_SECONDS alongside the other class-level tunables.
- Reject group_message_buffer_size <= 0 in a Pydantic field_validator
  rather than silently disabling the buffer at write time.
- Mark SignalConfig.allow_from as a computed_field so it shows up in
  model_dump() instead of being invisible to serialization.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
96eb3b7194 fix(signal): redistribute textStyle ranges across split message chunks
split_message can break a long Signal payload into multiple JSON-RPC sends,
but the previous code attached the full textStyle list only to chunk 0.
Style ranges in later chunks were dropped, and ranges whose offsets pointed
past chunk 0's end were sent as invalid metadata against chunk 0.

Add _partition_styles, which rebases each range against the chunk it lives
in (in UTF-16 code units, matching the markdown converter) and splits
boundary-spanning ranges across the chunks they touch. Whitespace trimmed
by split_message's lstrip is skipped so offsets stay aligned.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
8f6b7611a2 fix(signal): emit textStyle offsets in UTF-16 code units
Signal's BodyRange (via signal-cli's textStyle) interprets start/length as
UTF-16 code units, but the Phase-3 assembly used Python's len(), which counts
code points. A single non-BMP character (e.g. an emoji) earlier in a message
shifted every subsequent styled span left by one unit, dropping the last
letter of bold/italic words.

Track a running UTF-16 offset in the assembly loop and add regression tests
covering emojis, supplementary CJK, ZWJ sequences, and a multi-section
message that mirrors the reported failure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 1a6fe093e7 fix(signal): drop duplicate self in unconfigured-account log call
Addresses review feedback on HKUDS/nanobot#3852: self.self.logger.error
would crash if the phone_number guard ever fired.
2026-05-21 01:00:36 +08:00
Kaloyan TenchovandXubin Ren 8ec1025193 feat(signal): add Signal channel support
Integrates signal-cli daemon via HTTP JSON-RPC as a nanobot channel.
Supports DMs and group chats with open/allowlist access policies,
markdown→Signal text style conversion, typing indicators, attachment
handling, group message context buffering, and automatic reconnect
with exponential backoff.

Includes unit tests for channel lifecycle, message routing, mention
detection, markdown conversion, and message splitting.

Originally based on https://github.com/HKUDS/nanobot/pull/601.
2026-05-21 01:00:36 +08:00
Xubin Ren 480ca28a2d feat(tools): improve coding workflow recovery 2026-05-21 00:58:05 +08:00
Xubin Ren 3e154bb5cf fix(tools): align exec platform test doubles 2026-05-20 23:42:55 +08:00
Xubin Ren 6851fa57a6 feat(tools): optimize coding workflows 2026-05-20 23:08:21 +08:00
chengyongruandXubin Ren 09a692be6f docs(readme): add multi-language doc site links
Link nanobot.wiki documentation in 10 languages from README header:
English, 简体中文, 繁體中文, Español, Français, Bahasa Indonesia,
日本語, 한국어, Русский, Tiếng Việt.
2026-05-20 22:37:11 +08:00
Haisam Abbas 3f789bd9f9 Revert "fix shell guard url path detection"
This reverts commit 65cecc01fb.
2026-05-20 17:21:34 +05:00
Haisam Abbas 65cecc01fb fix shell guard url path detection 2026-05-20 17:16:53 +05:00
Haisam Abbas a7b34422f3 fix Gemini image base and provider docs 2026-05-20 14:06:55 +05:00
Haisam Abbas 72f999f8f7 refactor image provider HTTP handling 2026-05-20 13:56:43 +05:00
Haisam Abbas e6587a8d8e Fix image mime detection for MiniMax 2026-05-20 12:18:18 +05:00
Xubin Ren eae51333ad fix(providers): point Skywork at APIFree agent endpoint 2026-05-20 12:33:03 +08:00
moranandXubin Ren 6194a9b919 docs(configuration): fix APIFree formatting — merge wrapped description into single line 2026-05-20 12:33:03 +08:00
moranandXubin Ren 61ae869610 feat(providers): add APIFree support
Add APIFree as a built-in OpenAI-compatible provider. APIFree offers
agent-optimised models such as skywork-ai/skyclaw-v1 through an
OpenAI-compatible API at https://api.apifree.ai/agent/v1.

Changes:
- Register apifree provider in the provider registry
- Add config schema field
- Add documentation with configuration example
- Add provider tests, websocket channel tests, and webui tests
- Add provider icon in settings UI
2026-05-20 12:33:03 +08:00
Xubin Ren 3eebe08dba fix(exec): detach stdin for shell commands 2026-05-20 12:07:17 +08:00
Xubin Ren 38a5f09f02 refactor: preserve cold-start lazy boundaries 2026-05-20 12:02:23 +08:00
chengyongruandXubin Ren af9f8d54b8 perf: optimize gateway cold start from ~6.9s to ~460ms (#3918)
Channel lazy load: discover_enabled() only imports enabled channel
modules instead of all 18 modules with heavy SDKs (telegram, discord,
slack, etc). discover_all() now delegates to discover_enabled().

Lazy OpenAI client: defer AsyncOpenAI() + httpx construction to
_ensure_client() with asyncio.Lock double-checked locking. openai
and httpx imports moved from module-level into _ensure_client().

Minor: lazy Nanobot/RunResult and CronService exports via __getattr__.

Benchmark: 6910ms → 460ms (-93.3%)
2026-05-20 12:02:23 +08:00
134 changed files with 15756 additions and 1895 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }}
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
python-version: ${{ fromJSON('["3.13","3.14"]') }}
+1
View File
@@ -97,3 +97,4 @@ logs/
tmp/
temp/
*.tmp
exp/
+13 -1
View File
@@ -1,6 +1,18 @@
![cover-v5-optimized](./images/GitHub_README.png)
<div align="center">
<p>
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview">English</a> |
<a href="https://nanobot.wiki/cn/docs/latest/getting-started/nanobot-overview">简体中文</a> |
<a href="https://nanobot.wiki/zh-Hant/docs/latest/getting-started/nanobot-overview">繁體中文</a> |
<a href="https://nanobot.wiki/es/docs/latest/getting-started/nanobot-overview">Español</a> |
<a href="https://nanobot.wiki/fr/docs/latest/getting-started/nanobot-overview">Français</a> |
<a href="https://nanobot.wiki/id/docs/latest/getting-started/nanobot-overview">Bahasa Indonesia</a> |
<a href="https://nanobot.wiki/ja/docs/latest/getting-started/nanobot-overview">日本語</a> |
<a href="https://nanobot.wiki/ko/docs/latest/getting-started/nanobot-overview">한국어</a> |
<a href="https://nanobot.wiki/ru/docs/latest/getting-started/nanobot-overview">Русский</a> |
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
</p>
<p>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
@@ -61,7 +73,7 @@
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
+67
View File
@@ -17,6 +17,7 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
| **Wecom** | Bot ID + Bot Secret |
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
| **Mochat** | Claw token (auto-setup available) |
| **Signal** | signal-cli daemon + phone number |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@@ -669,3 +670,69 @@ nanobot gateway
```
</details>
<details>
<summary><b>Signal</b></summary>
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
**1. Install signal-cli**
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
```bash
signal-cli -u +1234567890 register
signal-cli -u +1234567890 verify <CODE>
```
Start the daemon:
```bash
signal-cli -a +1234567890 daemon --http localhost:8080
```
**2. Configure**
```json
{
"channels": {
"signal": {
"enabled": true,
"phoneNumber": "+1234567890",
"daemonHost": "localhost",
"daemonPort": 8080,
"dm": {
"enabled": true,
"policy": "open"
},
"group": {
"enabled": true,
"policy": "open",
"requireMention": true
}
}
}
}
```
> - `phoneNumber`: Your registered Signal phone number.
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
**3. Run**
```bash
nanobot gateway
```
> [!TIP]
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
</details>
+3 -2
View File
@@ -148,6 +148,7 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
@@ -168,7 +169,7 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
<details>
<summary><b>Skywork / APIFree</b></summary>
Skywork uses the OpenAI-compatible APIFree API endpoint. Configure the provider
Skywork uses APIFree's OpenAI-compatible Agent API endpoint. Configure the provider
once, then use Skywork model IDs such as `skywork-ai/skyclaw-v1`.
```json
@@ -176,7 +177,7 @@ once, then use Skywork model IDs such as `skywork-ai/skyclaw-v1`.
"providers": {
"skywork": {
"apiKey": "${SKYWORK_API_KEY}",
"apiBase": "https://api.apifree.ai/v1"
"apiBase": "https://api.apifree.ai/agent/v1"
}
},
"agents": {
+28 -3
View File
@@ -23,7 +23,7 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
}
```
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, and Gemini configuration examples.
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, and StepFun configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -46,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `stepfun` |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -168,6 +168,31 @@ For reference-image edits, use a Gemini Flash image model:
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
### Ollama
Ollama's experimental native image generation API works with local servers and hosted ollama.com models. Local access at `http://localhost:11434/api` does not require an API key; set `providers.ollama.apiKey` only when targeting `https://ollama.com/api`.
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/api"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "ollama",
"model": "x/z-image-turbo",
"defaultAspectRatio": "16:9",
"defaultImageSize": "2K"
}
}
}
```
Ollama maps `defaultAspectRatio` and `defaultImageSize` to native `width` and `height` values. Reference images are not supported by this integration.
### StepFun
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
@@ -274,7 +299,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, or `stepfun` |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, or `stepfun` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+19 -3
View File
@@ -2,9 +2,10 @@
nanobot - A lightweight AI agent framework
"""
from importlib.metadata import PackageNotFoundError, version as _pkg_version
from pathlib import Path
import tomllib
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from pathlib import Path
def _read_pyproject_version() -> str | None:
@@ -27,6 +28,21 @@ def _resolve_version() -> str:
__version__ = _resolve_version()
__logo__ = "🐈"
from nanobot.nanobot import Nanobot, RunResult
_LAZY_EXPORTS = {
"Nanobot": ".nanobot",
"RunResult": ".nanobot",
}
def __getattr__(name: str):
module_path = _LAZY_EXPORTS.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(module_path, __name__)
val = getattr(mod, name)
globals()[name] = val
return val
__all__ = ["Nanobot", "RunResult"]
+26 -200
View File
@@ -2,7 +2,6 @@
import base64
import mimetypes
import os
import platform
from contextlib import suppress
from importlib.resources import files as pkg_files
@@ -11,16 +10,11 @@ from typing import Any, Mapping, Sequence
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
from nanobot.config.schema import InputLimitsConfig
from nanobot.session.goal_state import goal_state_runtime_lines
from nanobot.utils.helpers import (
audio_format_for_api,
audio_mime_compat,
current_time_str,
detect_audio_mime,
detect_image_mime,
truncate_text,
video_mime_compat,
)
from nanobot.utils.prompt_templates import render_template
@@ -28,18 +22,17 @@ from nanobot.utils.prompt_templates import render_template
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
_RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None, input_limits: InputLimitsConfig | None = None):
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace
self.timezone = timezone
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace, disabled_skills=set(disabled_skills) if disabled_skills else None)
self.input_limits = input_limits or InputLimitsConfig()
def build_system_prompt(
self,
@@ -54,6 +47,8 @@ class ContextBuilder:
if bootstrap:
parts.append(bootstrap)
parts.append(render_template("agent/tool_contract.md"))
memory = self.memory.get_memory_context()
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}")
@@ -149,28 +144,6 @@ class ContextBuilder:
return content.strip() == tpl.read_text(encoding="utf-8").strip()
return False
@staticmethod
def _file_size_ok(p: Path, max_bytes: int) -> bool | None:
"""Check file size via stat without reading into memory.
Returns True if size is within limit, False if oversized,
None if file cannot be stat'd (caller should try read_bytes instead).
"""
try:
return os.stat(p).st_size <= max_bytes
except OSError:
return None
@staticmethod
def _encode_image_block(raw: bytes, mime: str, path: Path) -> dict[str, Any]:
"""Base64-encode file bytes into an image_url content block."""
b64 = base64.b64encode(raw).decode()
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(path)},
}
def build_messages(
self,
history: list[dict[str, Any]],
@@ -183,12 +156,14 @@ class ContextBuilder:
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
current_runtime_lines: Sequence[str] | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
extra = goal_state_runtime_lines(session_metadata)
extra = [
*goal_state_runtime_lines(session_metadata),
]
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,
@@ -196,12 +171,7 @@ class ContextBuilder:
sender_id=sender_id,
supplemental_lines=extra or None,
)
user_content = self._build_user_content(
current_message, media,
supports_vision=supports_vision,
supports_audio=supports_audio,
supports_video=supports_video,
)
user_content = self._build_user_content(current_message, media)
# Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
@@ -223,171 +193,27 @@ class ContextBuilder:
messages.append({"role": current_role, "content": merged})
return messages
def _build_user_content(
self,
text: str,
media: list[str] | None,
*,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
) -> str | list[dict[str, Any]]:
"""Build user message content with optional media blocks.
Args:
text: The user text message.
media: List of file paths to media files.
supports_vision: True=model supports images, False=use placeholder,
None=unconfigured (send images as before, let
provider/retry handle degradation).
supports_audio: True=model supports native audio, False/None=skip
(channel layer already transcribed).
supports_video: True=model supports native video, False/None=use
[file: path] placeholder.
"""
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
"""Build user message content with optional base64-encoded images."""
if not media:
return text
blocks: list[dict[str, Any]] = []
notes: list[str] = []
limits = self.input_limits
# Enforce image count limit
max_images = limits.max_input_images
image_count = 0
image_media = []
non_image_media = []
images = []
for path in media:
p = Path(path)
guessed_mime = mimetypes.guess_type(path)[0] or ""
if guessed_mime.startswith("image/"):
image_count += 1
if image_count <= max_images:
image_media.append(path)
else:
non_image_media.append(path)
if image_count > max_images:
extra = image_count - max_images
noun = "image" if extra == 1 else "images"
notes.append(
f"[Skipped {extra} {noun}: "
f"only the first {max_images} images are included]"
)
# Process images
for path in image_media:
p = Path(path)
if not p.is_file():
continue
# When explicitly marked as non-vision, downgrade to text placeholder
if supports_vision is False:
blocks.append({"type": "text", "text": f"[image: {p}]"})
raw = p.read_bytes()
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"):
continue
b64 = base64.b64encode(raw).decode()
images.append({
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
size_ok = self._file_size_ok(p, limits.max_input_image_bytes)
if size_ok is False:
size_mb = limits.max_input_image_bytes // (1024 * 1024)
notes.append(f"[Skipped image: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped image: unable to read ({p.name or path})]")
continue
img_mime = detect_image_mime(raw[:32]) or mimetypes.guess_type(path)[0]
if not img_mime or not img_mime.startswith("image/"):
notes.append(f"[Skipped image: unsupported or invalid image format ({p.name})]")
continue
blocks.append(self._encode_image_block(raw, img_mime, p))
# Process non-image media (audio, video, unknown)
audio_count = 0
video_count = 0
for path in non_image_media:
p = Path(path)
if not p.is_file():
continue
guessed_mime = mimetypes.guess_type(path)[0] or ""
is_audio = guessed_mime.startswith("audio/")
is_video = guessed_mime.startswith("video/")
# Pre-check file size via stat to avoid reading oversized files into memory.
# Determine the relevant byte limit based on detected media type.
_size_limit = 0
if is_audio or is_video:
_size_limit = limits.max_input_audio_bytes if is_audio else limits.max_input_video_bytes
_stat_size_ok = self._file_size_ok(p, _size_limit) if _size_limit else None
if _stat_size_ok is False:
size_mb = _size_limit // (1024 * 1024)
label = "audio" if is_audio else "video"
notes.append(f"[Skipped {label}: file too large ({p.name}, limit {size_mb} MB)]")
continue
try:
raw = p.read_bytes()
except OSError:
notes.append(f"[Skipped file: unable to read ({p.name or path})]")
continue
# Audio detection: by magic bytes or by filename
# Always pass filename so fallback can match when magic bytes fail
audio_mime = detect_audio_mime(raw[:32], filename=path)
if audio_mime or is_audio:
if supports_audio is True and audio_mime_compat(audio_mime):
audio_count += 1
if audio_count > limits.max_input_audios:
if audio_count == limits.max_input_audios + 1:
notes.append(
f"[Skipped audio: only {limits.max_input_audios} audio file(s) allowed]"
)
continue
if len(raw) > limits.max_input_audio_bytes:
size_mb = limits.max_input_audio_bytes // (1024 * 1024)
notes.append(f"[Skipped audio: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "input_audio",
"input_audio": {"data": b64, "format": audio_format_for_api(audio_mime)},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[audio: {p}]"})
continue
# Video detection (already classified above)
if is_video:
if supports_video is True and video_mime_compat(guessed_mime):
video_count += 1
if video_count > limits.max_input_videos:
if video_count == limits.max_input_videos + 1:
notes.append(
f"[Skipped video: only {limits.max_input_videos} video file(s) allowed]"
)
continue
if len(raw) > limits.max_input_video_bytes:
size_mb = limits.max_input_video_bytes // (1024 * 1024)
notes.append(f"[Skipped video: file too large ({p.name}, limit {size_mb} MB)]")
continue
b64 = base64.b64encode(raw).decode()
blocks.append({
"type": "video_url",
"video_url": {"url": f"data:{guessed_mime};base64,{b64}"},
"_meta": {"path": str(p)},
})
else:
blocks.append({"type": "text", "text": f"[video: {p}]"})
continue
# Unknown files are silently ignored (preserves pre-multimodal behaviour)
continue
note_text = "\n".join(notes).strip()
text_block = text if not note_text else (f"{note_text}\n\n{text}" if text else note_text)
if not blocks:
return text_block
return blocks + [{"type": "text", "text": text_block}]
if not images:
return text
return images + [{"type": "text", "text": text}]
+5 -27
View File
@@ -28,6 +28,7 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.cli_apps import utils as cli_app_utils
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
@@ -59,7 +60,6 @@ if TYPE_CHECKING:
UNIFIED_SESSION_KEY = "unified:default"
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
@@ -190,10 +190,6 @@ class AgentLoop:
model_preset: str | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
input_limits: Any = None,
supports_vision: bool | None = None,
supports_audio: bool | None = None,
supports_video: bool | None = None,
):
from nanobot.config.schema import ToolsConfig
@@ -231,10 +227,6 @@ class AgentLoop:
self.tools_config = _tc
self.web_config = _tc.web
self.exec_config = _tc.exec
self.input_limits = input_limits or _tc.input_limits
self._supports_vision = supports_vision
self._supports_audio = supports_audio
self._supports_video = supports_video
self._image_generation_provider_configs = dict(image_generation_provider_configs or {})
if (
image_generation_provider_config is not None
@@ -248,7 +240,7 @@ class AgentLoop:
self._pending_turn_latency_ms: dict[str, int] = {}
self._extra_hooks: list[AgentHook] = hooks or []
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills, input_limits=self.input_limits)
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
self._webui_turns = WebuiTurnCoordinator(
bus=self.bus,
@@ -374,10 +366,6 @@ class AgentLoop:
model_preset=defaults.model_preset,
provider_snapshot_loader=provider_snapshot_loader,
preset_snapshot_loader=preset_snapshot_loader,
input_limits=config.tools.input_limits,
supports_vision=defaults.supports_vision(defaults.model),
supports_audio=defaults.supports_audio(defaults.model),
supports_video=defaults.supports_video(defaults.model),
**extra,
)
@@ -580,7 +568,7 @@ class AgentLoop:
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra)
@@ -605,10 +593,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
)
async def _dispatch_command_inline(
@@ -1073,10 +1058,7 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata,
supports_vision=self._supports_vision,
supports_audio=self._supports_audio,
supports_video=self._supports_video,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1422,10 +1404,6 @@ class AgentLoop:
filtered.append({"type": "text", "text": image_placeholder_text(path)})
continue
if block.get("type") in ("input_audio", "video_url"):
filtered.append(LLMProvider._media_placeholder(block["type"], block))
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if should_truncate_text and len(text) > self.max_tool_result_chars:
+23 -13
View File
@@ -19,7 +19,8 @@ from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker,
prepare_file_edit_tracker as _prepare_file_edit_tracker,
prepare_file_edit_trackers,
StreamingFileEditTracker,
)
from nanobot.utils.helpers import (
@@ -58,11 +59,14 @@ _SNIP_SAFETY_BUFFER = 1024
_MICROCOMPACT_KEEP_RECENT = 10
_MICROCOMPACT_MIN_CHARS = 500
_COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep",
"web_search", "web_fetch", "list_dir",
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@dataclass(slots=True)
@@ -857,8 +861,8 @@ class AgentRunner:
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_tracker = (
prepare_file_edit_tracker(
file_edit_trackers = (
prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
@@ -868,13 +872,13 @@ class AgentRunner:
if progress_callback is not None
else None
)
if file_edit_tracker is not None and progress_callback is not None:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
)],
) for file_edit_tracker in file_edit_trackers],
)
try:
if tool is not None:
@@ -884,10 +888,13 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
if file_edit_tracker is not None and progress_callback is not None:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_error_event(file_edit_tracker, str(exc))],
[
build_file_edit_error_event(file_edit_tracker, str(exc))
for file_edit_tracker in file_edit_trackers
],
)
event = {
"name": tool_call.name,
@@ -910,10 +917,13 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
if file_edit_tracker is not None and progress_callback is not None:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_error_event(file_edit_tracker, result)],
[
build_file_edit_error_event(file_edit_tracker, result)
for file_edit_tracker in file_edit_trackers
],
)
event = {
"name": tool_call.name,
@@ -933,13 +943,13 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
if file_edit_tracker is not None and progress_callback is not None:
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
)],
) for file_edit_tracker in file_edit_trackers],
)
detail = "" if result is None else str(result)
+352
View File
@@ -0,0 +1,352 @@
"""Apply file edits by providing structured edit instructions."""
from __future__ import annotations
import difflib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
ObjectSchema,
StringSchema,
tool_parameters_schema,
)
@dataclass(slots=True)
class _PatchSummary:
action: str
path: str
added: int = 0
deleted: int = 0
class _PatchError(ValueError):
pass
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _validate_relative_path(path: str) -> str:
normalized = path.strip()
if not normalized:
raise _PatchError("patch path cannot be empty")
if "\0" in normalized:
raise _PatchError(f"patch path contains a null byte: {path!r}")
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
raise _PatchError(f"patch path must be relative: {path}")
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
raise _PatchError(f"patch path must not contain '..': {path}")
return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int:
if not text:
return 0
return len(text.splitlines())
def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
deleted = 0
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag in ("replace", "delete"):
deleted += i2 - i1
if tag in ("replace", "insert"):
added += j2 - j1
return added, deleted
def _format_summary(summary: _PatchSummary) -> str:
stats = ""
if summary.added or summary.deleted:
stats = f" (+{summary.added}/-{summary.deleted})"
return f"- {summary.action} {summary.path}{stats}"
@tool_parameters(
tool_parameters_schema(
edits=ArraySchema(
items=ObjectSchema(
path=StringSchema("Relative path to the file to edit."),
action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
enum=["replace", "add", "delete"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace and delete.",
nullable=True,
),
new_text=StringSchema(
"Text to replace with or append. Required for replace and add.",
nullable=True,
),
required=["path", "action"],
),
description="List of edits to apply. Each edit specifies a file and the change to make.",
min_items=1,
max_items=20,
),
dry_run=BooleanSchema(
description="Validate and summarize the patch without writing files.",
default=False,
),
required=["edits"],
)
)
class ApplyPatchTool(_FsTool):
"""Apply file edits by providing structured edit instructions."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "apply_patch"
@property
def description(self) -> str:
return (
"Default tool for code edits. Supports multi-file changes in a single call. "
"Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
"Use edit_file only for small exact replacements on a single file."
)
async def execute(
self,
edits: list[dict] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
try:
if not edits:
raise _PatchError("must provide edits")
writes: dict[Path, str] = {}
deletes: set[Path] = set()
summaries: list[_PatchSummary] = []
for edit in edits:
if not isinstance(edit, dict):
raise _PatchError("each edit must be an object")
raw_path = edit.get("path")
if not isinstance(raw_path, str):
raise _PatchError("path required for edit")
path = _validate_relative_path(raw_path)
action = edit.get("action")
if not isinstance(action, str):
raise _PatchError(f"action required for edit: {path}")
source = self._resolve(path)
if action == "add":
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for add: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
exists = True
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
exists = True
else:
content = ""
exists = False
if exists:
uses_crlf = "\r\n" in content
new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
new_norm = new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
summaries.append(
_PatchSummary(
action=action_name, path=path, added=added, deleted=deleted
)
)
elif action == "replace":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
new_norm = (
norm_content[:pos]
+ new_text.replace("\r\n", "\n")
+ norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
elif action == "delete":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for delete: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
if norm_old == norm_content:
deletes.add(source)
writes.pop(source, None)
added, deleted = 0, _text_line_count(content)
summaries.append(
_PatchSummary(
action="delete", path=path, added=added, deleted=deleted
)
)
else:
new_norm = (
norm_content[:pos] + norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
else:
raise _PatchError(f"unknown action: {action}")
if dry_run:
return "Patch dry-run succeeded:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
backups: dict[Path, bytes | None] = {}
for path in set(writes) | deletes:
backups[path] = path.read_bytes() if path.exists() else None
try:
for path in deletes:
if path.exists():
path.unlink()
for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
except Exception:
for path, data in backups.items():
if data is None:
if path.exists():
path.unlink()
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
raise
for path in set(writes) | deletes:
self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
except PermissionError as exc:
return f"Error: {exc}"
except _PatchError as exc:
return f"Error applying patch: {exc}"
except Exception as exc:
return f"Error applying patch: {exc}"
+127
View File
@@ -0,0 +1,127 @@
"""Controlled runner for installed CLI Apps."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
class CliAppsToolConfig(Base):
"""CLI Apps tool configuration."""
enable: bool = True
install_timeout: int = Field(default=300, ge=1, le=3600)
run_timeout: int = Field(default=60, ge=1, le=600)
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
@tool_parameters(
tool_parameters_schema(
required=["name"],
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
args=ArraySchema(
StringSchema("One command-line argument."),
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
nullable=True,
),
json=BooleanSchema(
description="Whether to prepend --json when supported by the CLI.",
default=False,
nullable=True,
),
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
timeout=IntegerSchema(
description="Timeout in seconds for this CLI call.",
minimum=1,
maximum=600,
nullable=True,
),
)
)
class CliAppsTool(Tool):
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
config_key = "cli_apps"
_scopes = {"core", "subagent"}
@classmethod
def config_cls(cls):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
restrict_to_workspace=ctx.config.restrict_to_workspace,
runtime=CliAppsRuntimeConfig(
install_timeout=cfg.install_timeout,
run_timeout=cfg.run_timeout,
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
),
)
def __init__(
self,
*,
workspace: Path,
restrict_to_workspace: bool = False,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = workspace
self.restrict_to_workspace = restrict_to_workspace
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def name(self) -> str:
return "run_cli_app"
@property
def description(self) -> str:
try:
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
except Exception:
installed = []
installed_note = (
f" Installed Settings CLI Apps: {', '.join(installed)}."
if installed
else " No Settings CLI Apps are currently installed."
)
return (
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
"unknown names are rejected. Execution uses argv, not shell."
+ installed_note
)
async def execute(
self,
name: str,
args: list[str] | None = None,
json: bool | None = False,
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
try:
return manager.run(
name,
args=args or [],
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"
+591
View File
@@ -0,0 +1,591 @@
"""Session support for long-running exec workflows."""
from __future__ import annotations
import asyncio
import shutil
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000
DEFAULT_WAIT_FOR_MS = 10_000
MAX_WAIT_FOR_MS = 120_000
DEFAULT_MAX_OUTPUT_CHARS = 10_000
MAX_OUTPUT_CHARS = 50_000
@dataclass(slots=True)
class _SessionPoll:
output: str
done: bool
exit_code: int | None
elapsed_s: float = 0.0
timed_out: bool = False
terminated: bool = False
stdin_closed: bool = False
truncated_chars: int = 0
@dataclass(slots=True)
class ExecSessionInfo:
session_id: str
command: str
cwd: str
elapsed_s: float
idle_s: float
remaining_s: float
returncode: int | None
class _ExecSession:
def __init__(
self,
*,
session_id: str,
process: asyncio.subprocess.Process,
command: str,
cwd: str,
timeout: int,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.started_at = time.monotonic()
self.deadline = time.monotonic() + timeout
self.last_access = time.monotonic()
self._chunks: list[str] = []
self._lock = asyncio.Lock()
self._timed_out = False
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
async def _read_stream(
self,
stream: asyncio.StreamReader | None,
prefix: str,
) -> None:
if stream is None:
return
first = True
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
if prefix and first:
text = prefix + text
first = False
async with self._lock:
self._chunks.append(text)
async def write(self, chars: str) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
try:
self.process.stdin.write(chars.encode("utf-8"))
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
return "session stdin is closed"
return None
async def close_stdin(self) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
self.process.stdin.close()
with suppress(BrokenPipeError, ConnectionResetError):
await self.process.stdin.wait_closed()
return None
async def poll(
self,
yield_time_ms: int,
max_output_chars: int,
*,
terminated: bool = False,
stdin_closed: bool = False,
) -> _SessionPoll:
self.last_access = time.monotonic()
if yield_time_ms > 0 and self.process.returncode is None:
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
if self.process.returncode is None and time.monotonic() >= self.deadline:
self._timed_out = True
await self.kill()
if self.process.returncode is not None:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.gather(self._stdout_task, self._stderr_task),
timeout=2.0,
)
async with self._lock:
output = "".join(self._chunks)
self._chunks.clear()
output, truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=output,
done=self.process.returncode is not None,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
timed_out=self._timed_out,
terminated=terminated,
stdin_closed=stdin_closed,
truncated_chars=truncated,
)
async def kill(self) -> None:
if self.process.returncode is not None:
return
self.process.kill()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.process.wait(), timeout=5.0)
class ExecSessionManager:
def __init__(self, *, max_sessions: int = 8, idle_timeout: int = 1800) -> None:
self.max_sessions = max_sessions
self.idle_timeout = idle_timeout
self._sessions: dict[str, _ExecSession] = {}
self._lock = asyncio.Lock()
async def start(
self,
*,
command: str,
cwd: str,
env: dict[str, str],
timeout: int,
shell_program: str | None,
login: bool,
yield_time_ms: int,
max_output_chars: int,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
if len(self._sessions) >= self.max_sessions:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
process = await self._spawn(command, cwd, env, shell_program, login)
session_id = uuid.uuid4().hex[:12]
session = _ExecSession(
session_id=session_id,
process=process,
command=command,
cwd=cwd,
timeout=timeout,
)
self._sessions[session_id] = session
poll = await session.poll(yield_time_ms, max_output_chars)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return session_id, poll
async def write(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if chars:
error = await session.write(chars)
if error:
raise RuntimeError(error)
stdin_closed = False
if close_stdin:
error = await session.close_stdin()
if error:
raise RuntimeError(error)
stdin_closed = True
if terminate:
await session.kill()
poll = await session.poll(
yield_time_ms,
max_output_chars,
terminated=terminate,
stdin_closed=stdin_closed,
)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
return [
ExecSessionInfo(
session_id=session_id,
command=session.command,
cwd=session.cwd,
elapsed_s=max(0.0, now - session.started_at),
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
)
for session_id, session in sorted(self._sessions.items())
]
async def _cleanup_locked(self) -> None:
now = time.monotonic()
stale = [
session_id
for session_id, session in self._sessions.items()
if now - session.last_access > self.idle_timeout
]
for session_id in stale:
session = self._sessions.pop(session_id)
await session.kill()
async def _spawn(
self,
command: str,
cwd: str,
env: dict[str, str],
shell_program: str | None,
login: bool,
) -> asyncio.subprocess.Process:
from nanobot.agent.tools import shell
if shell._IS_WINDOWS:
return await asyncio.create_subprocess_shell(
command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
if login and shell_program.rsplit("/", 1)[-1] in {"bash", "zsh"}:
args.append("-l")
args.extend(["-c", command])
return await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
DEFAULT_EXEC_SESSION_MANAGER = ExecSessionManager()
def clamp_session_int(value: int | None, default: int, minimum: int, maximum: int) -> int:
if value is None:
return default
return min(max(value, minimum), maximum)
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
if len(output) <= max_output_chars:
return output, 0
half = max_output_chars // 2
omitted = len(output) - max_output_chars
return (
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts = [poll.output] if poll.output else []
if poll.truncated_chars:
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
if poll.timed_out:
parts.append("Error: Command timed out; session was terminated.")
if poll.terminated and not poll.timed_out:
parts.append("Session terminated.")
if poll.stdin_closed:
parts.append("Stdin closed.")
if poll.done:
parts.append(f"Exit code: {poll.exit_code}")
else:
parts.append(f"Process running. session_id: {session_id}")
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
return "\n".join(parts) if parts else "(no output yet)"
@tool_parameters(
tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
chars=StringSchema(
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.",
nullable=True,
),
close_stdin=BooleanSchema(
description="Close stdin after writing chars. Useful for commands waiting for EOF.",
default=False,
),
terminate=BooleanSchema(
description="Terminate the running exec session.",
default=False,
),
yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
),
wait_for=StringSchema(
"Optional text to wait for in output before returning. "
"Useful for interactive commands and dev servers.",
nullable=True,
),
wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0,
maximum=MAX_WAIT_FOR_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
required=["session_id"],
)
)
class WriteStdinTool(Tool):
"""Write to or poll a running exec session."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def exclusive(self) -> bool:
return True
@property
def name(self) -> str:
return "write_stdin"
@property
def description(self) -> str:
return (
"Interact with a running exec session created by exec with "
"yield_time_ms. Use chars='' to poll without writing, chars to send "
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
"process. Use wait_for with wait_timeout_ms for dev servers, test "
"watchers, and prompts where you need to wait for expected output. "
"Do not use this to start new commands; start them with exec."
)
async def execute(
self,
session_id: str,
chars: str | None = None,
close_stdin: bool = False,
terminate: bool = False,
yield_time_ms: int | None = None,
wait_for: str | None = None,
wait_timeout_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
try:
if max_output_chars is None:
max_output_chars = max_output_tokens
output_limit = clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
)
if wait_for:
return await self._wait_for_output(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
wait_for=wait_for,
wait_timeout_ms=clamp_session_int(
wait_timeout_ms,
DEFAULT_WAIT_FOR_MS,
0,
MAX_WAIT_FOR_MS,
),
max_output_chars=output_limit,
)
poll = await self._manager.write(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
)
return format_session_poll(session_id, poll)
except KeyError:
return f"Error: exec session not found: {session_id}"
except Exception as exc:
return f"Error writing to exec session: {exc}"
async def _wait_for_output(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
wait_for: str,
wait_timeout_ms: int,
max_output_chars: int,
) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000)
aggregate: list[str] = []
first = True
poll: _SessionPoll | None = None
while True:
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
step_ms = min(500, remaining_ms)
poll = await self._manager.write(
session_id=session_id,
chars=chars if first else None,
close_stdin=close_stdin if first else False,
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
)
first = False
if poll.output:
aggregate.append(poll.output)
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
return format_session_poll(session_id, poll)
if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}"
return result
@tool_parameters(tool_parameters_schema())
class ListExecSessionsTool(Tool):
"""List active exec sessions."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def name(self) -> str:
return "list_exec_sessions"
@property
def description(self) -> str:
return (
"List active long-running exec sessions, including session_id, cwd, "
"elapsed time, idle time, remaining timeout, and command preview. "
"Use this to recover a session_id after context shifts before "
"polling, writing stdin, or terminating with write_stdin."
)
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
if not sessions:
return "No active exec sessions."
lines = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
command = command[:119] + "..."
status = "exited" if info.returncode is not None else "running"
lines.append(
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
f"| cwd={info.cwd} | {command}"
)
return "\n".join(lines)
except Exception as exc:
return f"Error listing exec sessions: {exc}"
+106 -22
View File
@@ -132,6 +132,10 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
minimum=1,
),
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
force=BooleanSchema(
description="Bypass same-file read deduplication and return content again.",
default=False,
),
required=["path"],
)
)
@@ -154,7 +158,11 @@ class ReadFileTool(_FsTool):
"Text output format: LINE_NUM|CONTENT. "
"Images return visual content for analysis. "
"Supports PDF, DOCX, XLSX, PPTX documents. "
"Use find_files/list_dir first when the path is uncertain. "
"Read the relevant range before editing so replacements or patches "
"are based on current content. "
"Use offset and limit for large text files. "
"Use force=true to re-read content even if unchanged. "
"Reads exceeding ~128K chars are truncated."
)
@@ -162,7 +170,15 @@ class ReadFileTool(_FsTool):
def read_only(self) -> bool:
return True
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
async def execute(
self,
path: str | None = None,
offset: int = 1,
limit: int | None = None,
pages: str | None = None,
force: bool = False,
**kwargs: Any,
) -> Any:
try:
if not path:
return "Error reading file: Unknown path"
@@ -202,7 +218,13 @@ class ReadFileTool(_FsTool):
current_mtime = os.path.getmtime(fp)
except OSError:
current_mtime = 0.0
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
if (
not force
and entry
and entry.can_dedup
and entry.offset == offset
and entry.limit == limit
):
if current_mtime != entry.mtime:
# File was modified externally - force full read and mark as not dedupable
entry.can_dedup = False
@@ -365,9 +387,10 @@ class WriteFileTool(_FsTool):
@property
def description(self) -> str:
return (
"Write content to a file. Overwrites if the file already exists; "
"creates parent directories as needed. "
"For partial edits, prefer edit_file instead."
"Create a new file or intentionally replace an entire file with "
"the provided content. Overwrites existing files and creates parent "
"directories as needed. For code changes or partial edits, prefer "
"apply_patch; use edit_file only for small exact replacements."
)
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
@@ -657,6 +680,24 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
old_text=StringSchema("The text to find and replace"),
new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1,
nullable=True,
),
line_hint=IntegerSchema(
1,
description="Optional 1-based line hint used to choose the nearest match.",
minimum=1,
nullable=True,
),
expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.",
minimum=1,
nullable=True,
),
required=["path", "old_text", "new_text"],
)
)
@@ -674,10 +715,13 @@ class EditFileTool(_FsTool):
@property
def description(self) -> str:
return (
"Edit a file by replacing old_text with new_text. "
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
"If old_text matches multiple times, you must provide more context "
"or set replace_all=true. Shows a diff of the closest match on failure."
"Perform a small, exact replacement in one file by replacing "
"old_text with new_text. Use this for narrow text substitutions "
"with old_text copied from read_file. For multi-file, structural, "
"or generated code edits, prefer apply_patch. If old_text matches "
"multiple times, provide more context or set occurrence, line_hint, "
"replace_all, and expected_replacements. Shows closest-match "
"diagnostics on failure."
)
@staticmethod
@@ -688,7 +732,8 @@ class EditFileTool(_FsTool):
async def execute(
self, path: str | None = None, old_text: str | None = None,
new_text: str | None = None,
replace_all: bool = False, **kwargs: Any,
replace_all: bool = False, occurrence: int | None = None,
line_hint: int | None = None, expected_replacements: int | None = None, **kwargs: Any,
) -> str:
try:
if not path:
@@ -697,10 +742,12 @@ class EditFileTool(_FsTool):
raise ValueError("Unknown old_text")
if new_text is None:
raise ValueError("Unknown new_text")
# .ipynb detection
if path.endswith(".ipynb"):
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
if occurrence is not None and occurrence < 1:
return "Error: occurrence must be >= 1."
if line_hint is not None and line_hint < 1:
return "Error: line_hint must be >= 1."
if expected_replacements is not None and expected_replacements < 1:
return "Error: expected_replacements must be >= 1."
fp = self._resolve(path)
@@ -743,15 +790,42 @@ class EditFileTool(_FsTool):
if not matches:
return self._not_found_msg(old_text, content, path)
count = len(matches)
if replace_all and occurrence is not None:
return "Error: occurrence cannot be used with replace_all=true."
if replace_all and line_hint is not None:
return "Error: line_hint cannot be used with replace_all=true."
if occurrence is not None and line_hint is not None:
return "Error: line_hint cannot be used with occurrence."
if count > 1 and not replace_all:
line_numbers = [match.line for match in matches]
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
if len(line_numbers) > 3:
preview += ", ..."
location_hint = f" at {preview}" if preview else ""
if occurrence is not None:
if occurrence > count:
return (
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times."
)
elif line_hint is not None:
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return (
f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times."
)
else:
line_numbers = [match.line for match in matches]
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
if len(line_numbers) > 3:
preview += ", ..."
location_hint = f" at {preview}" if preview else ""
return (
f"Warning: old_text appears {count} times{location_hint}. "
"Provide more context, set occurrence to choose one match, "
"or set replace_all=true."
)
elif occurrence is not None and occurrence > count:
return (
f"Warning: old_text appears {count} times{location_hint}. "
"Provide more context to make it unique, or set replace_all=true."
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time."
)
norm_new = new_text.replace("\r\n", "\n")
@@ -760,7 +834,17 @@ class EditFileTool(_FsTool):
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
norm_new = self._strip_trailing_ws(norm_new)
selected = matches if replace_all else matches[:1]
if replace_all:
selected = matches
elif line_hint is not None:
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
else:
selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements:
return (
f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}."
)
new_content = content
for match in reversed(selected):
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
-9
View File
@@ -130,12 +130,6 @@ class ImageGenerationTool(Tool):
}
return cls(**kwargs)
def _missing_api_key_error(self) -> str:
cls = get_image_gen_provider(self.config.provider)
if cls and cls.missing_key_message:
return f"Error: {cls.missing_key_message}"
return f"Error: {self.config.provider} API key is not configured."
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
@@ -173,9 +167,6 @@ class ImageGenerationTool(Tool):
client = self._provider_client()
if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'"
provider = self._provider_config()
if not provider or not provider.api_key:
return self._missing_api_key_error()
requested = count or 1
if requested > self.config.max_images_per_turn:
-162
View File
@@ -1,162 +0,0 @@
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
from __future__ import annotations
import json
import uuid
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.filesystem import _FsTool
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
cell: dict[str, Any] = {
"cell_type": cell_type,
"source": source,
"metadata": {},
}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
if generate_id:
cell["id"] = uuid.uuid4().hex[:8]
return cell
def _make_empty_notebook() -> dict:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
@tool_parameters(
tool_parameters_schema(
path=StringSchema("Path to the .ipynb notebook file"),
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
new_source=StringSchema("New source content for the cell"),
cell_type=StringSchema(
"Cell type: 'code' or 'markdown' (default: code)",
enum=["code", "markdown"],
),
edit_mode=StringSchema(
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
enum=["replace", "insert", "delete"],
),
required=["path", "cell_index"],
)
)
class NotebookEditTool(_FsTool):
"""Edit Jupyter notebook cells: replace, insert, or delete."""
_scopes = {"core"}
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
@property
def name(self) -> str:
return "notebook_edit"
@property
def description(self) -> str:
return (
"Edit a Jupyter notebook (.ipynb) cell. "
"Modes: replace (default) replaces cell content, "
"insert adds a new cell after the target index, "
"delete removes the cell at the index. "
"cell_index is 0-based."
)
async def execute(
self,
path: str | None = None,
cell_index: int = 0,
new_source: str = "",
cell_type: str = "code",
edit_mode: str = "replace",
**kwargs: Any,
) -> str:
try:
if not path:
return "Error: path is required"
if not path.endswith(".ipynb"):
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
if edit_mode not in self._VALID_EDIT_MODES:
return (
f"Error: Invalid edit_mode '{edit_mode}'. "
"Use one of: replace, insert, delete."
)
if cell_type not in self._VALID_CELL_TYPES:
return (
f"Error: Invalid cell_type '{cell_type}'. "
"Use one of: code, markdown."
)
fp = self._resolve(path)
# Create new notebook if file doesn't exist and mode is insert
if not fp.exists():
if edit_mode != "insert":
return f"Error: File not found: {path}"
nb = _make_empty_notebook()
cell = _new_cell(new_source, cell_type, generate_id=True)
nb["cells"].append(cell)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully created {fp} with 1 cell"
try:
nb = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
return f"Error: Failed to parse notebook: {e}"
cells = nb.get("cells", [])
nbformat_minor = nb.get("nbformat_minor", 0)
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
if edit_mode == "delete":
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells.pop(cell_index)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully deleted cell {cell_index} from {fp}"
if edit_mode == "insert":
insert_at = min(cell_index + 1, len(cells))
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
cells.insert(insert_at, cell)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully inserted cell at index {insert_at} in {fp}"
# Default: replace
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells[cell_index]["source"] = new_source
if cell_type and cells[cell_index].get("cell_type") != cell_type:
cells[cell_index]["cell_type"] = cell_type
if cell_type == "code":
cells[cell_index].setdefault("outputs", [])
cells[cell_index].setdefault("execution_count", None)
elif "outputs" in cells[cell_index]:
del cells[cell_index]["outputs"]
cells[cell_index].pop("execution_count", None)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully edited cell {cell_index} in {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing notebook: {e}"
+169 -2
View File
@@ -1,4 +1,4 @@
"""Search tools: grep."""
"""Search tools: file discovery and grep."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
_DEFAULT_FILE_HEAD_LIMIT = 200
T = TypeVar("T")
_TYPE_GLOB_MAP = {
"py": ("*.py", "*.pyi"),
@@ -88,6 +89,14 @@ def _matches_type(name: str, file_type: str | None) -> bool:
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
def _matches_query(rel_path: str, query: str | None) -> bool:
if not query:
return True
haystack = rel_path.lower()
terms = [part for part in query.lower().split() if part]
return all(term in haystack for term in terms)
class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
@@ -109,6 +118,163 @@ class _SearchTool(_FsTool):
yield current / filename
class FindFilesTool(_SearchTool):
"""Find files by path fragment, glob, or type."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "find_files"
@property
def description(self) -> str:
return (
"Find files by path fragment, glob, or file type. "
"Use this before read_file when you need to locate files, and "
"prefer it over shell find/ls for ordinary workspace discovery. "
"Returns workspace-relative paths and skips common dependency/build "
"directories."
)
@property
def read_only(self) -> bool:
return True
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory or file to search in (default '.')",
},
"query": {
"type": "string",
"description": (
"Optional case-insensitive path fragment search. "
"Whitespace-separated terms must all be present."
),
},
"glob": {
"type": "string",
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
},
"type": {
"type": "string",
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
},
"include_dirs": {
"type": "boolean",
"description": "Include matching directories as well as files (default false)",
},
"sort": {
"type": "string",
"enum": ["path", "modified"],
"description": "Sort by path or most recently modified first (default path)",
},
"head_limit": {
"type": "integer",
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
"minimum": 0,
"maximum": 1000,
},
"offset": {
"type": "integer",
"description": "Skip the first N results before applying head_limit",
"minimum": 0,
"maximum": 100000,
},
},
}
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
if root.is_file():
yield root
return
if include_dirs:
yield root
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
current = Path(dirpath)
if include_dirs and current != root:
yield current
for filename in sorted(filenames):
yield current / filename
async def execute(
self,
path: str = ".",
query: str | None = None,
glob: str | None = None,
type: str | None = None,
include_dirs: bool = False,
sort: str = "path",
head_limit: int | None = None,
offset: int = 0,
**kwargs: Any,
) -> str:
try:
target = self._resolve(path or ".")
if not target.exists():
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}"
if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'"
limit = (
_DEFAULT_FILE_HEAD_LIMIT
if head_limit is None
else None if head_limit == 0 else head_limit
)
root = target if target.is_dir() else target.parent
matches: list[tuple[str, float]] = []
for candidate in self._iter_paths(target, include_dirs=include_dirs):
if candidate.is_dir() and not include_dirs:
continue
rel_path = candidate.relative_to(root).as_posix()
display_path = self._display_path(candidate, root)
name = candidate.name
if glob and not _match_glob(rel_path, name, glob):
continue
if candidate.is_file() and not _matches_type(name, type):
continue
if candidate.is_dir() and type:
continue
if not _matches_query(display_path, query):
continue
try:
mtime = candidate.stat().st_mtime
except OSError:
mtime = 0.0
suffix = "/" if candidate.is_dir() else ""
matches.append((display_path + suffix, mtime))
if sort == "modified":
matches.sort(key=lambda item: (-item[1], item[0]))
else:
matches.sort(key=lambda item: item[0])
paths = [item[0] for item in matches]
paged, truncated = _paginate(paths, limit, offset)
if not paged:
return "No files found"
result = "\n".join(paged)
note = _pagination_note(limit, offset, truncated)
if note:
result += "\n\n" + note
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error finding files: {e}"
class GrepTool(_SearchTool):
"""Search file contents using a regex-like pattern."""
_scopes = {"core", "subagent"}
@@ -125,7 +291,8 @@ class GrepTool(_SearchTool):
return (
"Search file contents with a regex pattern. "
"Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. "
"use content mode for matching lines with context. Prefer this "
"over shell grep for ordinary workspace searches. "
"Skips binary and files >2 MB. Supports glob/type filtering."
)
+223 -51
View File
@@ -8,6 +8,7 @@ import re
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -15,8 +16,17 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.exec_session import (
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
DEFAULT_EXEC_SESSION_MANAGER,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
@@ -44,10 +54,22 @@ class ExecToolConfig(Base):
deny_patterns: list[str] = Field(default_factory=list)
@dataclass(slots=True)
class _PreparedCommand:
command: str
cwd: str
env: dict[str, str]
timeout: int
shell_program: str | None
login: bool
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
@@ -57,7 +79,44 @@ class ExecToolConfig(Base):
minimum=1,
maximum=600,
),
required=["command"],
shell=StringSchema(
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
nullable=True,
),
login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default true).",
default=True,
nullable=True,
),
yield_time_ms=IntegerSchema(
description=(
"Optional milliseconds to wait before returning output. "
"When set, a still-running command returns a session_id that "
"can be polled or written to with write_stdin. Omit this field "
"to keep one-shot exec behavior."
),
minimum=0,
maximum=MAX_YIELD_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
description=(
"Maximum output characters to return when yield_time_ms is used "
"(default 10000, max 50000)."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
max_output_tokens=IntegerSchema(
description=(
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
)
)
class ExecTool(Tool):
@@ -98,6 +157,7 @@ class ExecTool(Tool):
sandbox: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
@@ -125,6 +185,7 @@ class ExecTool(Tool):
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@property
def name(self) -> str:
@@ -150,10 +211,15 @@ class ExecTool(Tool):
def description(self) -> str:
return (
"Execute a shell command and return its output. "
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
"and grep/glob over shell find/grep. "
"Use this for tests, builds, package commands, git commands, and "
"other process execution. Prefer read_file/find_files/grep for "
"inspection and apply_patch/write_file/edit_file for file changes "
"instead of cat, shell find/grep, echo, or sed. "
"Use -y or --yes flags to avoid interactive prompts. "
"Output is truncated at 10 000 chars; timeout defaults to 60s."
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
)
@property
@@ -161,9 +227,111 @@ class ExecTool(Tool):
return True
async def execute(
self, command: str, working_dir: str | None = None,
timeout: int | None = None, **kwargs: Any,
self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None,
timeout: int | None = None, shell: str | None = None,
login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
command = command or cmd
working_dir = working_dir or workdir
if not command:
return "Error: Missing command. Provide command or cmd."
if max_output_chars is None:
max_output_chars = max_output_tokens
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
if isinstance(prepared, str):
return prepared
if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
try:
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=prepared.timeout,
)
except asyncio.TimeoutError:
await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
async def _execute_session(
self,
prepared: _PreparedCommand,
yield_time_ms: int | None,
max_output_chars: int | None,
) -> str:
try:
session_id, poll = await self._session_manager.start(
command=prepared.command,
cwd=prepared.cwd,
env=prepared.env,
timeout=prepared.timeout,
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
),
)
return format_session_poll(session_id, poll)
except Exception as exc:
return f"Error executing command: {exc}"
def _prepare_command(
self,
command: str,
working_dir: str | None = None,
timeout: int | None = None,
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
cwd = working_dir or self.working_dir or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
@@ -211,52 +379,24 @@ class ExecTool(Tool):
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
try:
process = await self._spawn(command, cwd, env)
shell_program, shell_error = self._resolve_shell(shell)
if shell_error:
return shell_error
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=effective_timeout,
)
except asyncio.TimeoutError:
await self._kill_process(process)
return f"Error: Command timed out after {effective_timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = self._MAX_OUTPUT
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
return _PreparedCommand(
command=command,
cwd=cwd,
env=env,
timeout=effective_timeout,
shell_program=shell_program,
login=True if login is None else login,
)
@staticmethod
async def _spawn(
command: str, cwd: str, env: dict[str, str],
shell_program: str | None = None,
login: bool = True,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
@@ -266,20 +406,52 @@ class ExecTool(Tool):
# the raw command string to COMSPEC without re-quoting.
return await asyncio.create_subprocess_shell(
command,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
bash = shutil.which("bash") or "/bin/bash"
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l")
args.extend(["-c", command])
return await asyncio.create_subprocess_exec(
bash, "-l", "-c", command,
*args,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
@staticmethod
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
if not shell:
return None, None
if _IS_WINDOWS:
return None, "Error: shell parameter is not supported on Windows"
if "\0" in shell or "\n" in shell or "\r" in shell:
return None, "Error: shell contains invalid characters"
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
if not path.is_file() or not os.access(path, os.X_OK):
return None, f"Error: shell is not executable: {shell}"
return str(path), None
if "/" in shell or "\\" in shell:
return None, "Error: shell must be a shell name or absolute path"
if shell not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
resolved = shutil.which(shell)
if not resolved:
return None, f"Error: shell not found: {shell}"
return resolved, None
@staticmethod
async def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Kill a subprocess and reap it to prevent zombies."""
@@ -416,7 +588,7 @@ class ExecTool(Tool):
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
+99 -18
View File
@@ -8,7 +8,7 @@ import json
import os
import re
from typing import Any, Callable
from urllib.parse import quote, urlparse
from urllib.parse import quote, urljoin, urlparse
import httpx
from loguru import logger
@@ -78,9 +78,82 @@ def _validate_url(url: str) -> tuple[bool, str]:
def _validate_url_safe(url: str) -> tuple[bool, str]:
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
from nanobot.security.network import validate_url_target
return validate_url_target(url)
async def _get_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, str | None]:
"""GET a URL while validating every redirect target before requesting it."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, f"Redirect blocked: {error_msg}"
response = await client.get(current_url, headers=headers, follow_redirects=False)
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, None
location = response.headers.get("location")
if not location:
return response, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await response.aclose()
return None, f"Redirect blocked: {error_msg}"
await response.aclose()
current_url = next_url
return None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
async def _stream_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, Any | None, str | None]:
"""Open a streamed response while validating every redirect target first."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, None, f"Redirect blocked: {error_msg}"
stream = client.stream(
"GET",
current_url,
headers=headers,
follow_redirects=False,
)
response = await stream.__aenter__()
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, stream, None
location = response.headers.get("location")
if not location:
return response, stream, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await stream.__aexit__(None, None, None)
return None, None, f"Redirect blocked: {error_msg}"
await stream.__aexit__(None, None, None)
current_url = next_url
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
"""Format provider results into shared plaintext output."""
if not items:
@@ -488,19 +561,26 @@ class WebFetchTool(Tool):
# Detect and fetch images directly to avoid Jina's textual image captioning
try:
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
from nanobot.security.network import validate_resolved_url
redir_ok, redir_err = validate_resolved_url(str(r.url))
if not redir_ok:
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
r, stream, redirect_error = await _stream_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
try:
ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"):
r.raise_for_status()
raw = await r.aread()
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
finally:
if stream is not None:
await stream.__aexit__(None, None, None)
except Exception as e:
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
@@ -549,23 +629,22 @@ class WebFetchTool(Tool):
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
"""Local fallback using readability-lxml."""
from readability import Document
try:
async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=MAX_REDIRECTS,
timeout=30.0,
proxy=self.proxy,
) as client:
r = await client.get(url, headers={"User-Agent": self.user_agent})
r, redirect_error = await _get_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
r.raise_for_status()
from nanobot.security.network import validate_resolved_url
redir_ok, redir_err = validate_resolved_url(str(r.url))
if not redir_ok:
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"):
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
@@ -573,6 +652,8 @@ class WebFetchTool(Tool):
if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
from readability import Document
doc = Document(r.text)
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
+28 -32
View File
@@ -172,22 +172,19 @@ def _extract_element_content(element: dict) -> list[str]:
return parts
def _extract_post_content(content_json: dict) -> tuple[str, list[str], list[dict]]:
"""Extract text and media info from Feishu post (rich text) message.
def _extract_post_content(content_json: dict) -> tuple[str, list[str]]:
"""Extract text and image keys from Feishu post (rich text) message.
Handles three payload shapes:
- Direct: {"title": "...", "content": [[...]]}
- Localized: {"zh_cn": {"title": "...", "content": [...]}}
- Wrapped: {"post": {"zh_cn": {"title": "...", "content": [...]}}}
Returns (text, image_keys, media_items) where media_items is a list of
{"tag": "media", "file_key": "..."} dicts for video/file attachments.
"""
def _parse_block(block: dict) -> tuple[str | None, list[str], list[dict]]:
def _parse_block(block: dict) -> tuple[str | None, list[str]]:
if not isinstance(block, dict) or not isinstance(block.get("content"), list):
return None, [], []
texts, images, medias = [], [], []
return None, []
texts, images = [], []
if title := block.get("title"):
texts.append(title)
for row in block["content"]:
@@ -207,36 +204,43 @@ def _extract_post_content(content_json: dict) -> tuple[str, list[str], list[dict
texts.append(f"\n```{lang}\n{code_text}\n```\n")
elif tag == "img" and (key := el.get("image_key")):
images.append(key)
elif tag == "media" and el.get("file_key"):
medias.append({"tag": "media", "file_key": el["file_key"]})
return (" ".join(texts).strip() or None), images, medias
return (" ".join(texts).strip() or None), images
# Unwrap optional {"post": ...} envelope
root = content_json
if isinstance(root, dict) and isinstance(root.get("post"), dict):
root = root["post"]
if not isinstance(root, dict):
return "", [], []
return "", []
# Direct format
if "content" in root:
text, imgs, medias = _parse_block(root)
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(root)
if text or imgs:
return text or "", imgs
# Localized: prefer known locales, then fall back to any dict child
for key in ("zh_cn", "en_us", "ja_jp"):
if key in root:
text, imgs, medias = _parse_block(root[key])
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(root[key])
if text or imgs:
return text or "", imgs
for val in root.values():
if isinstance(val, dict):
text, imgs, medias = _parse_block(val)
if text or imgs or medias:
return text or "", imgs, medias
text, imgs = _parse_block(val)
if text or imgs:
return text or "", imgs
return "", [], []
return "", []
def _extract_post_text(content_json: dict) -> str:
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
class FeishuConfig(Base):
@@ -1152,7 +1156,7 @@ class FeishuChannel(BaseChannel):
if msg_type == "text":
text = content_json.get("text", "").strip()
elif msg_type == "post":
text, _, _ = _extract_post_content(content_json)
text, _ = _extract_post_content(content_json)
text = text.strip()
else:
text = ""
@@ -1747,7 +1751,7 @@ class FeishuChannel(BaseChannel):
content_parts.append(text)
elif msg_type == "post":
text, image_keys, media_items = _extract_post_content(content_json)
text, image_keys = _extract_post_content(content_json)
if text:
content_parts.append(text)
# Download images embedded in post
@@ -1758,14 +1762,6 @@ class FeishuChannel(BaseChannel):
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
# Download media (video/file) embedded in post
for media_item in media_items:
file_path, content_text = await self._download_and_save_media(
"media", media_item, message_id
)
if file_path:
media_paths.append(file_path)
content_parts.append(content_text)
elif msg_type in ("image", "audio", "file", "media"):
file_path, content_text = await self._download_and_save_media(
+20 -7
View File
@@ -70,34 +70,47 @@ class ChannelManager:
def _init_channels(self) -> None:
"""Initialize channels discovered via pkgutil scan + entry_points plugins."""
from nanobot.channels.registry import discover_all
from nanobot.channels.registry import discover_channel_names, discover_enabled
transcription_provider = self.config.channels.transcription_provider
transcription_key = self._resolve_transcription_key(transcription_provider)
transcription_base = self._resolve_transcription_base(transcription_provider)
transcription_language = self.config.channels.transcription_language
for name, cls in discover_all().items():
# Collect enabled module names first, then only import those.
# Channel configs live in ChannelsConfig's extra fields (via
# extra="allow"), so we enumerate candidates from pkgutil scan
# (cheap, no imports) and any plugin keys in __pydantic_extra__.
names = discover_channel_names()
candidate_names = set(names)
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
candidate_names.update(extra.keys())
enabled_names: set[str] = set()
for name in candidate_names:
section = getattr(self.config.channels, name, None)
if section is None:
continue
enabled = (
if (
section.get("enabled", False)
if isinstance(section, dict)
else getattr(section, "enabled", False)
)
if not enabled:
):
enabled_names.add(name)
for name, cls in discover_enabled(enabled_names, _names=names).items():
section = getattr(self.config.channels, name, None)
if section is None:
continue
try:
kwargs: dict[str, Any] = {}
# Only the WebSocket channel currently hosts the embedded webui
# surface; other channels stay oblivious to these knobs.
if cls.name == "websocket":
if self._session_manager is not None:
kwargs["session_manager"] = self._session_manager
static_path = _default_webui_dist()
if static_path is not None:
kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_path
if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name
channel = cls(section, self.bus, **kwargs)
+39 -15
View File
@@ -1,5 +1,4 @@
"""Auto-discovery for built-in channel modules and external plugins."""
from __future__ import annotations
import importlib
@@ -37,12 +36,14 @@ def load_channel_class(module_name: str) -> type[BaseChannel]:
raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}")
def discover_plugins() -> dict[str, type[BaseChannel]]:
def discover_plugins(enabled_names: set[str] | None = None) -> dict[str, type[BaseChannel]]:
"""Discover external channel plugins registered via entry_points."""
from importlib.metadata import entry_points
plugins: dict[str, type[BaseChannel]] = {}
for ep in entry_points(group="nanobot.channels"):
if enabled_names is not None and ep.name not in enabled_names:
continue
try:
cls = ep.load()
plugins[ep.name] = cls
@@ -51,21 +52,44 @@ def discover_plugins() -> dict[str, type[BaseChannel]]:
return plugins
def discover_enabled(
enabled_names: set[str],
*,
_names: list[str] | None = None,
_include_all_external: bool = False,
) -> dict[str, type[BaseChannel]]:
"""Return channels whose module names are in *enabled_names*.
Uses cheap ``pkgutil.iter_modules`` to list names, then imports only
those that match — skipping the heavy third-party SDK imports of
unneeded channels.
"""
names = _names if _names is not None else discover_channel_names()
result: dict[str, type[BaseChannel]] = {}
for modname in names:
if modname not in enabled_names:
continue
try:
result[modname] = load_channel_class(modname)
except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e)
external = discover_plugins(None if _include_all_external else enabled_names)
shadowed = set(external) & set(result)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
if _include_all_external:
result.update({k: v for k, v in external.items() if k not in shadowed})
else:
result.update({k: v for k, v in external.items() if k not in shadowed and k in enabled_names})
return result
def discover_all() -> dict[str, type[BaseChannel]]:
"""Return all channels: built-in (pkgutil) merged with external (entry_points).
Built-in channels take priority — an external plugin cannot shadow a built-in name.
"""
builtin: dict[str, type[BaseChannel]] = {}
for modname in discover_channel_names():
try:
builtin[modname] = load_channel_class(modname)
except ImportError as e:
logger.debug("Skipping built-in channel '{}': {}", modname, e)
external = discover_plugins()
shadowed = set(external) & set(builtin)
if shadowed:
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
return {**external, **builtin}
names = discover_channel_names()
return discover_enabled(set(names), _names=names, _include_all_external=True)
File diff suppressed because it is too large Load Diff
+140 -4
View File
@@ -34,7 +34,7 @@ from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir
from nanobot.config.paths import get_media_dir, get_workspace_path
from nanobot.config.schema import Base
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.webui_turns import websocket_turn_wall_started_at
@@ -52,6 +52,11 @@ from nanobot.webui.settings_api import (
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.cli_apps_api import (
cli_apps_action,
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
@@ -420,6 +425,16 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"video/webm",
"video/quicktime",
})
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".png",
".jpg",
".jpeg",
".webp",
".gif",
})
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
@@ -449,6 +464,7 @@ class WebSocketChannel(BaseChannel):
*,
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None = None,
):
if isinstance(config, dict):
@@ -461,6 +477,10 @@ class WebSocketChannel(BaseChannel):
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
# Chat IDs that opted into WebUI-specific rendering by sending a typed
# envelope with ``webui: true``. Raw WebSocket clients keep the legacy
# wire shape.
self._webui_chats: set[str] = set()
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
@@ -471,8 +491,14 @@ class WebSocketChannel(BaseChannel):
self._static_dist_path: Path | None = (
static_dist_path.resolve() if static_dist_path is not None else None
)
self._workspace_path = (
Path(workspace_path).expanduser()
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._runtime_model_name = runtime_model_name
self._settings_restart_sections: set[str] = set()
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one
# file, nothing else. The secret regenerates on restart so links
@@ -653,6 +679,21 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if got == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if got == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if got == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -874,6 +915,32 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return _http_error(500, "failed to load CLI Apps")
return _http_json_response(payload)
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = await asyncio.to_thread(cli_apps_action, action, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return _http_error(status, message)
return _http_json_response(payload)
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
@@ -915,6 +982,7 @@ class WebSocketChannel(BaseChannel):
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
augment_assistant_text=self._rewrite_local_markdown_images,
)
if data is None:
return _http_error(404, "webui thread not found")
@@ -961,6 +1029,9 @@ class WebSocketChannel(BaseChannel):
}
if media:
user_obj["media_paths"] = list(media)
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
@@ -1050,6 +1121,46 @@ class WebSocketChannel(BaseChannel):
return None
return {"url": signed, "name": path.name}
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
url = raw_url.strip()
if url.startswith("<") and url.endswith(">"):
url = url[1:-1].strip()
if not url or url.startswith(("/api/media/", "#")):
return None
parsed = urlparse(url)
if parsed.scheme or parsed.netloc:
return None
if parsed.query or parsed.fragment:
return None
path_text = unquote(url)
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
return None
candidate = Path(path_text).expanduser()
if not candidate.is_absolute():
candidate = self._workspace_path / candidate
try:
resolved = candidate.resolve(strict=False)
resolved.relative_to(self._workspace_path)
except (OSError, ValueError):
return None
if not resolved.is_file():
return None
signed = self._sign_or_stage_media_path(resolved)
return signed["url"] if signed else None
def _rewrite_local_markdown_images(self, text: str) -> str:
if "![" not in text:
return text
def replace(match: re.Match[str]) -> str:
signed_url = self._markdown_image_url_for_local_path(match.group(2))
if not signed_url:
return match.group(0)
title = match.group(3) or ""
return f"![{match.group(1)}]({signed_url}{title})"
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
"""Serve a single media file previously signed via
:meth:`_sign_media_path`. Validates the signature, decodes the
@@ -1421,6 +1532,10 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
self._webui_chats.add(cid)
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@@ -1454,6 +1569,7 @@ class WebSocketChannel(BaseChannel):
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._webui_chats.clear()
self._issued_tokens.clear()
self._api_tokens.clear()
@@ -1532,10 +1648,12 @@ class WebSocketChannel(BaseChannel):
await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
should_rewrite_images = msg.chat_id in self._webui_chats
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
"text": text,
"text": wire_text,
}
if msg.media:
payload["media"] = msg.media
@@ -1563,7 +1681,9 @@ class WebSocketChannel(BaseChannel):
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
self._try_append_webui_transcript(msg.chat_id, payload)
transcript_payload = dict(payload)
transcript_payload["text"] = text
self._try_append_webui_transcript(msg.chat_id, transcript_payload)
raw = json.dumps(payload, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
@@ -1628,17 +1748,33 @@ class WebSocketChannel(BaseChannel):
if not conns:
return
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
should_rewrite_images = chat_id in self._webui_chats
transcript_body: dict[str, Any] | None = None
if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
if should_rewrite_images:
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._rewrite_local_markdown_images(full_text)
if rewritten != full_text or delta:
body["text"] = rewritten
transcript_body = {**body, "text": full_text}
else:
body = {
"event": "delta",
"chat_id": chat_id,
"text": delta,
}
if should_rewrite_images:
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, body)
if transcript_body is not None:
transcript_body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, transcript_body or body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
+163 -6
View File
@@ -79,6 +79,12 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
ERRCODE_SESSION_EXPIRED = -14
SESSION_PAUSE_DURATION_S = 60 * 60
# iLink context_token is observed to expire server-side after ~90-160s of
# agent inactivity (openclaw/openclaw#61174). Proactively refresh before
# sending if the cached token is older than this threshold.
CONTEXT_TOKEN_MAX_AGE_S = 60
# Retry constants (matching the reference plugin's monitor.ts)
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY_S = 30
@@ -159,6 +165,8 @@ class WeixinChannel(BaseChannel):
self._session_pause_until: float = 0.0
self._typing_tasks: dict[str, asyncio.Task] = {}
self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {}
self._pending_tool_hints: dict[str, list[str]] = {}
# ------------------------------------------------------------------
# State persistence
@@ -486,6 +494,7 @@ class WeixinChannel(BaseChannel):
except Exception:
if not self._running:
break
self.logger.exception("WeChat poll loop error")
consecutive_failures += 1
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
consecutive_failures = 0
@@ -495,6 +504,7 @@ class WeixinChannel(BaseChannel):
async def stop(self) -> None:
self._running = False
self._pending_tool_hints.clear()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
for chat_id in list(self._typing_tasks):
@@ -545,6 +555,7 @@ class WeixinChannel(BaseChannel):
# Check for API-level errors (monitor.ts checks both ret and errcode)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
if is_error:
@@ -575,8 +586,10 @@ class WeixinChannel(BaseChannel):
# Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or []
for msg in msgs:
with suppress(Exception):
try:
await self._process_message(msg)
except Exception:
self.logger.exception("Failed to process WeChat message")
# ------------------------------------------------------------------
# Inbound message processing (matches inbound.ts + process-message.ts)
@@ -610,6 +623,7 @@ class WeixinChannel(BaseChannel):
ctx_token = msg.get("context_token", "")
if ctx_token:
self._context_tokens[from_user_id] = ctx_token
self._context_token_at[from_user_id] = time.time()
self._save_state()
# Parse item_list (WeixinMessage.item_list — types.ts:161)
@@ -915,6 +929,99 @@ class WeixinChannel(BaseChannel):
}
return ""
async def _refresh_context_token_if_stale(
self, chat_id: str, context_token: str
) -> str:
"""Return a fresh context_token if the cached one is too old.
iLink context_token expires server-side after a short idle period
(empirically ~90s). Proactively refreshing before sending prevents
silent message loss on long agent turns or cron pushes.
"""
if not context_token:
return context_token
now = time.time()
cached_at = self._context_token_at.get(chat_id, 0)
age = now - cached_at
if age < CONTEXT_TOKEN_MAX_AGE_S:
return context_token
self.logger.debug(
"WeChat context_token for {} is {:.0f}s old; refreshing via getconfig",
chat_id,
age,
)
body: dict[str, Any] = {
"ilink_user_id": chat_id,
"context_token": context_token,
"base_info": BASE_INFO,
}
try:
data = await self._api_post("ilink/bot/getconfig", body)
except Exception as e:
self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e)
return context_token
if data.get("ret", 0) != 0:
self.logger.warning(
"WeChat getconfig returned ret={} for {}: {}",
data.get("ret"),
chat_id,
data.get("errmsg", ""),
)
return context_token
new_token = str(data.get("context_token", "") or "")
if new_token and new_token != context_token:
self.logger.info(
"WeChat context_token refreshed for {} (age {:.0f}s -> fresh)",
chat_id,
age,
)
self._context_tokens[chat_id] = new_token
self._context_token_at[chat_id] = now
self._save_state()
return new_token
return context_token
async def _flush_tool_hints(self, chat_id: str) -> None:
"""Send any buffered tool hints for *chat_id* as a single message.
Tool hints are coalesced to reduce message count and avoid hitting the
WeChat iLink rate limit (~7 msgs / 5 min). Failures are logged but
not raised so that the main message send is never blocked.
"""
hints = self._pending_tool_hints.pop(chat_id, None)
if not hints:
return
self.logger.info(
"Flushing {} buffered tool hint(s) for {}",
len(hints),
chat_id,
)
ctx_token = self._context_tokens.get(chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(chat_id, ctx_token)
if not ctx_token:
self.logger.warning(
"Dropped {} buffered tool hint(s) for {}: no context_token",
len(hints),
chat_id,
)
return
try:
await self._send_text(chat_id, "\n\n".join(hints), ctx_token)
except Exception:
self.logger.exception(
"Failed to flush buffered tool hints for {}", chat_id
)
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
"""Best-effort sendtyping wrapper."""
if not typing_ticket:
@@ -944,11 +1051,47 @@ class WeixinChannel(BaseChannel):
self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False))
# Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
if is_progress and (msg.metadata or {}).get("_tool_hint"):
if not self.send_tool_hints:
return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
self.logger.debug(
"Buffered tool hint for {} (count={})",
msg.chat_id,
len(self._pending_tool_hints[msg.chat_id]),
)
return
# Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer.
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id
)
return
content = msg.content.strip()
# Empty progress messages (e.g. after_iteration tool_events) must
# NOT act as separators — they have no visible content.
if is_progress and not content and not (msg.media or []):
self.logger.debug(
"Skipped empty progress message for {} (no visible content)",
msg.chat_id,
)
return
# Flush buffered hints before sending any visible message.
await self._flush_tool_hints(msg.chat_id)
if not is_progress:
await self._stop_typing(msg.chat_id, clear_remote=True)
content = msg.content.strip()
ctx_token = self._context_tokens.get(msg.chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
if not ctx_token:
raise RuntimeError(
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
@@ -1037,6 +1180,18 @@ class WeixinChannel(BaseChannel):
with suppress(Exception):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Weixin iLink does not support native streaming deltas.
We only hook ``_stream_end`` so buffered tool hints are flushed even
when the final answer carries the ``_streamed`` flag and bypasses
:meth:`send`.
"""
if metadata and metadata.get("_stream_end"):
await self._flush_tool_hints(chat_id)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received."""
if not self._client or not self._token or not chat_id:
@@ -1120,10 +1275,11 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if errcode and errcode != 0:
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
raise RuntimeError(
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
)
async def _send_media_file(
@@ -1270,10 +1426,11 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if errcode and errcode != 0:
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
raise RuntimeError(
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
)
+13
View File
@@ -0,0 +1,13 @@
"""CLI Apps integration helpers."""
from nanobot.cli_apps.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,
)
__all__ = [
"CliAppError",
"CliAppManager",
"CliAppsRuntimeConfig",
]
+955
View File
@@ -0,0 +1,955 @@
"""CLI-Anything catalog, install state, and safe CLI execution."""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.config.paths import get_runtime_subdir
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
_MAX_TOOL_OUTPUT_CHARS = 12_000
_MAX_ARTIFACT_SCAN_PATHS = 4_000
_MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ARTIFACT_EXTENSIONS = frozenset({
".csv",
".drawio",
".gif",
".html",
".jpeg",
".jpg",
".json",
".md",
".pdf",
".png",
".svg",
".txt",
".vsdx",
".webp",
".xml",
})
_INLINE_ARTIFACT_EXTENSIONS = frozenset({".gif", ".jpeg", ".jpg", ".png", ".webp"})
_ARTIFACT_IGNORE_DIRS = frozenset({
".git",
".hg",
".mypy_cache",
".nanobot",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"venv",
})
class CliAppError(ValueError):
"""User-facing CLI Apps failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(slots=True)
class CliAppsRuntimeConfig:
"""Runtime knobs for CLI Apps."""
install_timeout: int = 300
run_timeout: int = 60
catalog_ttl_seconds: int = 3600
_BRANDS: dict[str, tuple[str, str]] = {
"1password-cli": ("1password", "#3B66BC"),
"audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"),
"calibre": ("calibre", "#45B29D"),
"chromadb": ("chroma", "#FFDE2D"),
"comfyui": ("comfyui", "#111827"),
"contentful": ("contentful", "#2478CC"),
"dify": ("dify", "#155EEF"),
"drawio": ("diagramsdotnet", "#F08705"),
"elevenlabs": ("elevenlabs", "#000000"),
"eth2-quickstart": ("ethereum", "#627EEA"),
"firefly-iii": ("fireflyiii", "#CD5029"),
"freecad": ("freecad", "#418FDE"),
"generate-veo-video": ("googlegemini", "#8E75B2"),
"gimp": ("gimp", "#5C5543"),
"godot": ("godotengine", "#478CBF"),
"hacker-feeds-cli": ("rss", "#FFA500"),
"inkscape": ("inkscape", "#000000"),
"intelwatch": ("intel", "#0071C5"),
"iterm2": ("iterm2", "#000000"),
"jimeng": ("bytedance", "#3C8CFF"),
"kdenlive": ("kdenlive", "#527EB2"),
"krita": ("krita", "#3BABFF"),
"libreoffice": ("libreoffice", "#18A303"),
"mailchimp": ("mailchimp", "#FFE01B"),
"mermaid": ("mermaid", "#FF3670"),
"minimax": ("minimax", "#111827"),
"musescore": ("musescore", "#1A70B8"),
"n8n": ("n8n", "#EA4B71"),
"notebooklm": ("googlenotebooklm", "#4285F4"),
"obs-studio": ("obsstudio", "#302E31"),
"obsidian": ("obsidian", "#7C3AED"),
"ollama": ("ollama", "#000000"),
"pm2": ("pm2", "#2B037A"),
"qgis": ("qgis", "#589632"),
"safari": ("safari", "#006CFF"),
"sanity": ("sanity", "#F03E2F"),
"sentry": ("sentry", "#362D59"),
"sketch": ("sketch", "#F7B500"),
"shopify": ("shopify", "#7AB55C"),
"nsight-graphics": ("nvidia", "#76B900"),
"unrealinsights": ("unrealengine", "#0E1128"),
"ueatelier": ("unrealengine", "#0E1128"),
"ve-twini": ("x", "#000000"),
"wecom": ("wechat", "#07C160"),
"suno": ("suno", "#000000"),
"lldb": ("llvm", "#262D3A"),
"android-cli": ("android", "#3DDC84"),
"adguardhome": ("adguard", "#68BC71"),
"zotero": ("zotero", "#CC2936"),
"zoom": ("zoom", "#0B5CFF"),
}
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.com", "#111827"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
"deployhq": ("deployhq.com", "#00A2D9"),
"exa": ("exa.ai", "#111827"),
"feishu": ("larksuite.com", "#00A5FF"),
"inkstitch": ("inkstitch.org", "#222222"),
"macrocli": ("github.com/HKUDS/CLI-Anything/tree/main/macrocli", "#24292F"),
"mubu": ("mubu.com", "#16A085"),
"nslogger": ("github.com/fpillet/NSLogger", "#24292F"),
"novita": ("novita.ai", "#7C3AED"),
"openscreen": ("openscreen.com", "#2563EB"),
"py4csr": ("github.com/yanmingyu92/py4csr", "#24292F"),
"quietshrink": ("github.com/achiya-automation/quietshrink", "#111827"),
"renderdoc": ("renderdoc.org", "#2C7DB8"),
"rms": ("rms.teltonika-networks.com", "#0054A6"),
"sbox": ("sbox.game", "#F59E0B"),
"seaclip": ("github.com/SeaClip-Lite/SeaClip", "#0284C7"),
"shotcut": ("shotcut.org", "#3B82F6"),
"slay-the-spire-ii": ("megacrit.com", "#B91C1C"),
"stata": ("stata.com", "#1F4E79"),
"unimol-tools": ("github.com/deepmodeling/Uni-Mol", "#4F46E5"),
"videocaptioner": ("github.com/WEIFENG2333/VideoCaptioner", "#2563EB"),
"wiremock": ("wiremock.org", "#FF6A00"),
}
_BRAND_ALIASES: dict[str, str] = {
"1password": "1password-cli",
"dify-workflow": "dify",
"feishu-lark": "feishu",
"lark-cli": "feishu",
"minimax-cli": "minimax",
"obsidian-cli": "obsidian",
"slay-the-spire-2": "slay-the-spire-ii",
"slay-the-spire-ii": "slay-the-spire-ii",
"unimol-tools": "unimol-tools",
"unimol": "unimol-tools",
"veo": "generate-veo-video",
}
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
def _now() -> float:
return time.time()
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
def _command_exists(command: str) -> bool:
try:
parts = shlex.split(command)
except ValueError:
return False
if not parts:
return False
return shutil.which(parts[0]) is not None
def _is_pip_install_command(command: str) -> bool:
try:
tokens = shlex.split(command)
except ValueError:
return False
return (
len(tokens) >= 3
and tokens[:2] == ["pip", "install"]
) or (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "install"]
and tokens[0] in {"python", "python3", sys.executable}
)
def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
if not command or _has_shell_meta(command):
return None
try:
tokens = shlex.split(command)
except ValueError:
return None
if tokens[:2] == ["pip", "uninstall"]:
args = tokens[2:]
elif (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "uninstall"]
and tokens[0] in {"python", "python3", sys.executable}
):
args = tokens[4:]
else:
return None
packages = [arg for arg in args if arg not in {"-y", "--yes"}]
if not packages or any(arg.startswith("-") for arg in packages):
return None
return packages
def _brand_key(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
def _brand_candidates(app: dict[str, Any]) -> list[str]:
values = [
str(app.get("name") or ""),
str(app.get("display_name") or ""),
str(app.get("entry_point") or "").removeprefix("cli-anything-"),
]
seen: set[str] = set()
candidates: list[str] = []
for value in values:
key = _brand_key(value)
while key and key not in seen:
seen.add(key)
candidates.append(key)
parts = key.split("-")
if len(parts) <= 1 or parts[-1] not in _BRAND_TRAILING_WORDS:
break
key = "-".join(parts[:-1])
return candidates
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
brand = None
domain_brand = None
for candidate in _brand_candidates(app):
key = _BRAND_ALIASES.get(candidate, candidate)
brand = _BRANDS.get(key)
if brand:
break
domain_brand = _BRAND_DOMAINS.get(key)
if domain_brand:
break
if not brand:
if not domain_brand:
return None, None
domain, color = domain_brand
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
slug, color = brand
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False)
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{int(_now() * 1_000_000)}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
finally:
if tmp_path.exists():
tmp_path.unlink()
def _safe_skill_path(value: str) -> str | None:
if not value.startswith("skills/"):
return None
parts = value.split("/")
if any(part in {"", ".", ".."} for part in parts):
return None
return value if parts[-1] == "SKILL.md" else None
def _skill_content_url(skill_md: str) -> str | None:
safe_path = _safe_skill_path(skill_md)
if safe_path:
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
parsed = urlparse(skill_md)
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
return None
if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE):
return None
suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/")
return skill_md if _safe_skill_path(suffix) else None
def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
if len(text) <= limit:
return text
omitted = len(text) - limit
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
class CliAppManager:
"""Manage CLI-Anything registry entries and local install state."""
def __init__(
self,
*,
workspace: Path,
data_dir: Path | None = None,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = Path(workspace).expanduser()
self.data_dir = Path(data_dir) if data_dir is not None else get_runtime_subdir("cli-apps")
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def installed_path(self) -> Path:
return self.data_dir / "installed.json"
def _cache_path(self, source: str) -> Path:
return self.data_dir / f"{source}_registry_cache.json"
def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {}
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
return apps if isinstance(apps, dict) else {}
def _save_installed(self, installed: dict[str, Any]) -> None:
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
def installed_names(self) -> list[str]:
"""Return registry names explicitly installed through CLI Apps."""
return sorted(str(name) for name in self._load_installed())
def _fetch_registry(
self,
url: str,
cache_path: Path,
*,
force_refresh: bool = False,
) -> dict[str, Any]:
cached = _read_json(cache_path)
if (
not force_refresh
and cached
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
):
data = cached.get("data")
if isinstance(data, dict):
return data
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise ValueError("registry response must be an object")
except Exception:
if cached and isinstance(cached.get("data"), dict):
return cached["data"]
raise
_write_json(cache_path, {"_cached_at": _now(), "data": data})
return data
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
registries = [
(
"harness",
self._fetch_registry(
CLI_ANYTHING_REGISTRY_URL,
self._cache_path("harness"),
force_refresh=force_refresh,
),
),
(
"public",
self._fetch_registry(
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
self._cache_path("public"),
force_refresh=force_refresh,
),
),
]
apps_by_name: dict[str, dict[str, Any]] = {}
updated_values: list[str] = []
for source, registry in registries:
meta = registry.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
updated_values.append(meta["updated"])
for row in registry.get("clis", []):
if not isinstance(row, dict) or not row.get("name"):
continue
entry = dict(row)
entry["_source"] = source
key = str(entry["name"]).lower()
previous = apps_by_name.get(key)
if previous:
previous_source = str(previous.get("_source") or source)
merged_source = (
previous_source if previous_source == source else f"{previous_source}+{source}"
)
apps_by_name[key] = {**previous, **entry, "_source": merged_source}
else:
apps_by_name[key] = entry
return list(apps_by_name.values()), max(updated_values) if updated_values else None
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
wanted = name.lower()
for app in self.catalog(force_refresh=force_refresh)[0]:
if str(app.get("name", "")).lower() == wanted:
return app
raise CliAppError(f"CLI app '{name}' not found", status=404)
def mentioned_installed_apps(self, text: str) -> list[dict[str, str]]:
"""Return installed CLI Apps referenced as ``@name`` in user text."""
if "@" not in text:
return []
installed = self._load_installed()
if not installed:
return []
installed_by_name = {
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
for name, data in installed.items()
}
seen: set[str] = set()
mentions: list[dict[str, str]] = []
for match in _MENTION_RE.finditer(text):
wanted = str(match.group(2)).lower()
if wanted in seen or wanted not in installed_by_name:
continue
installed_name, data = installed_by_name[wanted]
seen.add(wanted)
entry_point = str(data.get("entry_point") or "")
mentions.append(
{
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
return mentions
def _strategy(self, app: dict[str, Any]) -> str:
package_manager = str(app.get("package_manager") or "").lower()
install_strategy = str(app.get("install_strategy") or "").lower()
if package_manager == "bundled" or install_strategy == "bundled":
return "bundled"
if package_manager in {"npm", "brew", "uv", "pip"}:
return package_manager
if app.get("npm_package"):
return "npm"
install_cmd = str(app.get("install_cmd") or "")
if _is_pip_install_command(install_cmd):
return "pip"
return "unsupported"
def _install_supported(self, app: dict[str, Any]) -> bool:
if self._strategy(app) == "unsupported":
return False
install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
app: dict[str, Any],
installed: dict[str, Any],
) -> dict[str, Any]:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
install_supported = self._install_supported(app)
is_installed = name in installed
available = bool(entry_point and shutil.which(entry_point))
if is_installed and available:
status = "installed"
elif is_installed:
status = "missing"
elif not install_supported:
status = "unsupported"
elif available:
status = "available"
else:
status = "not_installed"
logo_url, brand_color = _brand_payload(app)
return {
"name": name,
"display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized",
"description": app.get("description") or "",
"requires": app.get("requires") or "",
"source": app.get("_source") or "harness",
"entry_point": entry_point,
"install_supported": install_supported,
"installed": is_installed,
"available": available,
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
}
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh)
installed = self._load_installed()
rows = [self._app_payload(app, installed) for app in apps]
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
return {
"apps": rows,
"installed_count": sum(1 for item in rows if item["installed"]),
"catalog_updated_at": updated,
}
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
install_cmd = str(app.get("install_cmd") or "")
try:
tokens = shlex.split(install_cmd)
except ValueError:
return None
if tokens[:2] == ["pip", "install"]:
args = tokens[2:]
elif len(tokens) >= 5 and tokens[1:4] == ["-m", "pip", "install"]:
args = tokens[4:]
else:
return None
args = [arg for arg in args if not arg.startswith("-")]
if len(args) != 1 or args[0].startswith("git+"):
return None
return args[0]
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
install_cmd = str(app.get("install_cmd") or "")
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
raise CliAppError("unsupported pip install command")
tokens = shlex.split(install_cmd)
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
prefix = [sys.executable, "-m", "pip", "install"]
if update:
prefix.extend(["--upgrade", "--force-reinstall"])
return prefix + args
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
uninstall_cmd = str(app.get("uninstall_cmd") or "")
packages = _pip_uninstall_args_from_command(uninstall_cmd)
if packages:
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
if not package:
entry_point = str(app.get("entry_point") or "").strip()
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
npm = shutil.which("npm")
if not npm:
raise CliAppError("npm is not installed")
package = str(app.get("npm_package") or "")
if not package:
raise CliAppError("registry entry has no npm_package")
if action == "install":
return [npm, "install", "-g", package]
if action == "update":
return [npm, "install", "-g", package + "@latest"]
return [npm, "uninstall", "-g", package]
def _split_safe_command(self, app: dict[str, Any], key: str, expected: str) -> list[str]:
command = str(app.get(key) or "")
if not command:
raise CliAppError(f"no {key} is defined for {app['name']}")
if _has_shell_meta(command):
raise CliAppError("script-style install commands are disabled in this MVP")
try:
argv = shlex.split(command)
except ValueError as exc:
raise CliAppError(f"invalid command: {exc}") from exc
if not argv or argv[0] != expected:
raise CliAppError(f"unsupported {expected} command")
return argv
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
strategy = self._strategy(app)
if strategy == "pip":
if action == "install":
return self._pip_install_argv(app)
if action == "update":
return self._pip_install_argv(app, update=True)
return self._pip_uninstall_argv(app)
if strategy == "npm":
return self._npm_argv(app, action)
if strategy == "brew":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "brew")
if strategy == "uv":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "uv")
if strategy == "bundled":
return None
raise CliAppError("this CLI app uses an unsupported install strategy")
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
return {
"version": app.get("version") or "unknown",
"entry_point": app.get("entry_point") or "",
"source": app.get("_source") or "harness",
"strategy": self._strategy(app),
"installed_at": int(_now()),
}
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
skill_md = str(app.get("skill_md") or "").strip()
if not skill_md:
return None
url = _skill_content_url(skill_md)
if not url:
return None
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
text = response.text
except Exception:
return None
if "SKILL.md" not in url and not text.lstrip().startswith("---"):
return None
return text if len(text) < 250_000 else None
def _fallback_skill(self, app: dict[str, Any]) -> str:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = str(app.get("description") or f"Use {display} from nanobot.")
return f"""---
name: {_safe_skill_name(name)}
description: >-
{description}
---
# {display}
Use this skill when the user asks nanobot to operate {display} through its installed CLI app.
If the user attached `@{name}` in chat, treat that as the selected app for the current turn.
## Commands
```bash
{entry} --help
{entry} --json --help
```
Prefer machine-readable output when the CLI supports `--json`.
"""
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
marker = "<!-- nanobot-cli-app-note -->"
if marker in content:
return content
name = str(app.get("name") or "unknown")
note = f"""{marker}
## Nanobot execution
Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@{name}` as a CLI App Attachment.
"""
lines = content.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for index, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
def install_skill(self, app: dict[str, Any]) -> Path:
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
return path
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> None:
installed = self._load_installed()
installed[str(app["name"])] = self._installed_entry(app)
self._save_installed(installed)
self.install_skill(app)
def install(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
if not self._install_supported(app):
raise CliAppError("this CLI app uses an unsupported install strategy")
strategy = self._strategy(app)
if strategy == "bundled":
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
if detect_cmd and _command_exists(detect_cmd):
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
raise CliAppError(str(note))
argv = self._argv_for_action(app, "install")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
def update(self, name: str) -> dict[str, Any]:
app = self.get_app(name, force_refresh=True)
if str(app["name"]) not in self._load_installed():
raise CliAppError("CLI app is not installed")
if self._strategy(app) == "bundled":
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
argv = self._argv_for_action(app, "update")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
def uninstall(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
if self._strategy(app) != "bundled":
argv = self._argv_for_action(app, "uninstall")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
installed.pop(str(app["name"]), None)
self._save_installed(installed)
self.remove_skill(str(app["name"]))
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
def test(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
entry = str(app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
result = self._run_argv([resolved, "--help"], timeout=min(self.runtime.run_timeout, 30))
ok = result.returncode == 0
output = _truncate((result.stdout or result.stderr or "").strip(), 3000)
return self.payload() | {
"last_action": {
"ok": ok,
"message": f"{entry} --help exited {result.returncode}",
"output": output,
}
}
def _resolve_cwd(
self,
working_dir: str | None,
*,
restrict_to_workspace: bool,
) -> Path:
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False)
workspace = self.workspace.resolve(strict=False)
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
raise CliAppError("working_dir is outside the configured workspace")
return cwd
def _iter_artifact_candidates(self, cwd: Path) -> list[Path]:
if not cwd.is_dir():
return []
out: list[Path] = []
stack = [cwd]
scanned = 0
while stack and scanned < _MAX_ARTIFACT_SCAN_PATHS:
directory = stack.pop()
try:
entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
except OSError:
continue
for path in entries:
if scanned >= _MAX_ARTIFACT_SCAN_PATHS:
break
scanned += 1
try:
if path.is_dir() and not path.is_symlink():
if path.name not in _ARTIFACT_IGNORE_DIRS:
stack.append(path)
continue
if path.is_file() and path.suffix.lower() in _ARTIFACT_EXTENSIONS:
out.append(path.resolve(strict=False))
except OSError:
continue
return out
def _artifact_snapshot(self, cwd: Path) -> dict[Path, tuple[int, int]]:
snapshot: dict[Path, tuple[int, int]] = {}
for path in self._iter_artifact_candidates(cwd):
try:
stat = path.stat()
except OSError:
continue
snapshot[path] = (stat.st_mtime_ns, stat.st_size)
return snapshot
def _changed_artifacts(
self,
cwd: Path,
before: dict[Path, tuple[int, int]],
) -> list[Path]:
changed: list[tuple[int, Path]] = []
for path, stamp in self._artifact_snapshot(cwd).items():
if before.get(path) == stamp:
continue
changed.append((stamp[0], path))
changed.sort(key=lambda item: (item[0], item[1].name.lower()))
return [path for _, path in changed[-_MAX_ARTIFACT_REPORT:]]
def _format_artifact_path(self, cwd: Path, path: Path) -> str:
try:
return path.relative_to(cwd).as_posix()
except ValueError:
return path.name
@staticmethod
def _format_artifact_size(path: Path) -> str:
try:
size = path.stat().st_size
except OSError:
return "unknown size"
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
return f"{size / (1024 * 1024):.1f} MB"
def _format_artifact_lines(self, cwd: Path, paths: list[Path]) -> list[str]:
lines: list[str] = []
for path in paths:
rel = self._format_artifact_path(cwd, path)
ext = path.suffix.lower()
kind = (
"previewable image"
if ext in _INLINE_ARTIFACT_EXTENSIONS
else ext.lstrip(".") or "file"
)
lines.append(f"- {rel} ({kind}, {self._format_artifact_size(path)})")
return lines
def run(
self,
name: str,
args: list[str] | None = None,
*,
json_output: bool = False,
working_dir: str | None = None,
timeout: int | None = None,
restrict_to_workspace: bool = False,
) -> str:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError(f"CLI app '{name}' is not installed")
cwd = self._resolve_cwd(working_dir, restrict_to_workspace=restrict_to_workspace)
entry = str(installed[str(app["name"])].get("entry_point") or app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
clean_args = [str(arg) for arg in (args or [])]
if json_output and "--json" not in clean_args:
clean_args = ["--json", *clean_args]
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
artifact_snapshot = self._artifact_snapshot(cwd)
try:
result = subprocess.run(
[resolved, *clean_args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=effective_timeout,
env=os.environ.copy(),
)
except subprocess.TimeoutExpired:
return f"CLI app '{name}' timed out after {effective_timeout}s"
output = [
f"CLI app '{name}' exited {result.returncode}.",
f"Command: {entry} {' '.join(shlex.quote(arg) for arg in clean_args)}".rstrip(),
]
if result.stdout:
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
if result.stderr:
output.append("\nSTDERR:\n" + result.stderr.rstrip())
artifacts = self._changed_artifacts(cwd, artifact_snapshot)
if artifacts:
output.append(
"\nArtifacts created or updated:\n"
+ "\n".join(self._format_artifact_lines(cwd, artifacts))
)
if any(path.suffix.lower() in _INLINE_ARTIFACT_EXTENSIONS for path in artifacts):
output.append(
"\nTo show a preview in WebUI, reference a raster artifact with Markdown "
"using its workspace-relative path, for example `![diagram](diagram.png)`."
)
return _truncate("\n".join(output))
+62
View File
@@ -0,0 +1,62 @@
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for CLI app attachments."""
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible CLI app annotations for the current turn."""
if skip:
return []
text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
return _cli_app_runtime_lines(text, metadata, workspace)
def _cli_app_runtime_lines(
text: str,
metadata: Mapping[str, Any] | None,
workspace: Path,
) -> list[str]:
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
mentions = [
item for item in structured
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
]
if mentions:
return [
"CLI App Attachment: "
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
]
if "@" not in text:
return []
try:
from nanobot.cli_apps import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
return []
return [
"CLI App Mention: "
f"@{item['name']} "
f"(installed; tool={item['tool']}; "
f"entry_point={item['entry_point'] or 'unknown'}; "
f"skill={item['skill']}). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
]
+5 -39
View File
@@ -11,6 +11,7 @@ from pydantic_settings import BaseSettings
from nanobot.cron.types import CronSchedule
if TYPE_CHECKING:
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@@ -155,35 +156,8 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("consolidationRatio"),
serialization_alias="consolidationRatio",
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
vision_models: list[str] = Field(default_factory=list) # Models that support image input
audio_models: list[str] = Field(default_factory=list) # Models that support native audio input
video_models: list[str] = Field(default_factory=list) # Models that support native video input
dream: DreamConfig = Field(default_factory=DreamConfig)
@staticmethod
def _bare_model(model: str) -> str:
"""Strip provider prefix, e.g. 'openai/gpt-4o' -> 'gpt-4o'."""
return model.split("/", 1)[-1].lower() if "/" in model else model.lower()
def _supports_capability(self, model: str, patterns: list[str]) -> bool | None:
"""Check if model matches any pattern. Returns None if patterns is empty."""
if not patterns:
return None
bare = self._bare_model(model)
return any(p.lower() in bare for p in patterns)
def supports_vision(self, model: str) -> bool | None:
"""Check if model supports vision. None if unconfigured."""
return self._supports_capability(model, self.vision_models)
def supports_audio(self, model: str) -> bool | None:
"""Check if model supports native audio. None if unconfigured."""
return self._supports_capability(model, self.audio_models)
def supports_video(self, model: str) -> bool | None:
"""Check if model supports native video. None if unconfigured."""
return self._supports_capability(model, self.video_models)
class AgentsConfig(Base):
"""Agent configuration."""
@@ -238,6 +212,7 @@ class ProvidersConfig(Base):
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
@@ -285,17 +260,6 @@ class MCPServerConfig(Base):
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
class InputLimitsConfig(Base):
"""Limits for user-provided multimodal inputs."""
max_input_images: int = 3
max_input_image_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_audios: int = 1
max_input_audio_bytes: int = 10 * 1024 * 1024 # 10 MB
max_input_videos: int = 1
max_input_video_bytes: int = 20 * 1024 * 1024 # 20 MB
def _lazy_default(module_path: str, class_name: str) -> Any:
"""Deferred import helper for ToolsConfig default factories."""
import importlib
@@ -313,11 +277,11 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
)
input_limits: InputLimitsConfig = Field(default_factory=InputLimitsConfig)
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
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)
@@ -500,6 +464,7 @@ def _resolve_tool_config_refs() -> None:
"""
import sys
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@@ -508,6 +473,7 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace
mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
+13 -1
View File
@@ -1,6 +1,18 @@
"""Cron service for scheduled agent tasks."""
from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronSchedule
__all__ = ["CronService", "CronJob", "CronSchedule"]
_LAZY = {"CronService": ".service"}
def __getattr__(name: str):
module_path = _LAZY.get(name)
if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from importlib import import_module
mod = import_module(module_path, __name__)
val = getattr(mod, name)
globals()[name] = val
return val
+1 -9
View File
@@ -212,7 +212,7 @@ class AnthropicProvider(LLMProvider):
@staticmethod
def _convert_user_content(content: Any) -> Any:
"""Convert user message content, translating image_url and input_audio blocks."""
"""Convert user message content, translating image_url blocks."""
if isinstance(content, str) or content is None:
return content or "(empty)"
if not isinstance(content, list):
@@ -228,14 +228,6 @@ class AnthropicProvider(LLMProvider):
if converted:
result.append(converted)
continue
if item.get("type") == "input_audio":
# Anthropic doesn't support native audio → text placeholder
result.append(LLMProvider._media_placeholder("input_audio", item))
continue
if item.get("type") == "video_url":
# Anthropic doesn't support native video → text placeholder
result.append(LLMProvider._media_placeholder("video_url", item))
continue
result.append(item)
return result or "(empty)"
+18 -36
View File
@@ -13,6 +13,8 @@ from typing import Any
from loguru import logger
from nanobot.utils.helpers import image_placeholder_text
@dataclass
class ToolCallRequest:
@@ -437,23 +439,9 @@ class LLMProvider(ABC):
return merged
_MEDIA_LABEL_MAP = {"image_url": "image", "input_audio": "audio", "video_url": "video"}
_STRIP_MEDIA_TYPES = frozenset({"image_url", "input_audio", "video_url"})
@staticmethod
def _media_placeholder(btype: str, block: dict[str, Any]) -> dict[str, str]:
"""Build a text placeholder for a media block."""
path = (block.get("_meta") or {}).get("path", "")
label = LLMProvider._MEDIA_LABEL_MAP.get(btype, "media")
text = f"[{label}: {path}]" if path else f"[{label}]"
return {"type": "text", "text": text}
@staticmethod
def _strip_media_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url, input_audio, and video_url blocks with text placeholders.
Returns None if no media blocks were found (no changes needed).
"""
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
found = False
result = []
for msg in messages:
@@ -461,8 +449,10 @@ class LLMProvider(ABC):
if isinstance(content, list):
new_content = []
for b in content:
if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
new_content.append(LLMProvider._media_placeholder(b["type"], b))
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
new_content.append({"type": "text", "text": placeholder})
found = True
else:
new_content.append(b)
@@ -472,13 +462,8 @@ class LLMProvider(ABC):
return result if found else None
@staticmethod
def _strip_image_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""Replace image_url blocks with text placeholder. Returns None if no images found."""
return LLMProvider._strip_media_content(messages)
@staticmethod
def _strip_media_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace media blocks with text placeholder *in-place*.
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*.
Mutates the content lists of the original message dicts so that
callers holding references to those dicts also see the stripped
@@ -489,16 +474,13 @@ class LLMProvider(ABC):
content = msg.get("content")
if isinstance(content, list):
for i, b in enumerate(content):
if isinstance(b, dict) and b.get("type") in LLMProvider._STRIP_MEDIA_TYPES:
content[i] = LLMProvider._media_placeholder(b["type"], b)
if isinstance(b, dict) and b.get("type") == "image_url":
path = (b.get("_meta") or {}).get("path", "")
placeholder = image_placeholder_text(path, empty="[image omitted]")
content[i] = {"type": "text", "text": placeholder}
found = True
return found
@staticmethod
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
"""Replace image_url blocks with text placeholder *in-place*."""
return LLMProvider._strip_media_content_inplace(messages)
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
"""Call chat() and convert unexpected exceptions to error responses."""
try:
@@ -756,18 +738,18 @@ class LLMProvider(ABC):
identical_error_count = 1 if error_key else 0
if not self._is_transient_response(response):
stripped = self._strip_media_content(original_messages)
stripped = self._strip_image_content(original_messages)
if stripped is not None and stripped != kw["messages"]:
logger.warning(
"Non-transient LLM error with media content, retrying without media"
"Non-transient LLM error with image content, retrying without images"
)
retry_kw = dict(kw)
retry_kw["messages"] = stripped
result = await call(**retry_kw)
# Permanently strip media from the original messages so
# Permanently strip images from the original messages so
# subsequent iterations do not repeat the error-retry cycle.
if result.finish_reason != "error":
self._strip_media_content_inplace(original_messages)
self._strip_image_content_inplace(original_messages)
return result
return response
+2 -1
View File
@@ -207,8 +207,9 @@ class GitHubCopilotProvider(OpenAICompatProvider):
async def _refresh_client_api_key(self) -> str:
token = await self._get_copilot_access_token()
client = await self._ensure_client()
self.api_key = token
self._client.api_key = token
client.api_key = token
return token
async def chat(
+583 -14
View File
@@ -2,8 +2,10 @@
from __future__ import annotations
import asyncio
import base64
import binascii
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
@@ -31,6 +33,14 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
}
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
_OLLAMA_DEFAULT_SIDE = 1024
_OLLAMA_SIZE_PRESETS = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
}
_OLLAMA_EXPLICIT_SIZE_RE = re.compile(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$")
_OLLAMA_ASPECT_RATIO_RE = re.compile(r"^\s*(\d+)\s*:\s*(\d+)\s*$")
class ImageGenerationError(RuntimeError):
@@ -129,6 +139,11 @@ _IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
"""Register an image provider at import time only.
The registry is populated by module side effects so provider discovery
stays lazy and consistent across the process.
"""
name = cls.provider_name
if not name:
raise ValueError(f"{cls.__name__} must set provider_name")
@@ -219,7 +234,10 @@ class ImageGenerationProvider(ABC):
*,
headers: dict[str, str],
body: dict[str, Any],
client: httpx.AsyncClient | None = None,
) -> httpx.Response:
if client is not None:
return await client.post(url, headers=headers, json=body)
if self._client is not None:
return await self._client.post(url, headers=headers, json=body)
async with httpx.AsyncClient(timeout=self.timeout) as c:
@@ -390,10 +408,11 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
model_path = _aihubmix_model_path(model)
url = f"{self.api_base}/models/{model_path}/predictions"
try:
response = await client.post(
response = await self._http_post(
url,
headers={**headers, "Content-Type": "application/json"},
json=body,
body=body,
client=client,
)
except httpx.TimeoutException as exc:
raise ImageGenerationError("AIHubMix image generation timed out") from exc
@@ -429,6 +448,139 @@ def _http_error_detail(response: httpx.Response) -> str:
return response.text[:500] or "<empty response body>"
def _round_to_multiple(value: float, multiple: int = 8) -> int:
rounded = int(round(value / multiple) * multiple)
return max(multiple, rounded)
def _ollama_dimensions(aspect_ratio: str | None, image_size: str | None) -> tuple[int, int]:
if image_size:
size = image_size.strip()
explicit = _OLLAMA_EXPLICIT_SIZE_RE.fullmatch(size)
if explicit:
return int(explicit.group(1)), int(explicit.group(2))
long_side = _OLLAMA_SIZE_PRESETS.get(size.upper(), _OLLAMA_DEFAULT_SIDE)
else:
long_side = _OLLAMA_DEFAULT_SIDE
if not aspect_ratio:
return long_side, long_side
ratio = _OLLAMA_ASPECT_RATIO_RE.fullmatch(aspect_ratio.strip())
if ratio is None:
return long_side, long_side
width_ratio = int(ratio.group(1))
height_ratio = int(ratio.group(2))
if width_ratio <= 0 or height_ratio <= 0:
return long_side, long_side
if width_ratio >= height_ratio:
width = long_side
height = _round_to_multiple(long_side * height_ratio / width_ratio)
else:
height = long_side
width = _round_to_multiple(long_side * width_ratio / height_ratio)
return max(8, width), max(8, height)
def _ollama_image_data_url(value: str) -> str:
if value.startswith("data:image/"):
return value
return _b64_image_data_url(value)
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
images: list[str] = []
def collect(value: Any) -> None:
if isinstance(value, str) and value:
images.append(_ollama_image_data_url(value))
elif isinstance(value, list):
for item in value:
collect(item)
collect(payload.get("image"))
collect(payload.get("images"))
return images
class OllamaImageGenerationClient(ImageGenerationProvider):
"""Async client for Ollama native image generation models."""
provider_name = "ollama"
default_timeout = 300.0
def _default_base_url(self) -> str:
return "http://localhost:11434/api"
def _resolve_base_url(self, api_base: str | None) -> str:
if api_base:
base = api_base.rstrip("/")
if base.endswith("/v1"):
return f"{base[:-3]}/api"
return base
return self._default_base_url()
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if reference_images:
raise ImageGenerationError(
"Ollama image generation does not support reference images"
)
width, height = _ollama_dimensions(aspect_ratio, image_size)
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"width": width,
"height": height,
"steps": 0,
}
body.update(self.extra_body)
body["stream"] = False
headers = {
"Content-Type": "application/json",
**self.extra_headers,
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
url = f"{self.api_base}/generate"
response = await self._http_post(url, headers=headers, body=body)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = _http_error_detail(response)
logger.error(
"Ollama image generation failed (HTTP {}): {}",
response.status_code,
detail,
)
raise ImageGenerationError(
f"Ollama image generation failed (HTTP {response.status_code}): {detail}"
) from exc
data = response.json()
images = _ollama_images_from_payload(data)
self._require_images(images, data)
response_text = data.get("response")
content = response_text if isinstance(response_text, str) else ""
return GeneratedImageResponse(images=images, content=content, raw=data)
class GeminiImageGenerationClient(ImageGenerationProvider):
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
@@ -442,9 +594,9 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
return "https://generativelanguage.googleapis.com/v1beta"
def _resolve_base_url(self, api_base: str | None) -> str:
# The Gemini provider's registry default_api_base is the OpenAI-compat
# shim (.../v1beta/openai/), which has no image endpoints.
# Skip the registry lookup and use the native API base directly.
# Gemini chat completions use the registry's OpenAI-compatible shim.
# Image generation must hit the native Generative Language API, so we
# intentionally bypass the shared registry lookup here.
if api_base:
return api_base.rstrip("/")
return self._default_base_url()
@@ -706,22 +858,16 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
body.update(self.extra_body)
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
return await self._generate_with_client(client, body, headers)
finally:
if self._client is None:
await client.aclose()
return await self._generate_with_client(body, headers)
async def _generate_with_client(
self,
client: httpx.AsyncClient,
body: dict[str, Any],
headers: dict[str, str],
) -> GeneratedImageResponse:
url = f"{self.api_base}/image_generation"
try:
response = await client.post(url, headers=headers, json=body)
response = await self._http_post(url, headers=headers, body=body)
except httpx.TimeoutException as exc:
raise ImageGenerationError("MiniMax image generation timed out") from exc
except httpx.RequestError as exc:
@@ -756,6 +902,426 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
return images
# ---------------------------------------------------------------------------
# OpenAI image generation
# ---------------------------------------------------------------------------
_OPENAI_DALLE2_SUPPORTED_SIZES = {"256x256", "512x512", "1024x1024"}
_OPENAI_DALLE3_SUPPORTED_SIZES = {"1024x1024", "1792x1024", "1024x1792"}
_OPENAI_GPT_IMAGE_SUPPORTED_SIZES = {
"1024x1024",
"1536x1024",
"1024x1536",
"auto",
}
_OPENAI_DALLE2_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1024x1024",
"9:16": "1024x1024",
"3:4": "1024x1024",
"4:3": "1024x1024",
}
_OPENAI_DALLE3_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1792x1024",
"9:16": "1024x1792",
"3:4": "1024x1792",
"4:3": "1792x1024",
}
_OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1536x1024",
"9:16": "1024x1536",
"3:4": "1024x1536",
"4:3": "1536x1024",
}
class OpenAIImageGenerationClient(ImageGenerationProvider):
"""OpenAI Images API using an API key (``providers.openai.apiKey``)."""
provider_name = "openai"
missing_key_message = (
"OpenAI API key is not configured. Set providers.openai.apiKey."
)
def _default_base_url(self) -> str:
return "https://api.openai.com/v1"
@staticmethod
def _strip_model_prefix(model: str) -> str:
"""Remove ``openai/`` prefix if present (OpenRouter convention)."""
if model.startswith("openai/") or model.startswith("openai_codex/"):
return model.split("/", 1)[1]
return model
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
if reference_images:
logger.warning(
"DALL-E models do not support reference images; "
"ignoring {} reference image(s) for {}",
len(reference_images),
model,
)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.extra_headers,
}
clean_model = self._strip_model_prefix(model)
body: dict[str, Any] = {
"model": clean_model,
"prompt": prompt,
}
if not _openai_is_gpt_image_model(clean_model):
body["response_format"] = "b64_json"
body["n"] = 1
size = _openai_size(clean_model, aspect_ratio, image_size)
if size:
body["size"] = size
body.update(self.extra_body)
logger.info("OpenAI Images API request: POST {}/images/generations body={}", self.api_base, body)
response = await self._http_post(
f"{self.api_base}/images/generations",
headers=headers,
body=body,
)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = response.text[:1000]
logger.error("OpenAI Images API error ({}): {}", response.status_code, detail)
raise ImageGenerationError(
f"OpenAI image generation failed (HTTP {response.status_code}): {detail}"
) from exc
payload = response.json()
logger.info("OpenAI Images API response ({}): {}", response.status_code,
{k: v for k, v in payload.items() if k != "data"})
client = self._client
owns_client = client is None
if owns_client:
client = httpx.AsyncClient(timeout=self.timeout)
try:
images = await _openai_images_from_payload(client, payload)
finally:
if owns_client:
await client.aclose()
self._require_images(images, payload)
return GeneratedImageResponse(images=images, content="", raw=payload)
# ---------------------------------------------------------------------------
# OpenAI Codex image generation
# ---------------------------------------------------------------------------
class CodexImageGenerationClient(ImageGenerationProvider):
"""OpenAI image generation via Codex subscription OAuth.
Uses the Codex Responses API with the ``image_generation`` tool
(the same mechanism ChatGPT uses internally). No API key required —
the Codex OAuth token from ``oauth_cli_kit`` is used instead.
"""
provider_name = "openai_codex"
missing_key_message = (
"Codex OAuth token is unavailable. "
"Log in with Codex subscription first."
)
def _default_base_url(self) -> str:
return "https://chatgpt.com/backend-api"
def _codex_model(self, model: str) -> str:
"""Strip the ``openai-codex/`` prefix if present."""
if model.startswith(("openai-codex/", "openai_codex/")):
return model.split("/", 1)[1]
return model
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
try:
from oauth_cli_kit import get_token as get_codex_token
except ImportError:
raise ImageGenerationError(self.missing_key_message)
try:
token = await asyncio.to_thread(get_codex_token)
except Exception as exc:
raise ImageGenerationError(self.missing_key_message) from exc
if not token or not token.access:
raise ImageGenerationError(self.missing_key_message)
logger.info(
"Using Codex OAuth token for image generation (account: {})",
token.account_id,
)
if reference_images:
logger.warning(
"Codex image generation does not support reference images; "
"ignoring {} reference image(s)",
len(reference_images),
)
headers = {
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": token.account_id,
"OpenAI-Beta": "responses=experimental",
"originator": "nanobot",
"User-Agent": "nanobot (python)",
"Content-Type": "application/json",
**self.extra_headers,
}
body: dict[str, Any] = {
"model": self._codex_model(model),
"instructions": "Generate an image based on the user's request.",
"input": [{"role": "user", "content": prompt}],
"tools": [{"type": "image_generation"}],
"tool_choice": "auto",
"stream": True,
"store": False,
}
body.update(self.extra_body)
logger.info("Codex Responses API request: POST {}/codex/responses body={}",
self.api_base, {k: v for k, v in body.items() if k != "input"})
response = await self._http_post(
f"{self.api_base}/codex/responses",
headers=headers,
body=body,
)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = response.text[:1000]
logger.error("Codex Responses API error ({}): {}", response.status_code, detail)
raise ImageGenerationError(
f"Codex image generation failed (HTTP {response.status_code}): {detail}"
) from exc
images, content_text = await _parse_codex_sse_images(response)
raw = {"status": "completed"}
self._require_images(images, raw)
return GeneratedImageResponse(images=images, content=content_text, raw=raw)
def _openai_size(
model: str,
aspect_ratio: str | None,
image_size: str | None,
) -> str:
"""Resolve aspect ratio or image_size to an OpenAI Images API size string."""
sizes, supported_sizes = _openai_size_options(model)
explicit_size = _normalize_openai_image_size(image_size)
if explicit_size and _openai_explicit_size_supported(
explicit_size,
supported_sizes=supported_sizes,
):
return explicit_size
if explicit_size:
logger.warning(
"OpenAI image size '{}' is not supported by {}; using aspect ratio/default size",
explicit_size,
model,
)
if aspect_ratio and aspect_ratio in sizes:
return sizes[aspect_ratio]
return "1024x1024"
def _openai_is_gpt_image_model(model: str) -> bool:
normalized = model.lower()
return normalized.startswith(("gpt-image", "chatgpt-image"))
def _openai_size_options(model: str) -> tuple[dict[str, str], set[str] | None]:
normalized = model.lower()
if normalized.startswith("dall-e-2"):
return _OPENAI_DALLE2_ASPECT_RATIO_SIZES, _OPENAI_DALLE2_SUPPORTED_SIZES
if normalized.startswith("dall-e-3"):
return _OPENAI_DALLE3_ASPECT_RATIO_SIZES, _OPENAI_DALLE3_SUPPORTED_SIZES
if normalized.startswith("gpt-image-2"):
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, None
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, _OPENAI_GPT_IMAGE_SUPPORTED_SIZES
def _normalize_openai_image_size(image_size: str | None) -> str | None:
if not image_size:
return None
normalized = image_size.strip().lower()
return normalized or None
def _openai_explicit_size_supported(
size: str,
*,
supported_sizes: set[str] | None,
) -> bool:
if supported_sizes is not None:
return size in supported_sizes
width, sep, height = size.partition("x")
return bool(sep and width.isdecimal() and height.isdecimal())
async def _openai_images_from_payload(
client: httpx.AsyncClient,
payload: dict[str, Any],
) -> list[str]:
"""Extract images from OpenAI Images API response.
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
b64 = item.get("b64_json")
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
continue
url = item.get("url")
if isinstance(url, str) and url:
images.append(await _download_image_data_url(client, url))
return images
def _codex_responses_images_from_payload(payload: dict[str, Any]) -> list[str]:
"""Extract images from Codex Responses API ``image_generation_call`` output."""
images: list[str] = []
for item in payload.get("output") or []:
if not isinstance(item, dict):
continue
if item.get("type") != "image_generation_call":
continue
result = item.get("result")
if isinstance(result, str):
images.append(result if result.startswith("data:image/") else _b64_image_data_url(result))
continue
if isinstance(result, dict):
image_url = result.get("image_url") or result.get("image") or ""
if isinstance(image_url, str):
images.append(image_url if image_url.startswith("data:image/") else _b64_image_data_url(image_url))
return images
async def _parse_codex_sse_images(
response: httpx.Response,
) -> tuple[list[str], str]:
"""Parse a Codex Responses API SSE stream for image generation output.
Returns ``(images, content_text)``.
"""
import json as _json
images: list[str] = []
text_parts: list[str] = []
buffer: list[str] = []
async for line_bytes in response.aiter_lines():
line = line_bytes.strip()
if line == "":
if buffer:
data_lines = []
for bl in buffer:
if bl.startswith("data:"):
data_lines.append(bl[5:].strip())
buffer.clear()
if data_lines:
raw = "".join(data_lines)
if raw == "[DONE]":
break
try:
event = _json.loads(raw)
except Exception:
continue
ev_type = event.get("type", "")
if ev_type in ("error", "response.failed"):
logger.error("Codex SSE failure: {}", raw[:2000])
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
continue
buffer.append(line)
# flush remaining
if buffer:
data_lines = [bl[5:].strip() for bl in buffer if bl.startswith("data:")]
raw = "".join(data_lines)
if raw and raw != "[DONE]":
try:
event = _json.loads(raw)
except Exception:
pass
else:
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
return images, "".join(text_parts).strip()
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
if event.get("type") != "response.output_item.done":
return
item = event.get("item") or {}
if item.get("type") != "image_generation_call":
return
result = item.get("result")
if isinstance(result, str):
if result.startswith("data:image/"):
images.append(result)
else:
images.append(_b64_image_data_url(result))
elif isinstance(result, dict):
image_url = result.get("image_url") or result.get("image") or ""
if isinstance(image_url, str):
if image_url.startswith("data:image/"):
images.append(image_url)
else:
images.append(_b64_image_data_url(image_url))
def _collect_text_from_sse_event(event: dict[str, Any], text_parts: list[str]) -> None:
if event.get("type") == "response.output_text.delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
text_parts.append(delta)
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰) image generation
# ---------------------------------------------------------------------------
@@ -883,8 +1449,11 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
# Provider registration
# ---------------------------------------------------------------------------
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(AIHubMixImageGenerationClient)
register_image_gen_provider(CodexImageGenerationClient)
register_image_gen_provider(GeminiImageGenerationClient)
register_image_gen_provider(OllamaImageGenerationClient)
register_image_gen_provider(MiniMaxImageGenerationClient)
register_image_gen_provider(OpenAIImageGenerationClient)
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(StepFunImageGenerationClient)
+170 -105
View File
@@ -11,25 +11,15 @@ import secrets
import string
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
import json_repair
from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI
else:
if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"install with `pip install langfuse` to enable tracing"
)
from openai import AsyncOpenAI
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import (
consume_sdk_stream,
@@ -39,8 +29,15 @@ from nanobot.providers.openai_responses import (
)
if TYPE_CHECKING:
from openai import AsyncOpenAI as AsyncOpenAIType
from nanobot.providers.registry import ProviderSpec
# Module-level placeholder — set lazily by _ensure_client on first real
# use, or replaced by tests via ``patch(...)``. Kept as a plain name so
# that ``unittest.mock.patch`` can find and replace it.
AsyncOpenAI: Any = None
_ALLOWED_MSG_KEYS = frozenset({
"role", "content", "tool_calls", "tool_call_id", "name",
"reasoning_content", "extra_content",
@@ -78,41 +75,43 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
"enable_thinking": lambda on: {"enable_thinking": on},
"reasoning_split": lambda on: {"reasoning_split": on},
}
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
}
_MODEL_THINKING_STYLES: dict[str, str] = {
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
}
def _is_kimi_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a Kimi thinking-capable model.
Supports two forms:
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
is checked against _KIMI_THINKING_MODELS
This covers both the native Moonshot provider (bare slug) and
OpenRouter-style names (``"publisher/slug"``).
"""
name = model_name.lower()
if name in _KIMI_THINKING_MODELS:
return True
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
return True
return False
def _model_slug(model_name: str) -> str:
return model_name.lower().rsplit("/", 1)[-1]
def _is_mimo_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a MiMo thinking-capable model.
def _model_thinking_style(model_name: str) -> str:
return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "")
Mirrors _is_kimi_thinking_model: gateway providers (e.g. OpenRouter
routing ``xiaomi/mimo-v2.5-pro``) have no ``thinking_style`` on their
spec, so the spec-driven branch in _build_kwargs misses them. The
model-name path catches those cases.
"""
name = model_name.lower()
if name in _MIMO_THINKING_MODELS:
return True
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
return True
return False
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
styles: list[str] = []
if spec and spec.thinking_style:
styles.append(spec.thinking_style)
model_style = _model_thinking_style(model_name)
if model_style and model_style not in styles:
styles.append(model_style)
return styles
def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None:
builder = _THINKING_STYLE_MAP.get(style)
return builder(thinking_enabled) if builder else None
def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None:
if not effort:
return None
builder = _GATEWAY_REASONING_STYLE_MAP.get(style)
return builder(effort) if builder else None
def _openai_compat_timeout_s() -> float:
@@ -302,43 +301,76 @@ class OpenAICompatProvider(LLMProvider):
effective_base = api_base or (spec.default_api_base if spec else None) or None
self._effective_base = effective_base
default_headers = {"x-session-affinity": uuid.uuid4().hex}
self._default_headers = {"x-session-affinity": uuid.uuid4().hex}
if _uses_openrouter_attribution(spec, effective_base):
default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
self._default_headers.update(_DEFAULT_OPENROUTER_HEADERS)
if extra_headers:
default_headers.update(extra_headers)
self._default_headers.update(extra_headers)
self._api_key_for_client = api_key or "no-key"
self._is_local = _is_local_endpoint(spec, effective_base)
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
# HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
# process_direct), the second call may grab a now-dead pooled
# connection, causing a transient APIConnectionError on every first
# attempt. Disabling keepalive for local endpoints avoids this by
# opening a fresh connection for each request, which is cheap on a
# LAN. Cloud providers benefit from keepalive, so we leave the
# default pool settings for them.
timeout_s = _openai_compat_timeout_s()
http_client: httpx.AsyncClient | None = None
if _is_local_endpoint(spec, effective_base):
http_client = httpx.AsyncClient(
limits=httpx.Limits(keepalive_expiry=0),
timeout=timeout_s,
)
self._client = AsyncOpenAI(
api_key=api_key or "no-key",
base_url=effective_base,
default_headers=default_headers,
max_retries=0,
timeout=timeout_s,
http_client=http_client,
)
# Lazy-init: the OpenAI client and its httpx transport are expensive
# to create (~700 ms on Windows). Defer until first use.
self._client: AsyncOpenAIType | None = None
self._client_lock = asyncio.Lock()
# Responses API circuit breaker: skip after repeated failures,
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
self._responses_failures: dict[str, int] = {}
self._responses_tripped_at: dict[str, float] = {}
def _build_client(self) -> None:
"""Create the OpenAI client using the current module-level AsyncOpenAI."""
import httpx
timeout_s = _openai_compat_timeout_s()
http_client: httpx.AsyncClient | None = None
if self._is_local:
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
# HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
# process_direct), the second call may grab a now-dead pooled
# connection, causing a transient APIConnectionError on every first
# attempt. Disabling keepalive for local endpoints avoids this by
# opening a fresh connection for each request, which is cheap on a
# LAN. Cloud providers benefit from keepalive, so we leave the
# default pool settings for them.
http_client = httpx.AsyncClient(
limits=httpx.Limits(keepalive_expiry=0),
timeout=timeout_s,
)
self._client = AsyncOpenAI(
api_key=self._api_key_for_client,
base_url=self._effective_base,
default_headers=self._default_headers,
max_retries=0,
timeout=timeout_s,
http_client=http_client,
)
async def _ensure_client(self):
"""Return the shared OpenAI client, creating it on first call."""
if self._client is not None:
return self._client
async with self._client_lock:
if self._client is not None:
return self._client
global AsyncOpenAI
if AsyncOpenAI is None:
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI as _AsyncOpenAI
else:
if os.environ.get("LANGFUSE_SECRET_KEY"):
logger.warning(
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
"install with `pip install langfuse` to enable tracing"
)
from openai import AsyncOpenAI as _AsyncOpenAI
AsyncOpenAI = _AsyncOpenAI
self._build_client()
return self._client
def _setup_env(self, api_key: str, api_base: str | None) -> None:
"""Set environment variables based on provider spec."""
spec = self._spec
@@ -432,6 +464,7 @@ class OpenAICompatProvider(LLMProvider):
"""Strip non-standard keys, normalize tool_call IDs."""
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
id_map: dict[str, str] = {}
pending_tool_ids: dict[str, deque[str]] = {}
force_string_content = bool(self._spec and self._spec.name == "deepseek")
def map_id(value: Any) -> Any:
@@ -439,15 +472,49 @@ class OpenAICompatProvider(LLMProvider):
return value
return id_map.setdefault(value, self._normalize_tool_call_id(value))
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
if isinstance(value, str) and value:
base = map_id(value)
else:
base = _short_tool_id()
if not isinstance(base, str) or not base:
base = _short_tool_id()
if base not in used_ids:
return base
seed = value if isinstance(value, str) and value else base
salt = 1
while True:
candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}")
if isinstance(candidate, str) and candidate not in used_ids:
return candidate
salt += 1
def map_tool_result_id(value: Any) -> Any:
if not isinstance(value, str):
return value
queue = pending_tool_ids.get(value)
if queue:
mapped = queue.popleft()
if not queue:
pending_tool_ids.pop(value, None)
return mapped
return map_id(value)
for clean in sanitized:
if isinstance(clean.get("tool_calls"), list):
normalized = []
for tc in clean["tool_calls"]:
used_ids: set[str] = set()
for idx, tc in enumerate(clean["tool_calls"]):
if not isinstance(tc, dict):
normalized.append(tc)
continue
tc_clean = dict(tc)
tc_clean["id"] = map_id(tc_clean.get("id"))
raw_id = tc_clean.get("id")
mapped_id = unique_tool_id(raw_id, used_ids, idx)
tc_clean["id"] = mapped_id
used_ids.add(mapped_id)
if isinstance(raw_id, str) and raw_id:
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
function = tc_clean.get("function")
if isinstance(function, dict):
function_clean = dict(function)
@@ -465,7 +532,7 @@ class OpenAICompatProvider(LLMProvider):
# that mix non-empty content with tool_calls.
clean["content"] = None
if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_id(clean["tool_call_id"])
clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"])
if (
force_string_content
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
@@ -552,39 +619,27 @@ class OpenAICompatProvider(LLMProvider):
if wire_effort and semantic_effort != "none":
kwargs["reasoning_effort"] = wire_effort
# Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise.
# The mapping is driven by ProviderSpec.thinking_style so that adding
# a new provider never requires touching this function.
if spec and spec.thinking_style and reasoning_effort is not None:
# Only send thinking controls when reasoning_effort is explicit so
# omitting the config preserves each provider's default.
if reasoning_effort is not None:
thinking_enabled = semantic_effort not in ("none", "minimal")
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
for thinking_style in _thinking_styles_for(spec, model_name):
extra = _thinking_extra_body(thinking_style, thinking_enabled)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
if gateway_style and _model_thinking_style(model_name):
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
# Model-level thinking injection for Kimi thinking-capable models.
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
# identically to bare names like "kimi-k2.5".
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
thinking_enabled = semantic_effort not in ("none", "minimal")
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
# Model-level thinking injection for MiMo thinking-capable models.
# Same shape as Kimi: gateway providers (OpenRouter, etc.) lack the
# xiaomi_mimo spec's thinking_style, so the spec-driven branch above
# misses them — match by model name to catch "xiaomi/mimo-v2.5-pro"
# and friends. (Direct xiaomi_mimo requests are also covered here;
# both branches write the same payload, so the dict update is a
# safe no-op for already-handled cases.)
if reasoning_effort is not None and _is_mimo_thinking_model(model_name):
thinking_enabled = semantic_effort not in ("none", "minimal")
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
# Moonshot rejects requests that carry both 'reasoning_effort'
# and the native 'thinking' param. We already expressed the
# user's intent via the provider-native shape, so drop the
# redundant wire-level kwarg. Only kimi models need this —
# Xiaomi's API accepts both params.
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
kwargs.pop("reasoning_effort", None)
if tools:
kwargs["tools"] = tools
@@ -599,8 +654,7 @@ class OpenAICompatProvider(LLMProvider):
and semantic_effort not in ("none", "minimal")
and (
(spec and spec.thinking_style)
or _is_kimi_thinking_model(model_name)
or _is_mimo_thinking_model(model_name)
or _model_thinking_style(model_name)
)
)
implicit_deepseek_thinking = (
@@ -1068,6 +1122,15 @@ class OpenAICompatProvider(LLMProvider):
if delta:
_accum_legacy_function_call(getattr(delta, "function_call", None))
# Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for
# parallel tool calls in streaming mode. Deduplicate before building
# the response so downstream tool messages don't collide.
_seen_tc_ids: set[str] = set()
for b in tc_bufs.values():
if not b["id"] or b["id"] in _seen_tc_ids:
b["id"] = _short_tool_id()
_seen_tc_ids.add(b["id"])
return LLMResponse(
content="".join(content_parts) or None,
tool_calls=[
@@ -1182,6 +1245,7 @@ class OpenAICompatProvider(LLMProvider):
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
await self._ensure_client()
try:
if self._should_use_responses_api(model, reasoning_effort):
try:
@@ -1223,6 +1287,7 @@ class OpenAICompatProvider(LLMProvider):
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
await self._ensure_client()
idle_timeout_s = int(os.environ.get("NANOBOT_STREAM_IDLE_TIMEOUT_S", "90"))
try:
if self._should_use_responses_api(model, reasoning_effort):
@@ -5,8 +5,6 @@ from __future__ import annotations
import json
from typing import Any
from nanobot.providers.base import LLMProvider
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items.
@@ -17,6 +15,7 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
"""
system_prompt = ""
input_items: list[dict[str, Any]] = []
used_item_ids: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
@@ -32,17 +31,19 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
if role == "assistant":
if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({
"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed", "id": f"msg_{idx}",
"status": "completed", "id": message_id,
})
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id, item_id = split_tool_call_id(tool_call.get("id"))
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
input_items.append({
"type": "function_call",
"id": item_id or f"fc_{idx}",
"id": response_item_id,
"call_id": call_id or f"call_{idx}",
"name": fn.get("name"),
"arguments": fn.get("arguments") or "{}",
@@ -60,10 +61,8 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
def convert_user_message(content: Any) -> dict[str, Any]:
"""Convert a user message's content to Responses API format.
Handles plain strings, ``text`` blocks -> ``input_text``,
``image_url`` blocks -> ``input_image``, and ``input_audio`` blocks.
``video_url`` is downgraded to a text placeholder because Codex does
not support native video.
Handles plain strings, ``text`` blocks -> ``input_text``, and
``image_url`` blocks -> ``input_image``.
"""
if isinstance(content, str):
return {"role": "user", "content": [{"type": "input_text", "text": content}]}
@@ -78,18 +77,6 @@ def convert_user_message(content: Any) -> dict[str, Any]:
url = (item.get("image_url") or {}).get("url")
if url:
converted.append({"type": "input_image", "image_url": url, "detail": "auto"})
elif item.get("type") == "input_audio":
audio_info = item.get("input_audio") or {}
audio_data = audio_info.get("data")
if audio_data:
converted.append({
"type": "input_audio",
"input_audio": {"data": audio_data, "format": audio_info.get("format", "wav")},
})
elif item.get("type") == "video_url":
# Codex doesn't support native video → text placeholder
placeholder = LLMProvider._media_placeholder("video_url", item)
converted.append({"type": "input_text", "text": placeholder["text"]})
if converted:
return {"role": "user", "content": converted}
return {"role": "user", "content": [{"type": "input_text", "text": ""}]}
@@ -113,6 +100,20 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
return converted
def _unique_item_id(item_id: str, used: set[str]) -> str:
"""Return a Responses input item id that is unique within one request."""
if item_id not in used:
used.add(item_id)
return item_id
suffix = 2
while f"{item_id}_{suffix}" in used:
suffix += 1
unique = f"{item_id}_{suffix}"
used.add(unique)
return unique
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
"""Split a compound ``call_id|item_id`` string.
+19 -1
View File
@@ -71,6 +71,11 @@ class ProviderSpec:
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
thinking_style: str = ""
# Gateway-native reasoning control to pair with model-level thinking styles.
# "reasoning_effort" — {"reasoning": {"effort": <none|minimal|...>}}
# (OpenRouter)
gateway_reasoning_style: str = ""
# When True, treat the "reasoning" response field as formal content
# when "content" is empty. Only set this for providers (e.g. StepFun)
# whose API returns the actual answer in "reasoning" instead of "content".
@@ -142,6 +147,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="openrouter",
default_api_base="https://openrouter.ai/api/v1",
supports_prompt_caching=True,
gateway_reasoning_style="reasoning_effort",
),
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
ProviderSpec(
@@ -165,7 +171,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
env_extras=(("APIFREE_API_KEY", "{api_key}"),),
is_gateway=True,
detect_by_base_keyword="apifree.ai",
default_api_base="https://api.apifree.ai/v1",
default_api_base="https://api.apifree.ai/agent/v1",
),
# AiHubMix: global gateway, OpenAI-compatible interface.
# strip_model_prefix=True: doesn't understand "anthropic/claude-3",
@@ -193,6 +199,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://api.siliconflow.cn/v1",
),
# Novita AI: OpenAI-compatible gateway for hosted model APIs.
ProviderSpec(
name="novita",
keywords=("novita",),
env_key="NOVITA_API_KEY",
display_name="Novita AI",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="novita",
default_api_base="https://api.novita.ai/openai",
),
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
ProviderSpec(
name="volcengine",
+17
View File
@@ -165,6 +165,23 @@ class Session:
image_placeholder_text(p) for p in media if isinstance(p, str) and p
)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps")
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
cli_lines: list[str] = []
for item in cli_apps[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
cli_lines.append(
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
f"skill=skills/cli-app-{name}/SKILL.md]"
)
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip():
+8 -4
View File
@@ -1,5 +1,9 @@
# Agent Instructions
## Workspace Guidance
Use this file for project-specific preferences, recurring workflow conventions, and instructions you want the agent to remember for this workspace. Keep durable facts about the user in `USER.md`, personality/style guidance in `SOUL.md`, and long-term memory in `memory/MEMORY.md`.
## Scheduled Reminders
Before scheduling reminders, check available skills and follow skill guidance first.
@@ -10,10 +14,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
## Heartbeat Tasks
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks.
- **Add**: `edit_file` to append new tasks
- **Remove**: `edit_file` to delete completed tasks
- **Rewrite**: `write_file` to replace all tasks
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
- Use `write_file` for first creation or intentional full-file rewrites.
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
-28
View File
@@ -1,28 +0,0 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling.
This file documents non-obvious constraints and usage patterns.
## exec — Safety Limits
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
- Output is truncated at 10,000 characters
- `restrictToWorkspace` config can limit file access to the workspace
## grep — Content Search
- Use `grep` to search file contents inside the workspace
- Default behavior returns only matching file paths (`output_mode="files_with_matches"`)
- Supports optional `glob` filtering (e.g. `glob="*.py"`) plus `context_before` / `context_after`
- Supports `type="py"`, `type="ts"`, `type="md"` and similar shorthand filters
- Use `fixed_strings=true` for literal keywords containing regex characters
- Use `output_mode="files_with_matches"` to get only matching file paths
- Use `output_mode="count"` to size a search before reading full matches
- Use `head_limit` and `offset` to page across results
- Prefer this over `exec` for code and history searches
- Binary or oversized files may be skipped to keep results readable
## cron — Scheduled Reminders
- Please refer to cron skill for usage.
+67
View File
@@ -0,0 +1,67 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling. This section
documents the general tool contract and non-obvious usage patterns.
## General Tool Contract
- Use the narrowest structured tool that directly matches the task.
- Use read-only discovery before writes when state is uncertain.
- Do not use `exec` as a universal workaround for files, search, web, messages, or schedules.
- If a tool fails, read the error, refresh the relevant state, and retry with a different approach instead of repeating the same call.
- After meaningful changes, verify with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output.
- Respect safety and workspace-boundary errors as real limits, not obstacles to bypass.
## Discovery and Reading
- Use `find_files` or `list_dir` to locate workspace paths before `read_file` when a path is uncertain.
- Use `grep` for content search inside the workspace; prefer it over shell grep for ordinary searches.
- `grep` defaults to `output_mode="files_with_matches"`; use `output_mode="content"` for matching lines with context.
- Use `fixed_strings=true` for literal keywords containing regex characters.
- Use `output_mode="count"` to size a broad search before reading full matches.
- Use `head_limit` and `offset` to page across large result sets.
- Binary or oversized files may be skipped to keep results readable.
## File and Coding Workflows
- For code or config changes, the default loop is: locate (`find_files`/`grep`), inspect (`read_file`), edit (`apply_patch`), then verify (`exec` or re-read).
- Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, adds, or deletes.
- Use `apply_patch dry_run=true` when the patch is uncertain and you want validation plus a change summary before writing.
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`; add `occurrence`, `line_hint`, or `expected_replacements` when ambiguity matters.
- Use `write_file` for new files or intentional full-file rewrites, not routine partial edits.
- If `apply_patch` or `edit_file` fails, re-read with `force=true`, narrow the context, and try a smaller patch rather than switching to shell `sed` or `echo`.
## Process Execution
- Use `exec` for tests, builds, package commands, git commands, and other process execution.
- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits.
- Use non-interactive flags such as `-y` or `--yes` when available.
- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated.
- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`.
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
- Use `list_exec_sessions` to recover active session IDs after context shifts.
## CLI App Attachments
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
## Web and External Information
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.
- Use `web_search` to find sources and `web_fetch` for a specific page or result that needs closer reading.
- Do not invent freshness-sensitive facts when tools can verify them.
## Messaging and Media
- Use `message` to send content or local media to the user/channel.
- `read_file` only reads content for your analysis; it does not deliver a file to the user.
- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text.
## Scheduling and Background Work
- Use `cron` for scheduled reminders or recurring jobs; do not run `nanobot cron` through `exec`.
- For heartbeat tasks, update `HEARTBEAT.md` according to the agent instructions.
- Do not write reminders only to memory files when the user expects an actual notification.
+269 -88
View File
@@ -3,15 +3,13 @@
from __future__ import annotations
import difflib
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
_LIVE_EMIT_INTERVAL_S = 0.18
_LIVE_EMIT_LINE_STEP = 24
@@ -154,19 +152,108 @@ def prepare_file_edit_tracker(
workspace: Path | None,
params: dict[str, Any] | None,
) -> FileEditTracker | None:
trackers = prepare_file_edit_trackers(
call_id=call_id,
tool_name=tool_name,
tool=tool,
workspace=workspace,
params=params,
)
return trackers[0] if trackers else None
def prepare_file_edit_trackers(
*,
call_id: str,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[FileEditTracker]:
if not is_file_edit_tool(tool_name):
return None
return []
paths = resolve_file_edit_paths(tool_name, tool, workspace, params)
trackers: list[FileEditTracker] = []
seen: set[Path] = set()
for path in paths:
try:
resolved = path.resolve()
except Exception:
resolved = path
if resolved in seen:
continue
seen.add(resolved)
before = read_file_snapshot(path)
trackers.append(FileEditTracker(
call_id=str(call_id or ""),
tool=tool_name,
path=path,
display_path=display_file_edit_path(path, workspace),
before=before,
))
return trackers
def resolve_file_edit_paths(
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[Path]:
if tool_name == "apply_patch":
return _resolve_apply_patch_paths(tool, workspace, params)
path = resolve_file_edit_path(tool, workspace, params)
if path is None:
return None
before = read_file_snapshot(path)
return FileEditTracker(
call_id=str(call_id or ""),
tool=tool_name,
path=path,
display_path=display_file_edit_path(path, workspace),
before=before,
)
return []
return [path]
def _resolve_apply_patch_paths(
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[Path]:
if not isinstance(params, dict):
return []
edits = params.get("edits")
if not isinstance(edits, list) or not edits:
return []
if params.get("dry_run") is True:
return []
resolved: list[Path] = []
seen: set[Path] = set()
for edit in edits:
if not isinstance(edit, dict):
continue
raw_path = edit.get("path")
if not isinstance(raw_path, str) or not raw_path.strip():
continue
path = _resolve_raw_file_edit_path(tool, workspace, raw_path)
if path is not None and path not in seen:
seen.add(path)
resolved.append(path)
return resolved
def _resolve_raw_file_edit_path(
tool: Any,
workspace: Path | None,
raw_path: str,
) -> Path | None:
resolver = getattr(tool, "_resolve", None)
if callable(resolver):
try:
resolved = resolver(raw_path)
if isinstance(resolved, Path):
return resolved
if resolved:
return Path(resolved)
except Exception:
return None
if workspace is None:
return Path(raw_path).expanduser().resolve()
return (workspace / raw_path).expanduser().resolve()
def build_file_edit_start_event(
@@ -304,6 +391,9 @@ class StreamingFileEditTracker:
self._states[key] = state
state.apply_delta(payload)
if state.name == "apply_patch":
await self._update_apply_patch(state)
return
if state.name not in {"write_file", "edit_file"}:
return
if state.path is None:
@@ -343,10 +433,80 @@ class StreamingFileEditTracker:
deleted=deleted,
)])
async def _update_apply_patch(self, state: _StreamingFileEditState) -> None:
if _json_bool_true(state.arguments, "dry_run"):
return
tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None
events: list[dict[str, Any]] = []
now = time.monotonic()
path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments))
if not path_matches:
return
for i, m in enumerate(path_matches):
raw_path = m.group(1)
path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path)
if path is None:
continue
segment_start = m.start()
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end]
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_text") or ""
added = _text_line_count(new_text) if action in ("replace", "add") else 0
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
delete_file = action == "delete"
file_state = state.patch_files.get(raw_path)
if file_state is None:
tracker = FileEditTracker(
call_id=state.call_id or state.key,
tool="apply_patch",
path=path,
display_path=display_file_edit_path(path, self._workspace),
before=read_file_snapshot(path),
)
file_state = _StreamingPatchFileState(tracker=tracker)
state.patch_files[raw_path] = file_state
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
deleted = _text_line_count(file_state.tracker.before.text or "")
if not file_state.should_emit(added, deleted, now):
continue
file_state.mark_emitted(added, deleted, now)
events.append(build_file_edit_live_event(
file_state.tracker,
added=added,
deleted=deleted,
))
if events:
await self._emit(events)
async def flush(self) -> None:
events: list[dict[str, Any]] = []
now = time.monotonic()
for state in self._states.values():
for file_state in state.patch_files.values():
added, deleted = file_state.last_added, file_state.last_deleted
if not file_state.emitted_once:
continue
if (
file_state.last_emitted_added == added
and file_state.last_emitted_deleted == deleted
):
continue
file_state.mark_emitted(added, deleted, now)
events.append(build_file_edit_live_event(
file_state.tracker,
added=added,
deleted=deleted,
))
if state.tracker is None:
continue
added, deleted = state.live_diff_counts()
@@ -367,12 +527,14 @@ class StreamingFileEditTracker:
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
"""Keep final start/end events keyed to any earlier streamed placeholder."""
used_canonicals: set[str] = set()
for tool_call in final_tool_calls:
canonical = self.canonical_call_id_for(tool_call)
if canonical:
if canonical and canonical not in used_canonicals:
try:
tool_call.id = canonical
except Exception:
used_canonicals.add(canonical)
except (AttributeError, TypeError):
pass
def canonical_call_id_for(self, tool_call: Any) -> str | None:
@@ -389,6 +551,10 @@ class StreamingFileEditTracker:
"""Mark streamed edits as failed when no final tool call will run."""
events: list[dict[str, Any]] = []
for state in self._states.values():
for file_state in state.patch_files.values():
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
continue
events.append(build_file_edit_error_event(file_state.tracker, error))
if state.tracker is None:
continue
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
@@ -492,6 +658,39 @@ class _StreamingJsonStringField:
self.last_char_cr = False
@dataclass(slots=True)
class _StreamingPatchFileState:
tracker: FileEditTracker
emitted_once: bool = False
last_emitted_added: int = -1
last_emitted_deleted: int = -1
last_emit_at: float = 0.0
last_added: int = 0
last_deleted: int = 0
def should_emit(self, added: int, deleted: int, now: float) -> bool:
self.last_added = added
self.last_deleted = deleted
if not self.emitted_once:
return True
if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
return False
if max(
abs(added - self.last_emitted_added),
abs(deleted - self.last_emitted_deleted),
) >= _LIVE_EMIT_LINE_STEP:
return True
return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
def mark_emitted(self, added: int, deleted: int, now: float) -> None:
self.emitted_once = True
self.last_added = added
self.last_deleted = deleted
self.last_emitted_added = added
self.last_emitted_deleted = deleted
self.last_emit_at = now
@dataclass(slots=True)
class _StreamingFileEditState:
key: str
@@ -509,6 +708,7 @@ class _StreamingFileEditState:
new_text: _StreamingJsonStringField = field(
default_factory=lambda: _StreamingJsonStringField("new_text")
)
patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict)
emitted_once: bool = False
last_emitted_added: int = -1
last_emitted_deleted: int = -1
@@ -531,6 +731,7 @@ class _StreamingFileEditState:
self.content.reset()
self.old_text.reset()
self.new_text.reset()
self.patch_files.clear()
return
delta = payload.get("arguments_delta")
if isinstance(delta, str) and delta:
@@ -590,6 +791,14 @@ class _StreamingFileEditState:
name = getattr(tool_call, "name", None)
if name != self.name:
return False
if self.name == "apply_patch":
arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict):
return False
edits = arguments.get("edits")
if not isinstance(edits, list):
return False
return '"edits"' in self.arguments
arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict):
return False
@@ -612,6 +821,51 @@ def _stream_key(payload: dict[str, Any]) -> str:
return ""
def _json_bool_true(source: str, key: str) -> bool:
return re.search(rf'"{re.escape(key)}"\s*:\s*true\b', source) is not None
def _extract_json_string_prefix(source: str, key: str) -> str | None:
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
if match is None:
return None
out: list[str] = []
i = match.end()
escape = False
while i < len(source):
ch = source[i]
if escape:
escape = False
if ch == "n":
out.append("\n")
elif ch == "r":
out.append("\r")
elif ch == "t":
out.append("\t")
elif ch == "u":
digits = source[i + 1:i + 5]
if len(digits) < 4:
break
try:
out.append(chr(int(digits, 16)))
except ValueError:
break
i += 4
else:
out.append(ch)
i += 1
continue
if ch == "\\":
escape = True
i += 1
continue
if ch == '"':
return "".join(out)
out.append(ch)
i += 1
return "".join(out)
def _extract_complete_json_string(source: str, key: str) -> str | None:
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
if match is None:
@@ -704,77 +958,4 @@ def _predict_after_text(
return before_text.replace(old_text, new_text)
return before_text.replace(old_text, new_text, 1)
return None
if tool_name == "notebook_edit":
return _predict_notebook_after_text(params, before_text)
return None
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
try:
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
except Exception:
return None
cells = nb.get("cells")
if not isinstance(cells, list):
return None
try:
cell_index = int(params.get("cell_index", 0))
except (TypeError, ValueError):
return None
new_source = params.get("new_source")
source = new_source if isinstance(new_source, str) else ""
cell_type = (
params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
)
mode = (
params.get("edit_mode")
if params.get("edit_mode") in ("replace", "insert", "delete")
else "replace"
)
if mode == "delete":
if 0 <= cell_index < len(cells):
cells.pop(cell_index)
else:
return None
elif mode == "insert":
insert_at = min(max(cell_index + 1, 0), len(cells))
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
else:
if not (0 <= cell_index < len(cells)):
return None
cell = cells[cell_index]
if not isinstance(cell, dict):
return None
cell["source"] = source
cell["cell_type"] = cell_type
if cell_type == "code":
cell.setdefault("outputs", [])
cell.setdefault("execution_count", None)
else:
cell.pop("outputs", None)
cell.pop("execution_count", None)
nb["cells"] = cells
try:
return json.dumps(nb, indent=1, ensure_ascii=False)
except Exception:
return None
def _empty_notebook() -> dict[str, Any]:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
return cell
+3 -75
View File
@@ -171,79 +171,6 @@ def detect_image_mime(data: bytes) -> str | None:
return None
# Audio formats supported by OpenAI input_audio block
_AUDIO_MIME_COMPAT = {"audio/wav", "audio/mpeg", "audio/mp3", "audio/aac",
"audio/ogg", "audio/flac", "audio/x-m4a", "audio/mp4"}
# Map MIME types to the format token expected by OpenAI-compatible input_audio APIs.
_AUDIO_FORMAT_MAP: dict[str, str] = {
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/ogg": "ogg",
"audio/flac": "flac",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
}
def detect_audio_mime(data: bytes, filename: str = "") -> str | None:
"""Detect audio MIME type from magic bytes; fallback to filename guess."""
if data[:4] == b"RIFF" and data[8:12] == b"WAVE":
return "audio/wav"
if data[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2", b"\xff\xfa"):
return "audio/mpeg"
if data[:4] == b"fLaC":
return "audio/flac"
if data[:4] == b"OggS":
return "audio/ogg"
if len(data) > 8 and data[4:8] == b"ftyp":
# Only claim audio for M4A-specific brands; avoid matching MP4 video.
brand = data[8:12]
if brand in (b"M4A ", b"M4AB", b"M4AC"):
return "audio/x-m4a"
if filename:
import mimetypes as _mt
guessed = _mt.guess_type(filename)[0]
if guessed and guessed.startswith("audio/"):
return guessed
return None
def audio_mime_compat(mime: str | None) -> bool:
"""Check if the audio MIME is compatible with OpenAI input_audio block."""
if not mime:
return False
return mime in _AUDIO_MIME_COMPAT
def audio_format_for_api(mime: str) -> str:
"""Convert an audio MIME type to the format token expected by the API.
Falls back to the subtype portion of the MIME (e.g. "x-m4a" from
"audio/x-m4a") when no explicit mapping exists.
"""
if not mime:
return "wav"
return _AUDIO_FORMAT_MAP.get(mime, mime.split("/")[-1])
# Video formats commonly supported by LLM APIs (data URI inline)
_VIDEO_MIME_COMPAT = {
"video/mp4", "video/quicktime", "video/x-m4v",
"video/webm", "video/x-matroska",
}
def video_mime_compat(mime: str | None) -> bool:
"""Check if the video MIME is in the commonly-supported set."""
if not mime:
return False
return mime in _VIDEO_MIME_COMPAT
def build_image_content_blocks(
raw: bytes, mime: str, path: str, label: str
) -> list[dict[str, Any]]:
@@ -649,7 +576,7 @@ def build_status_content(
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
"""Sync bundled templates to workspace. Only creates missing files."""
"""Sync bundled templates to workspace. Creates missing files without overwriting user files."""
from importlib.resources import files as pkg_files
try:
@@ -662,10 +589,11 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
added: list[str] = []
def _write(src, dest: Path):
content = src.read_text(encoding="utf-8") if src else ""
if dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
dest.write_text(content, encoding="utf-8")
added.append(str(dest.relative_to(workspace)))
for item in tpl.iterdir():
+4
View File
@@ -11,8 +11,10 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
"read_file": (["path", "file_path"], "read {}", True, False),
"write_file": (["path", "file_path"], "write {}", True, False),
"edit": (["file_path", "path"], "edit {}", True, False),
"find_files": (["query", "glob", "path"], "find {}", False, False),
"grep": (["pattern"], 'grep "{}"', False, False),
"exec": (["command"], "$ {}", False, True),
"list_exec_sessions": ([], "exec sessions", False, False),
"web_search": (["query"], 'search "{}"', False, False),
"web_fetch": (["url"], "fetch {}", True, False),
"list_dir": (["path"], "ls {}", True, False),
@@ -81,6 +83,8 @@ def _extract_arg(tc, key_args: list[str]) -> str | None:
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
"""Format a registered tool using its template."""
if not fmt[0] and "{}" not in fmt[1]:
return fmt[1]
val = _extract_arg(tc, fmt[0])
if val is None:
return tc.name
+93
View File
@@ -0,0 +1,93 @@
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
from __future__ import annotations
import re
from typing import Any
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config
QueryParams = dict[str, list[str]]
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
_CLI_APP_ATTACHMENT_KEYS = (
"name",
"display_name",
"category",
"entry_point",
"logo_url",
"brand_color",
)
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
return text[:limit]
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
"""Sanitize structured CLI app mentions sent by the WebUI."""
if not isinstance(raw, list):
return []
out: list[dict[str, str]] = []
seen: set[str] = set()
for item in raw[:8]:
if not isinstance(item, dict):
continue
name = _clip_ws_string(item.get("name"), 64)
if not name or _CLI_APP_NAME_RE.match(name) is None:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
row: dict[str, str] = {"name": key}
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
if value:
row[field] = value
out.append(row)
return out
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _manager() -> CliAppManager:
config = load_config()
cli_cfg = config.tools.cli_apps
return CliAppManager(
workspace=config.workspace_path,
runtime=CliAppsRuntimeConfig(
install_timeout=cli_cfg.install_timeout,
run_timeout=cli_cfg.run_timeout,
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
),
)
def cli_apps_payload() -> dict[str, Any]:
return _manager().payload()
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise CliAppError("missing CLI app name")
manager = _manager()
if action == "install":
return manager.install(name)
if action == "update":
return manager.update(name)
if action == "uninstall":
return manager.uninstall(name)
if action == "test":
return manager.test(name)
raise CliAppError(f"unknown CLI app action '{action}'", status=404)
+4
View File
@@ -73,12 +73,16 @@ def _mask_secret_hint(secret: str | None) -> str | None:
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_oauth:
return False
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if spec.is_oauth:
return True
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
+91 -3
View File
@@ -116,6 +116,55 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
return lines
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
if not isinstance(events, list):
return []
out: list[dict[str, Any]] = []
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") not in {"start", "end", "error"}:
continue
if not isinstance(event.get("name"), str):
fn = event.get("function")
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
continue
out.append(dict(event))
return out
def _tool_event_key(event: dict[str, Any]) -> str:
call_id = event.get("call_id")
if isinstance(call_id, str) and call_id:
return f"call:{call_id}"
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous:
return incoming
if not incoming:
return [dict(event) for event in previous if isinstance(event, dict)]
merged = [dict(event) for event in previous if isinstance(event, dict)]
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
for event in incoming:
key = _tool_event_key(event)
existing_index = index_by_key.get(key)
if existing_index is None:
index_by_key[key] = len(merged)
merged.append(event)
continue
existing = merged[existing_index]
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
if incoming_rank >= existing_rank:
merged[existing_index] = {**existing, **event}
return merged
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
@@ -136,6 +185,7 @@ def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
) -> list[dict[str, Any]]:
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
@@ -405,6 +455,9 @@ def replay_transcript_to_ui_messages(
row["media"] = media_att
if all(m.get("kind") == "image" for m in media_att):
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
cli_apps = rec.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
messages.append(row)
continue
@@ -449,6 +502,24 @@ def replay_transcript_to_ui_messages(
buffer_message_id = None
buffer_parts = []
continue
final_text = rec.get("text")
if isinstance(final_text, str):
if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
"content": final_text,
"isStreaming": True,
"createdAt": _ts_base + idx,
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": final_text, "isStreaming": True}
break
buffer_message_id = None
buffer_parts = []
continue
@@ -486,6 +557,7 @@ def replay_transcript_to_ui_messages(
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events"))
structured = tool_trace_lines_from_events(rec.get("tool_events"))
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
@@ -502,7 +574,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")])
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added:
if not added and not structured_events:
continue
else:
merged_traces = prev_traces + trace_lines
@@ -510,6 +582,9 @@ def replay_transcript_to_ui_messages(
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
if structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
}
messages[-1] = merged
@@ -521,6 +596,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}),
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
@@ -569,7 +645,14 @@ def replay_transcript_to_ui_messages(
buffer_parts = []
continue
for m in messages:
for i, m in enumerate(messages):
if (
augment_assistant_text is not None
and m.get("role") == "assistant"
and m.get("kind") != "trace"
and isinstance(m.get("content"), str)
):
messages[i] = {**m, "content": augment_assistant_text(m["content"])}
m.pop("isStreaming", None)
m.pop("reasoningStreaming", None)
return messages
@@ -579,12 +662,17 @@ def build_webui_thread_response(
session_key: str,
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
) -> dict[str, Any] | None:
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
lines = read_transcript_lines(session_key)
if not lines:
return None
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
msgs = replay_transcript_to_ui_messages(
lines,
augment_user_media=augment_user_media,
augment_assistant_text=augment_assistant_text,
)
return {
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key,
+53
View File
@@ -139,6 +139,13 @@ class TestLoadBootstrapFiles:
for name in ContextBuilder.BOOTSTRAP_FILES:
assert f"## {name}" in result
def test_legacy_tools_md_is_not_bootstrapped(self, tmp_path):
(tmp_path / "TOOLS.md").write_text("workspace tool notes", encoding="utf-8")
builder = _builder(tmp_path)
result = builder._load_bootstrap_files()
assert "TOOLS.md" not in result
assert "workspace tool notes" not in result
def test_utf8_content(self, tmp_path):
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
builder = _builder(tmp_path)
@@ -171,6 +178,37 @@ class TestIsTemplateContent:
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
# ---------------------------------------------------------------------------
# Bundled bootstrap templates
# ---------------------------------------------------------------------------
class TestBundledToolContract:
def test_tool_contract_balances_general_and_coding_workflows(self):
from importlib.resources import files as pkg_files
tpl = pkg_files("nanobot") / "templates" / "agent" / "tool_contract.md"
content = tpl.read_text(encoding="utf-8")
assert "## General Tool Contract" in content
assert "Use the narrowest structured tool" in content
assert "Do not use `exec` as a universal workaround" in content
assert "## File and Coding Workflows" in content
assert "apply_patch" in content
assert "## Web and External Information" in content
assert "## Messaging and Media" in content
assert "## Scheduling and Background Work" in content
assert "pure coding" not in content.lower()
def test_tool_contract_is_injected_without_workspace_file(self, tmp_path):
builder = _builder(tmp_path)
prompt = builder.build_system_prompt()
assert "# Tool Usage Notes" in prompt
assert "## General Tool Contract" in prompt
assert "Do not use `exec` as a universal workaround" in prompt
# ---------------------------------------------------------------------------
# _build_user_content
# ---------------------------------------------------------------------------
@@ -324,6 +362,21 @@ class TestBuildMessages:
assert "Other chat goal." not in str(without_goal[-1]["content"])
assert "Goal (active):" not in str(without_goal[-1]["content"])
def test_current_runtime_lines_are_injected(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages(
[],
"please use @zoom tonight",
current_runtime_lines=[
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
],
)
user_msg = str(messages[-1]["content"])
assert "CLI App Attachment: @zoom" in user_msg
assert "tool=run_cli_app" in user_msg
assert "entry_point=cli-anything-zoom" in user_msg
def test_consecutive_same_role_merged(self, tmp_path):
builder = _builder(tmp_path)
history = [{"role": "user", "content": "previous user message"}]
-178
View File
@@ -1,178 +0,0 @@
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.config.schema import InputLimitsConfig
from nanobot.utils.helpers import detect_audio_mime, video_mime_compat
PNG_BYTES = (
b"\x89PNG\r\n\x1a\n"
b"\x00\x00\x00\rIHDR"
b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00"
b"\x90wS\xde"
b"\x00\x00\x00\x0cIDATx\x9cc``\x00\x00\x00\x04\x00\x01"
b"\x0b\x0e-\xb4"
b"\x00\x00\x00\x00IEND\xaeB`\x82"
)
WAV_BYTES = b"RIFF\x24\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00"
def _builder(tmp_path: Path, input_limits: InputLimitsConfig | None = None) -> ContextBuilder:
return ContextBuilder(tmp_path, input_limits=input_limits)
class TestAudioDetection:
def test_detect_wav_from_magic_bytes(self) -> None:
assert detect_audio_mime(WAV_BYTES) == "audio/wav"
def test_detect_mp3_from_magic_bytes(self) -> None:
mp3 = b"\xff\xfb\x90\x00"
assert detect_audio_mime(mp3) == "audio/mpeg"
def test_detect_fallback_to_filename(self) -> None:
assert detect_audio_mime(b"unknown", filename="song.mp3") == "audio/mpeg"
def test_returns_none_for_non_audio(self) -> None:
assert detect_audio_mime(PNG_BYTES) is None
class TestVideoMimeCompat:
def test_mp4_is_compatible(self) -> None:
assert video_mime_compat("video/mp4") is True
def test_unknown_is_not_compatible(self) -> None:
assert video_mime_compat("video/avi") is False
def test_none_is_not_compatible(self) -> None:
assert video_mime_compat(None) is False
class TestBuildUserContentMultimodal:
def test_audio_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=True)
assert isinstance(content, list)
audio_blocks = [b for b in content if b.get("type") == "input_audio"]
assert len(audio_blocks) == 1
assert audio_blocks[0]["input_audio"]["format"] == "wav"
def test_audio_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "voice.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content("transcribe", [str(path)], supports_audio=False)
assert isinstance(content, list)
assert any("[audio:" in b.get("text", "") for b in content)
def test_video_block_when_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=True)
assert isinstance(content, list)
video_blocks = [b for b in content if b.get("type") == "video_url"]
assert len(video_blocks) == 1
assert video_blocks[0]["video_url"]["url"].startswith("data:video/mp4;base64,")
def test_video_placeholder_when_not_supported(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "clip.mp4"
path.write_bytes(b"\x00" * 64)
content = builder._build_user_content("describe", [str(path)], supports_video=False)
assert isinstance(content, list)
assert any("[video:" in b.get("text", "") for b in content)
def test_vision_fallback_downgrades_image(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
path = tmp_path / "pic.png"
path.write_bytes(PNG_BYTES)
content = builder._build_user_content("look", [str(path)], supports_vision=False)
assert isinstance(content, list)
assert any("[image:" in b.get("text", "") for b in content)
assert not any(b.get("type") == "image_url" for b in content)
def test_image_limit_count(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
paths = []
for i in range(5):
path = tmp_path / f"img{i}.png"
path.write_bytes(PNG_BYTES)
paths.append(str(path))
content = builder._build_user_content("describe", paths)
assert isinstance(content, list)
image_count = sum(1 for b in content if b.get("type") == "image_url")
assert image_count == 3 # default max_input_images
assert any("only the first 3 images" in b.get("text", "") for b in content)
def test_image_limit_bytes(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
big = tmp_path / "big.png"
big.write_bytes(PNG_BYTES + b"x" * builder.input_limits.max_input_image_bytes)
content = builder._build_user_content("analyze", [str(big)])
assert isinstance(content, str)
assert "file too large" in content
def test_audio_limit_count(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audios=1)
builder = _builder(tmp_path, input_limits=limits)
for i in range(2):
path = tmp_path / f"snd{i}.wav"
path.write_bytes(WAV_BYTES)
content = builder._build_user_content(
"compare", [str(tmp_path / "snd0.wav"), str(tmp_path / "snd1.wav")], supports_audio=True
)
assert isinstance(content, list)
audio_count = sum(1 for b in content if b.get("type") == "input_audio")
assert audio_count == 1
assert any("only 1 audio" in b.get("text", "") for b in content)
def test_audio_limit_bytes(self, tmp_path: Path) -> None:
limits = InputLimitsConfig(max_input_audio_bytes=32)
builder = _builder(tmp_path, input_limits=limits)
path = tmp_path / "big.wav"
path.write_bytes(WAV_BYTES + b"x" * 64)
content = builder._build_user_content("analyze", [str(path)], supports_audio=True)
assert isinstance(content, str)
assert "file too large" in content
def test_mixed_media_types(self, tmp_path: Path) -> None:
builder = _builder(tmp_path)
img = tmp_path / "pic.png"
img.write_bytes(PNG_BYTES)
snd = tmp_path / "voice.wav"
snd.write_bytes(WAV_BYTES)
vid = tmp_path / "clip.mp4"
vid.write_bytes(b"\x00" * 64)
content = builder._build_user_content(
"analyze",
[str(img), str(snd), str(vid)],
supports_vision=True,
supports_audio=True,
supports_video=True,
)
assert isinstance(content, list)
assert any(b.get("type") == "image_url" for b in content)
assert any(b.get("type") == "input_audio" for b in content)
assert any(b.get("type") == "video_url" for b in content)
+20
View File
@@ -346,6 +346,26 @@ class TestSyncWorkspaceTemplates:
content = (workspace / "AGENTS.md").read_text()
assert content == "existing content"
def test_does_not_create_tools_md(self, tmp_path):
"""Tool contract is injected internally, not copied into user workspaces."""
workspace = tmp_path / "workspace"
added = sync_workspace_templates(workspace, silent=True)
assert "TOOLS.md" not in added
assert not (workspace / "TOOLS.md").exists()
def test_preserves_existing_tools_md_without_overwriting(self, tmp_path):
"""Legacy user workspaces may have TOOLS.md; sync should leave it untouched."""
workspace = tmp_path / "workspace"
workspace.mkdir(parents=True)
tools_path = workspace / "TOOLS.md"
tools_path.write_text("custom tool notes", encoding="utf-8")
sync_workspace_templates(workspace, silent=True)
assert tools_path.read_text(encoding="utf-8") == "custom tool notes"
def test_creates_memory_directory(self, tmp_path):
"""Should create memory directory structure."""
workspace = tmp_path / "workspace"
@@ -359,6 +359,31 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
session = Session(key="test:cli-app")
session.messages.append(
{
"role": "user",
"content": "please use @drawio",
"cli_apps": [{
"name": "drawio",
"entry_point": "cli-anything-drawio",
}],
}
)
history = session.get_history(max_messages=500)
assert history == [{
"role": "user",
"content": (
"please use @drawio\n"
"[CLI App Attachment: @drawio; tool=run_cli_app; "
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
),
}]
def test_get_history_ignores_media_kwarg_on_non_user_rows():
"""``media`` only ever appears on user entries in practice, but the
synthesizer must be defensive: assistants / tools with list content
+41 -7
View File
@@ -111,6 +111,23 @@ def test_discover_plugins_loads_entry_points():
assert result["line"] is _FakePlugin
def test_discover_plugins_skips_names_outside_enabled_set():
from nanobot.channels.registry import discover_plugins
loaded: list[str] = []
def _load_disabled():
loaded.append("disabled")
return _FakePlugin
ep = SimpleNamespace(name="disabled", load=_load_disabled)
with patch(_EP_TARGET, return_value=[ep]):
result = discover_plugins({"enabled"})
assert result == {}
assert loaded == []
def test_discover_plugins_handles_load_error():
from nanobot.channels.registry import discover_plugins
@@ -152,6 +169,25 @@ def test_discover_all_includes_external_plugin():
assert result["line"] is _FakePlugin
def test_discover_enabled_imports_only_enabled_builtins():
from nanobot.channels.registry import discover_enabled
loaded: list[str] = []
def _load_channel(name: str):
loaded.append(name)
return _FakePlugin
with (
patch("nanobot.channels.registry.load_channel_class", side_effect=_load_channel),
patch(_EP_TARGET, return_value=[]),
):
result = discover_enabled({"enabled"}, _names=["enabled", "disabled"])
assert result == {"enabled": _FakePlugin}
assert loaded == ["enabled"]
def test_discover_all_builtin_shadows_plugin():
from nanobot.channels.registry import discover_all
@@ -180,7 +216,7 @@ async def test_manager_loads_plugin_from_dict_config():
)
with patch(
"nanobot.channels.registry.discover_all",
"nanobot.channels.registry.discover_enabled",
return_value={"fakeplugin": _FakePlugin},
):
mgr = ChannelManager.__new__(ChannelManager)
@@ -210,7 +246,7 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
)
with patch(
"nanobot.channels.registry.discover_all",
"nanobot.channels.registry.discover_enabled",
return_value={"fakeplugin": _FakePlugin},
):
mgr = ChannelManager.__new__(ChannelManager)
@@ -246,7 +282,7 @@ async def test_manager_propagates_openai_transcription_api_base_to_channels():
)
with patch(
"nanobot.channels.registry.discover_all",
"nanobot.channels.registry.discover_enabled",
return_value={"fakeplugin": _FakePlugin},
):
mgr = ChannelManager.__new__(ChannelManager)
@@ -498,10 +534,8 @@ async def test_manager_skips_disabled_plugin():
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
)
with patch(
"nanobot.channels.registry.discover_all",
return_value={"fakeplugin": _FakePlugin},
):
ep = _make_entry_point("fakeplugin", _FakePlugin)
with patch(_EP_TARGET, return_value=[ep]):
mgr = ChannelManager.__new__(ChannelManager)
mgr.config = fake_config
mgr.bus = MessageBus()
+2 -22
View File
@@ -27,11 +27,10 @@ def test_extract_post_content_supports_post_wrapper_shape() -> None:
}
}
text, image_keys, media_items = _extract_post_content(payload)
text, image_keys = _extract_post_content(payload)
assert text == "日报 完成"
assert image_keys == ["img_1"]
assert media_items == []
def test_extract_post_content_keeps_direct_shape_behavior() -> None:
@@ -46,29 +45,10 @@ def test_extract_post_content_keeps_direct_shape_behavior() -> None:
],
}
text, image_keys, media_items = _extract_post_content(payload)
text, image_keys = _extract_post_content(payload)
assert text == "Daily report"
assert image_keys == ["img_a", "img_b"]
assert media_items == []
def test_extract_post_content_extracts_media_tags() -> None:
payload = {
"title": "Video",
"content": [
[
{"tag": "text", "text": "see this"},
{"tag": "media", "file_key": "vid_1"},
]
],
}
text, image_keys, media_items = _extract_post_content(payload)
assert text == "Video see this"
assert image_keys == []
assert media_items == [{"tag": "media", "file_key": "vid_1"}]
def test_register_optional_event_keeps_builder_when_method_missing() -> None:
File diff suppressed because it is too large Load Diff
+525
View File
@@ -0,0 +1,525 @@
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
from nanobot.channels.signal import _markdown_to_signal, _partition_styles
from nanobot.utils.helpers import split_message
def _utf16_len(s: str) -> int:
return len(s.encode("utf-16-le")) // 2
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Return a dict mapping each styled substring to its style list."""
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = plain[start : start + length]
result.setdefault(span, []).append(style)
return result
def utf16_styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Like styles_for, but slices `plain` using UTF-16 offsets (Signal's units)."""
encoded = plain.encode("utf-16-le")
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = encoded[start * 2 : (start + length) * 2].decode("utf-16-le")
result.setdefault(span, []).append(style)
return result
# ---------------------------------------------------------------------------
# Basic cases
# ---------------------------------------------------------------------------
def test_empty():
plain, styles = _markdown_to_signal("")
assert plain == ""
assert styles == []
def test_plain_text():
plain, styles = _markdown_to_signal("hello world")
assert plain == "hello world"
assert styles == []
def test_bold_stars():
plain, styles = _markdown_to_signal("say **hello** now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_bold_underscores():
plain, styles = _markdown_to_signal("say __hello__ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_italic_star():
plain, styles = _markdown_to_signal("say *hello* now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_italic_underscore():
plain, styles = _markdown_to_signal("say _hello_ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_strikethrough():
plain, styles = _markdown_to_signal("say ~~hello~~ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
# ---------------------------------------------------------------------------
# Code
# ---------------------------------------------------------------------------
def test_inline_code():
plain, styles = _markdown_to_signal("run `ls -la` here")
assert plain == "run ls -la here"
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
def test_code_block():
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
assert "print('hi')" in plain
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or "MONOSPACE" in str(
styles_for(plain, styles)
)
def test_code_block_with_lang():
plain, styles = _markdown_to_signal("```python\ncode\n```")
assert "code" in plain
assert any("MONOSPACE" in s for s in styles)
def test_code_block_not_processed_further():
"""Markdown inside a code block must not be styled."""
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
assert "**not bold**" in plain
# Only MONOSPACE should be applied, no BOLD
for entry in styles:
assert "BOLD" not in entry
def test_inline_code_not_processed_further():
"""Markdown inside inline code must not be styled."""
plain, styles = _markdown_to_signal("use `**raw**` please")
assert "**raw**" in plain
for entry in styles:
assert "BOLD" not in entry
# ---------------------------------------------------------------------------
# Headers
# ---------------------------------------------------------------------------
def test_header_becomes_bold():
plain, styles = _markdown_to_signal("# My Title")
assert plain == "My Title"
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
def test_h2_becomes_bold():
plain, styles = _markdown_to_signal("## Sub-section")
assert plain == "Sub-section"
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
# ---------------------------------------------------------------------------
# Blockquotes
# ---------------------------------------------------------------------------
def test_blockquote_strips_marker():
plain, styles = _markdown_to_signal("> some quote")
assert plain == "some quote"
assert styles == []
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
def test_bullet_dash():
plain, styles = _markdown_to_signal("- item one")
assert plain == "• item one"
def test_bullet_star():
plain, styles = _markdown_to_signal("* item two")
assert plain == "• item two"
def test_numbered_list():
plain, styles = _markdown_to_signal("1. first\n2. second")
assert "1. first" in plain
assert "2. second" in plain
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
def test_link_text_differs_from_url():
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
assert plain == "Click here (https://example.com)"
assert styles == []
def test_link_text_equals_url():
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
assert plain == "https://example.com"
assert styles == []
def test_link_text_equals_url_without_scheme():
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
assert plain == "https://example.com"
# ---------------------------------------------------------------------------
# Mixed / nesting
# ---------------------------------------------------------------------------
def test_bold_and_italic_adjacent():
plain, styles = _markdown_to_signal("**bold** and *italic*")
assert plain == "bold and italic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_header_with_inline_code():
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
plain, styles = _markdown_to_signal("# Use `grep`")
assert plain == "Use grep"
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
assert "MONOSPACE" in sd.get("grep", [])
def test_multiline_mixed():
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
plain, styles = _markdown_to_signal(md)
assert "Title" in plain
assert "italic" in plain
assert "• bullet" in plain
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Title", [])
assert "ITALIC" in sd.get("italic", [])
# ---------------------------------------------------------------------------
# Table rendering
# ---------------------------------------------------------------------------
def test_table_rendered_as_monospace():
md = "| A | B |\n| - | - |\n| 1 | 2 |"
plain, styles = _markdown_to_signal(md)
assert "A" in plain and "B" in plain
assert any("MONOSPACE" in s for s in styles)
# ---------------------------------------------------------------------------
# Style range format
# ---------------------------------------------------------------------------
def test_style_range_format():
"""Each style entry must be 'start:length:STYLE'."""
_, styles = _markdown_to_signal("**bold** text")
for entry in styles:
parts = entry.split(":")
assert len(parts) == 3
assert parts[0].isdigit()
assert parts[1].isdigit()
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
def test_style_ranges_are_within_bounds():
text = "hello **world** end"
plain, styles = _markdown_to_signal(text)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= len(plain)
# ---------------------------------------------------------------------------
# Non-BMP / UTF-16 offsets
#
# Signal's BodyRange (and signal-cli's textStyle) interprets start/length in
# UTF-16 code units. Python's len() counts code points, so characters outside
# the BMP (emojis, supplementary CJK) shift offsets by +1 per occurrence.
# ---------------------------------------------------------------------------
def assert_within_utf16_bounds(plain: str, styles: list[str]) -> None:
limit = _utf16_len(plain)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= limit, f"range {entry} exceeds utf-16 length {limit} of {plain!r}"
def test_bold_with_emoji_inside():
plain, styles = _markdown_to_signal("**hi 🎉 bye**")
assert plain == "hi 🎉 bye"
assert utf16_styles_for(plain, styles) == {"hi 🎉 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_italic_with_trailing_emoji():
plain, styles = _markdown_to_signal("*bye 🎉*")
assert plain == "bye 🎉"
assert utf16_styles_for(plain, styles) == {"bye 🎉": ["ITALIC"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_emoji_prefix():
plain, styles = _markdown_to_signal("🎉 **bold**")
assert plain == "🎉 bold"
assert utf16_styles_for(plain, styles) == {"bold": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_and_inside_emoji():
plain, styles = _markdown_to_signal("🎉 **a 🎊 b**")
assert plain == "🎉 a 🎊 b"
assert utf16_styles_for(plain, styles) == {"a 🎊 b": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_supplementary_cjk_in_bold():
"""Non-BMP CJK (U+20BB7) proves the bug is UTF-16, not emoji-specific."""
plain, styles = _markdown_to_signal("**𠮷野家**")
assert plain == "𠮷野家"
assert utf16_styles_for(plain, styles) == {"𠮷野家": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_zwj_emoji_in_bold():
"""ZWJ family sequence = multiple surrogate pairs + BMP ZWJs."""
plain, styles = _markdown_to_signal("**hi 👨‍👩‍👧 bye**")
assert plain == "hi 👨‍👩‍👧 bye"
assert utf16_styles_for(plain, styles) == {"hi 👨‍👩‍👧 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_ascii_offsets_unchanged():
"""ASCII-only path must produce the same offsets as before the UTF-16 fix."""
plain, styles = _markdown_to_signal("**bold** plain *it*")
assert plain == "bold plain it"
assert sorted(styles) == sorted(["0:4:BOLD", "11:2:ITALIC"])
def test_reported_daily_brief_pattern():
"""Regression for the reported bug: a single non-BMP emoji shifts every
subsequent styled span left by 1 UTF-16 unit, lopping off the last letter.
"""
md = (
"**Weather**\n"
"- Conditions: 🌩️ Thunderstorms\n\n"
"**News**\n"
"*World*\n"
"*Local*\n\n"
"**Quote of the Day**"
)
plain, styles = _markdown_to_signal(md)
sd = utf16_styles_for(plain, styles)
assert sd.get("Weather") == ["BOLD"]
assert sd.get("News") == ["BOLD"]
assert sd.get("World") == ["ITALIC"]
assert sd.get("Local") == ["ITALIC"]
assert sd.get("Quote of the Day") == ["BOLD"]
assert_within_utf16_bounds(plain, styles)
# ---------------------------------------------------------------------------
# Chunk redistribution
#
# split_message can break a long Signal payload into multiple chunks. The
# style ranges from _markdown_to_signal are anchored to the full text, so
# they must be redistributed per-chunk with rebased offsets — otherwise
# styles for chunks 1..N are silently lost.
# ---------------------------------------------------------------------------
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
"""Helper: full markdown → signal pipeline, including chunking."""
plain, styles = _markdown_to_signal(text)
chunks = split_message(plain, max_len) if plain else [""]
return chunks, _partition_styles(plain, chunks, styles)
def test_partition_styles_single_chunk_passthrough():
plain, styles = _markdown_to_signal("**bold** plain *it*")
parts = _partition_styles(plain, [plain], styles)
assert parts == [styles]
def test_partition_styles_no_styles():
plain = "hello world"
assert _partition_styles(plain, [plain], []) == [[]]
assert _partition_styles(plain, ["hello", "world"], []) == [[], []]
def test_partition_styles_drops_styles_outside_chunks():
"""Whitespace trimmed by split_message must not carry a style range."""
plain = "a b"
# Fake a style spanning the trimmed whitespace only.
chunks = ["a", "b"]
parts = _partition_styles(plain, chunks, ["1:3:BOLD"])
assert parts == [[], []]
def test_partition_styles_long_message_preserves_chunk_one_styles():
"""A bold span deep in the message must follow the message into chunk 1."""
# Two ~30-char paragraphs separated by a blank line, then **tail**.
line_a = "alpha " * 5 # 30 chars, ends with space
line_b = "beta " * 5
md = f"{line_a.strip()}\n\n{line_b.strip()}\n\n**tail**"
plain, styles = _markdown_to_signal(md)
# Force a split between the paragraphs.
max_len = len(line_a.strip()) + 2 # fits paragraph A + the "\n\n"
chunks = split_message(plain, max_len)
assert len(chunks) >= 2, "test setup must produce a split"
parts = _partition_styles(plain, chunks, styles)
# The bold "tail" should land in the last chunk, with chunk-relative offset.
final_chunk = chunks[-1]
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_chunk_zero_styles_unchanged():
"""Styles entirely in chunk 0 keep their original offsets."""
md = "**head** middle and **tail**"
plain, styles = _markdown_to_signal(md)
# Split so chunk 0 contains "head" and part of the rest, chunk 1 contains "tail".
chunks = split_message(plain, 12)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
# "head" lives in chunk 0; assert its offset is unchanged (chunk 0 starts at 0).
head_entries = [s for s in parts[0] if "BOLD" in s]
assert any(s.startswith("0:4:") for s in head_entries)
def test_partition_styles_with_non_bmp_chunk_offset():
"""Chunk-start offsets must be expressed in UTF-16 code units."""
# Emoji in chunk 0, bold in chunk 1.
md = "🎉 alpha beta gamma\n\n**tail**"
plain, styles = _markdown_to_signal(md)
chunks = split_message(plain, 18)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
final_chunk = chunks[-1]
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_range_spanning_chunks_is_split():
"""A style range that straddles a chunk boundary gets sliced into both chunks."""
# Construct manually: plain = "abc def", style covers "abc def" (whole thing).
plain = "abc def"
chunks = split_message(plain, 4) # "abc" / "def"
assert chunks == ["abc", "def"]
parts = _partition_styles(plain, chunks, ["0:7:BOLD"])
# Chunk 0 holds 0:3:BOLD, chunk 1 holds 0:3:BOLD (length=3 each, "def" only
# since the space was trimmed by lstrip).
assert parts[0] == ["0:3:BOLD"]
assert parts[1] == ["0:3:BOLD"]
# ---------------------------------------------------------------------------
# Adjacency, nesting, and malformed input
# ---------------------------------------------------------------------------
def test_bold_italic_combo_outer_bold_inner_italic():
"""`**_combo_**` carries both BOLD and ITALIC over the same span."""
plain, styles = _markdown_to_signal("**_combo_**")
assert plain == "combo"
sd = styles_for(plain, styles)
assert set(sd.get("combo", [])) == {"BOLD", "ITALIC"}
def test_bold_and_italic_adjacent_no_separator():
"""`**bold***italic*` produces BOLD on `bold` and ITALIC on `italic`."""
plain, styles = _markdown_to_signal("**bold***italic*")
assert plain == "bolditalic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_unclosed_bold_falls_through_as_plain():
"""An unmatched `**` opener round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("**bold")
assert plain == "**bold"
assert styles == []
def test_unclosed_inline_code_falls_through_as_plain():
"""An unmatched backtick round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("use `grep")
assert plain == "use `grep"
assert styles == []
def test_inline_code_inside_blockquote():
"""Blockquote prefix is stripped; inline code becomes MONOSPACE."""
plain, styles = _markdown_to_signal("> use `grep`")
assert plain == "use grep"
sd = styles_for(plain, styles)
assert sd.get("grep") == ["MONOSPACE"]
def test_header_with_inner_bold_produces_contiguous_bold_ranges():
"""`# **wrap** me` — header forces BOLD over the whole line; the inner `**`
splits the run, yielding two contiguous BOLD ranges that together cover
"wrap me". This is intentional Signal renders adjacent same-style ranges
as a single visual span.
"""
plain, styles = _markdown_to_signal("# **wrap** me")
assert plain == "wrap me"
# Both ranges are BOLD; collectively they cover the whole "wrap me".
bold_ranges = [s for s in styles if s.endswith(":BOLD")]
assert len(bold_ranges) == 2
covered = set()
for entry in bold_ranges:
start, length, _ = entry.split(":", 2)
for i in range(int(start), int(start) + int(length)):
covered.add(i)
assert covered == set(range(len(plain)))
+95 -1
View File
@@ -480,6 +480,99 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert second["stream_id"] == "sid"
@pytest.mark.asyncio
async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
media = tmp_path / "media"
def fake_media_dir(channel: str | None = None):
path = media / channel if channel else media
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
assert mock_ws.send.await_count == 3
final = json.loads(mock_ws.send.call_args_list[2][0][0])
assert final["event"] == "stream_end"
assert final["text"].startswith("![Diagram](/api/media/")
@pytest.mark.asyncio
async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
media = tmp_path / "media"
def fake_media_dir(channel: str | None = None):
path = media / channel if channel else media
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
final = json.loads(mock_ws.send.await_args.args[0])
assert final["event"] == "stream_end"
assert final["text"].startswith("![Diagram](/api/media/")
@pytest.mark.asyncio
async def test_send_delta_stream_end_leaves_non_webui_payload_unchanged(tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
final = json.loads(mock_ws.send.await_args.args[0])
assert final == {"event": "stream_end", "chat_id": "chat-1", "stream_id": "sid"}
@pytest.mark.asyncio
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
bus = MagicMock()
@@ -1031,7 +1124,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
assert providers["openrouter"]["configured"] is False
assert providers["openrouter"]["api_key_required"] is True
assert providers["skywork"]["label"] == "Skywork"
assert providers["skywork"]["default_api_base"] == "https://api.apifree.ai/v1"
assert providers["skywork"]["default_api_base"] == "https://api.apifree.ai/agent/v1"
assert providers["ant_ling"]["label"] == "Ant Ling"
assert providers["ant_ling"]["default_api_base"] == "https://api.ant-ling.com/v1"
assert providers["atomic_chat"]["configured"] is False
@@ -1055,6 +1148,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert image_providers["openrouter"]["label"] == "OpenRouter"
assert image_providers["openrouter"]["configured"] is False
assert image_providers["openai_codex"]["configured"] is True
assert image_providers["gemini"]["label"] == "Gemini"
assert body["runtime"]["config_path"] == str(config_path)
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
@@ -105,6 +105,43 @@ async def test_message_without_media_backward_compatible() -> None:
assert call.kwargs["media"] is None
@pytest.mark.asyncio
async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "please use @drawio",
"webui": True,
"cli_apps": [
{
"name": "DrawIO",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
},
{"name": "bad name", "entry_point": "nope"},
],
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata["webui"] is True
assert metadata["cli_apps"] == [{
"name": "drawio",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
}]
@pytest.mark.asyncio
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
@@ -140,6 +140,75 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_payload",
lambda: {
"apps": [
{
"name": "gimp",
"display_name": "GIMP",
"category": "image",
"description": "Image editing",
"requires": "Python",
"source": "harness",
"entry_point": "cli-anything-gimp",
"install_supported": True,
"installed": False,
"available": False,
"status": "not_installed",
"logo_url": None,
"brand_color": None,
"skill_installed": False,
}
],
"installed_count": 0,
"catalog_updated_at": "2026-04-18",
},
)
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_action",
lambda action, query: {
"apps": [],
"installed_count": 1,
"catalog_updated_at": "2026-04-18",
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
},
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
catalog = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps",
headers=auth,
)
assert catalog.status_code == 200
assert catalog.json()["apps"][0]["name"] == "gimp"
installed = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
headers=auth,
)
assert installed.status_code == 200
assert installed.json()["last_action"]["message"] == "install:gimp"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path
@@ -44,6 +44,7 @@ def _ch(
bus: Any,
*,
session_manager: SessionManager | None = None,
workspace_path: Path | None = None,
port: int,
) -> WebSocketChannel:
return WebSocketChannel(
@@ -57,6 +58,7 @@ def _ch(
},
bus,
session_manager=session_manager,
workspace_path=workspace_path,
)
@@ -67,6 +69,15 @@ def bus() -> MagicMock:
return b
def _fake_media_dir(root: Path):
def inner(channel: str | None = None) -> Path:
path = root / channel if channel else root
path.mkdir(parents=True, exist_ok=True)
return path
return inner
async def _http_get(
url: str, headers: dict[str, str] | None = None
) -> httpx.Response:
@@ -123,6 +134,45 @@ def test_sign_media_path_round_trips_via_hmac(
assert _b64url_decode(payload).decode() == "a.png"
def test_local_markdown_image_is_staged_and_rewritten(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "demo_arch.png").write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel._rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)"
)
assert "![Cloud Architecture Diagram](/api/media/" in rewritten
staged = list((media / "websocket").iterdir())
assert len(staged) == 1
assert staged[0].read_bytes() == _PNG_BYTES
def test_local_markdown_image_rejects_workspace_escape(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.png"
outside.write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
text = "![nope](../outside.png)"
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
assert channel._rewrite_local_markdown_images(text) == text
assert not (media / "websocket").exists()
# ---------------------------------------------------------------------------
# /api/media/<sig>/<payload>: the serving handler
# ---------------------------------------------------------------------------
+526
View File
@@ -1,6 +1,7 @@
import asyncio
import json
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -374,6 +375,7 @@ async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-typing"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
channel._api_post = AsyncMock(
side_effect=[
@@ -402,6 +404,7 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-no-ticket"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})
@@ -1254,3 +1257,526 @@ async def test_send_text_succeeds_on_zero_errcode() -> None:
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
"""_send_text must raise when the API returns ret != 0, even if errcode is 0.
The iLink API signals failure through either field. Checking only errcode
caused silent message drops (responses generated but never delivered).
"""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()
# ---------------------------------------------------------------------------
# Tests for _poll_once not silently dropping messages on processing errors
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
"""When _process_message raises, _poll_once must log the error and continue
processing remaining messages instead of silently swallowing the exception."""
channel, _bus = _make_channel()
channel._client = SimpleNamespace(timeout=None)
channel._token = "token"
channel._get_updates_buf = "old-buf"
calls = []
logged_messages: list[str] = []
async def _failing_process(msg: dict) -> None:
calls.append(msg.get("message_id"))
if msg.get("message_id") == "msg-1":
raise RuntimeError("processing failed")
channel._process_message = _failing_process # type: ignore[method-assign]
monkeypatch.setattr(
channel.logger,
"exception",
lambda message, *args, **kwargs: logged_messages.append(str(message)),
)
channel._api_post = AsyncMock( # type: ignore[method-assign]
return_value={
"ret": 0,
"errcode": 0,
"get_updates_buf": "new-buf",
"msgs": [
{"message_id": "msg-1", "message_type": 1},
{"message_id": "msg-2", "message_type": 1},
],
}
)
await channel._poll_once()
# Both messages should have been attempted
assert calls == ["msg-1", "msg-2"]
# Buffer should still advance (already updated before processing)
assert channel._get_updates_buf == "new-buf"
# Error should be logged
assert any("Failed to process WeChat message" in m for m in logged_messages)
@pytest.mark.asyncio
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
"""When _poll_once raises a non-timeout exception, the start() loop must log
the error and continue polling instead of exiting silently."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.config.token = "token" # skip QR login in start()
channel._running = True
call_count = 0
logged_messages: list[str] = []
async def _failing_poll() -> None:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("poll exploded")
channel._running = False # Stop after second call
channel._poll_once = _failing_poll # type: ignore[method-assign]
monkeypatch.setattr(
channel.logger,
"exception",
lambda message, *args, **kwargs: logged_messages.append(str(message)),
)
# Use a tiny retry delay so the test finishes quickly
original_retry = weixin_mod.RETRY_DELAY_S
weixin_mod.RETRY_DELAY_S = 0.01
try:
await channel.start()
finally:
weixin_mod.RETRY_DELAY_S = original_retry
assert call_count == 2
assert any("WeChat poll loop error" in m for m in logged_messages)
# ---------------------------------------------------------------------------
# Tool-hint buffering
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Using tool",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["Using tool"]
@pytest.mark.asyncio
async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
for hint in ["tool1", "tool2"]:
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": hint,
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 2
channel._send_text.assert_any_await("wx-user", "tool1\n\ntool2", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_thought_progress_flushes_tool_hints() -> None:
"""Thoughts are visible progress messages and must act as separators,
flushing buffered tool hints before they are sent."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send a thought — progress but not a tool_hint.
# It must act as a separator and flush the buffered hint.
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Let me think...",
"media": [],
"metadata": {"_progress": True},
},
)()
)
# The buffered hint was flushed before the thought was sent.
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Let me think...", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
# Final answer arrives with nothing left to flush.
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 3
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
@pytest.mark.asyncio
async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"""Reasoning deltas are invisible in WeChat and must NOT flush buffered
tool hints otherwise hints separated only by hidden reasoning would
fail to coalesce."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send a reasoning delta — invisible in WeChat, must NOT flush
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Thinking step 1...",
"media": [],
"metadata": {"_progress": True, "_reasoning_delta": True},
},
)()
)
# Reasoning is invisible; hint stays buffered, _send_text not called
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
# Final answer flushes the buffered hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"""Empty progress messages (e.g. after_iteration tool_events) have no
visible content and must NOT act as separators."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send an empty progress message (no content, no media)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "",
"media": [],
"metadata": {"_progress": True, "_tool_events": [{"phase": "end"}]},
},
)()
)
# Nothing should have been sent yet
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
# Final answer flushes the buffered hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_buffer_flush_refreshes_context_token() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-old"
channel._context_token_at["wx-user"] = time.time()
channel._refresh_context_token_if_stale = AsyncMock(return_value="ctx-refreshed")
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._refresh_context_token_if_stale.await_count == 2
channel._refresh_context_token_if_stale.assert_any_await("wx-user", "ctx-old")
channel._send_text.assert_any_await("wx-user", "hint", "ctx-refreshed")
@pytest.mark.asyncio
async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock(side_effect=[RuntimeError("boom"), None])
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 2
channel._send_text.assert_any_await("wx-user", "hint", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
@pytest.mark.asyncio
async def test_buffer_flushed_on_stream_end() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send_delta("wx-user", "", {"_stream_end": True})
channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_stop_clears_buffer() -> None:
channel, _bus = _make_channel()
channel._pending_tool_hints["wx-user"] = ["hint1", "hint2"]
await channel.stop()
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_send_tool_hints_false_drops_tool_hints() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = False
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
channel._send_text.assert_not_awaited()
assert "wx-user" not in channel._pending_tool_hints
+3 -1
View File
@@ -572,6 +572,7 @@ async def test_github_copilot_provider_refreshes_client_api_key_before_chat():
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
provider = GitHubCopilotProvider(default_model="github-copilot/gpt-4")
await provider._ensure_client()
provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token")
@@ -611,7 +612,8 @@ def test_make_provider_passes_extra_headers_to_custom_provider():
)
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
make_provider(config)
provider = make_provider(config)
asyncio.run(provider._ensure_client())
kwargs = mock_async_openai.call_args.kwargs
assert kwargs["api_key"] == "test-key"
+408
View File
@@ -0,0 +1,408 @@
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
import pytest
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def _manager(tmp_path: Path) -> CliAppManager:
workspace = tmp_path / "workspace"
workspace.mkdir()
return CliAppManager(
workspace=workspace,
data_dir=tmp_path / "data",
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
)
def _seed_catalog(manager: CliAppManager) -> None:
harness = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"requires": "Python 3.10+",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
"skill_md": "skills/cli-anything-gimp/SKILL.md",
}
],
}
public = {
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"description": "Public duplicate entry",
},
{
"name": "jimeng",
"display_name": "Jimeng",
"version": "latest",
"description": "Script install",
"category": "ai",
"install_strategy": "script",
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
"entry_point": "dreamina",
},
{
"name": "feishu",
"display_name": "Feishu/Lark CLI",
"version": "latest",
"description": "Official Lark CLI",
"category": "communication",
"package_manager": "npm",
"npm_package": "@larksuite/cli",
"install_cmd": "npm install -g @larksuite/cli",
"entry_point": "lark-cli",
},
{
"name": "dify-workflow",
"display_name": "Dify Workflow",
"version": "latest",
"description": "Run Dify workflows",
"category": "ai",
"install_cmd": "pip install cli-anything-dify-workflow",
"entry_point": "cli-anything-dify-workflow",
},
{
"name": "shopify",
"display_name": "Shopify CLI",
"version": "latest",
"description": "Shopify",
"category": "web",
"package_manager": "npm",
"npm_package": "@shopify/cli",
"install_cmd": "npm install -g @shopify/cli",
"entry_point": "shopify",
},
{
"name": "clibrowser",
"display_name": "clibrowser",
"version": "latest",
"description": "Cargo install",
"category": "web",
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
"entry_point": "clibrowser",
},
{
"name": "suno",
"display_name": "Suno CLI",
"version": "latest",
"description": "python3 pip install",
"category": "music",
"package_manager": "pip",
"install_strategy": "command",
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
"entry_point": "suno",
},
],
}
_write_cache(manager._cache_path("harness"), harness)
_write_cache(manager._cache_path("public"), public)
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-04-18"
apps = {app["name"]: app for app in payload["apps"]}
assert set(apps) == {
"clibrowser",
"dify-workflow",
"feishu",
"gimp",
"jimeng",
"shopify",
"suno",
}
assert apps["gimp"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry"
assert apps["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True
assert apps["gimp"]["logo_url"]
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
assert apps["feishu"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
)
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
assert apps["clibrowser"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
)
def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(
manager,
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
payload = manager.install("gimp")
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
assert payload["last_action"]["ok"] is True
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert set(installed) == {"zoom"}
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
def test_fetch_skill_content_rejects_untrusted_urls(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "https://example.com/SKILL.md",
}) is None
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "skills/../evil/SKILL.md",
}) is None
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
seen: list[str] = []
class Response:
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
@staticmethod
def raise_for_status() -> None:
return None
def fake_get(url: str, **kwargs):
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
})
assert content and "# Test" in content
assert seen == [
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
]
def test_uninstall_removes_installed_state_and_generated_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"suno": {"entry_point": "suno"}})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
payload = manager.uninstall("suno")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
assert payload["last_action"]["ok"] is True
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed(
{
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
}
)
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
assert mentions == [
{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
with pytest.raises(CliAppError, match="not found"):
manager.install("missing")
with pytest.raises(CliAppError, match="unsupported"):
manager.install("jimeng")
def test_run_installed_cli_uses_argv_without_shell(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert "shell" not in kwargs or kwargs["shell"] is False
return subprocess.CompletedProcess(
argv,
0,
stdout="ARGS=" + repr(argv[1:]),
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed(
{
"gimp": {
"version": "1.0.0",
"entry_point": "cli-anything-gimp",
"source": "harness",
"strategy": "pip",
}
}
)
result = manager.run("gimp", ["project", "list"], json_output=True)
assert "CLI app 'gimp' exited 0" in result
assert "['--json', 'project', 'list']" in result
def test_run_reports_created_artifacts(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
cwd = Path(str(kwargs["cwd"]))
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
result = manager.run("gimp", ["render"])
assert "Artifacts created or updated:" in result
assert "diagram.png (previewable image" in result
assert "![diagram](diagram.png)" in result
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
with pytest.raises(CliAppError, match="outside the configured workspace"):
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
import asyncio
import json
import subprocess
import time
from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def test_run_cli_app_uses_installed_registry_app(
tmp_path: Path,
monkeypatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"gimp": {"entry_point": "cli-anything-gimp"}}
)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert "shell" not in kwargs or kwargs["shell"] is False
return subprocess.CompletedProcess(
argv,
0,
stdout="tool:" + " ".join(argv[1:]),
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(
workspace=workspace,
restrict_to_workspace=True,
runtime=CliAppsRuntimeConfig(run_timeout=5),
)
assert tool.name == "run_cli_app"
result = asyncio.run(
tool.execute(
name="gimp",
args=["project", "list"],
json=True,
working_dir=str(workspace),
)
)
assert "CLI app 'gimp' exited 0" in result
assert "tool:--json project list" in result
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
result = asyncio.run(tool.execute(name="gimp"))
assert "not installed" in result
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"drawio": {"entry_point": "cli-anything-drawio"}}
)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace)
assert "Settings CLI Apps: drawio" in tool.description
assert "ordinary system CLIs such as git, gh" in tool.description
+64
View File
@@ -0,0 +1,64 @@
"""Tests for CLI Apps loop helpers."""
from types import SimpleNamespace
from nanobot.cli_apps.service import CliAppManager
from nanobot.cli_apps.utils import runtime_lines, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
cli_apps = [{"name": "zoom"}]
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
assert session_extra({}) == {}
assert session_extra(None) == {}
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
manager = CliAppManager(workspace=tmp_path)
manager._save_installed(
{
"zoom": {
"entry_point": "cli-anything-zoom",
"source": "harness",
},
"krita": {
"entry_point": "cli-anything-krita",
"source": "harness",
},
}
)
lines = runtime_lines(
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
lines = runtime_lines(
SimpleNamespace(
content="please use @zoom tonight",
metadata={
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
+17
View File
@@ -192,3 +192,20 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
})
name = config.get_provider_name()
assert name == "anthropic"
def test_match_provider_routes_forced_novita_model_api_models() -> None:
config = Config.model_validate({
"providers": {
"novita": {"apiKey": "sk-test"},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
"provider": "novita",
}
},
})
assert config.get_provider_name() == "novita"
assert config.get_api_base() == "https://api.novita.ai/openai"
+29
View File
@@ -56,6 +56,35 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None:
assert result.content == "hello world"
def test_custom_provider_parse_chunks_deduplicates_parallel_tool_call_ids() -> None:
chunks = [{
"choices": [{
"finish_reason": "tool_calls",
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_dup",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
},
{
"index": 1,
"id": "call_dup",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
},
],
},
}],
}]
result = OpenAICompatProvider._parse_chunks(chunks)
ids = [tool_call.id for tool_call in result.tool_calls or []]
assert ids[0] == "call_dup"
assert len(ids) == 2
assert len(set(ids)) == 2
def test_local_provider_502_error_includes_reachability_hint() -> None:
spec = find_by_name("ollama")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
@@ -65,6 +65,7 @@ async def test_github_copilot_does_not_fall_back_from_responses_error():
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI", return_value=mock_client):
provider = GitHubCopilotProvider(default_model="github_copilot/gpt-5.4-mini")
await provider._ensure_client()
provider._get_copilot_access_token = AsyncMock(return_value="copilot-access-token")
response = await provider.chat(
+512
View File
@@ -9,10 +9,13 @@ import pytest
from nanobot.providers.image_generation import (
AIHubMixImageGenerationClient,
CodexImageGenerationClient,
GeminiImageGenerationClient,
GeneratedImageResponse,
ImageGenerationError,
MiniMaxImageGenerationClient,
OllamaImageGenerationClient,
OpenAIImageGenerationClient,
OpenRouterImageGenerationClient,
StepFunImageGenerationClient,
)
@@ -36,12 +39,14 @@ class FakeResponse:
payload: dict[str, Any],
status_code: int = 200,
content: bytes = b"",
sse_lines: list[str] | None = None,
) -> None:
self._payload = payload
self.status_code = status_code
self.text = str(payload)
self.content = content
self.request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
self._sse_lines = sse_lines
def json(self) -> dict[str, Any]:
return self._payload
@@ -51,6 +56,15 @@ class FakeResponse:
response = httpx.Response(self.status_code, request=self.request, text=self.text)
raise httpx.HTTPStatusError("failed", request=self.request, response=response)
async def aiter_lines(self):
if self._sse_lines is not None:
for line in self._sse_lines:
yield line
return
# Fallback: treat response text as SSE lines
for line in self.text.split("\n"):
yield line
class FakeClient:
def __init__(self, response: FakeResponse) -> None:
@@ -133,6 +147,54 @@ async def test_openrouter_image_generation_requires_api_key() -> None:
await client.generate(prompt="draw", model="model")
@pytest.mark.asyncio
async def test_ollama_image_generation_payload_and_response() -> None:
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
fake = FakeClient(FakeResponse({"image": raw_b64}))
client = OllamaImageGenerationClient(
api_key="ollama-test",
api_base="http://localhost:11434/v1/",
extra_headers={"X-Test": "1"},
extra_body={"seed": 123},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a sunset",
model="x/z-image-turbo",
aspect_ratio="16:9",
image_size="1K",
)
assert response.images == [PNG_DATA_URL]
assert response.content == ""
call = fake.calls[0]
assert call["url"] == "http://localhost:11434/api/generate"
assert call["headers"]["Authorization"] == "Bearer ollama-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "x/z-image-turbo"
assert body["prompt"] == "a sunset"
assert body["width"] == 1024
assert body["height"] == 576
assert body["steps"] == 0
assert body["stream"] is False
assert body["seed"] == 123
@pytest.mark.asyncio
async def test_ollama_image_generation_rejects_reference_images() -> None:
client = OllamaImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="reference images"):
await client.generate(
prompt="edit this",
model="x/z-image-turbo",
reference_images=["ref.png"],
)
@pytest.mark.asyncio
async def test_aihubmix_image_generation_payload_and_response() -> None:
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
@@ -348,6 +410,11 @@ async def test_gemini_requires_api_key() -> None:
await client.generate(prompt="draw", model="imagen-4.0-generate-001")
def test_gemini_image_client_uses_native_api_base_by_default() -> None:
client = GeminiImageGenerationClient(api_key="AIza-test")
assert client.api_base == "https://generativelanguage.googleapis.com/v1beta"
@pytest.mark.asyncio
async def test_gemini_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
@@ -390,6 +457,17 @@ async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path)
assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
@pytest.mark.asyncio
async def test_minimax_base64_response_uses_detected_mime() -> None:
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
fake = FakeClient(FakeResponse({"data": {"image_base64": [raw_b64]}}))
client = MiniMaxImageGenerationClient(api_key="sk-mm-test", client=fake) # type: ignore[arg-type]
response = await client.generate(prompt="draw", model="image-01")
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰)
# ---------------------------------------------------------------------------
@@ -515,3 +593,437 @@ async def test_stepfun_no_images_raises() -> None:
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="step-image-edit-2")
# ---------------------------------------------------------------------------
# OpenAI
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_openai_payload_and_response() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
api_base="https://api.openai.com/v1",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a cat on the moon",
model="dall-e-3",
aspect_ratio="16:9",
)
assert response.images == [PNG_DATA_URL]
call = fake.calls[0]
assert call["url"] == "https://api.openai.com/v1/images/generations"
assert call["headers"]["Authorization"] == "Bearer sk-openai-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "dall-e-3"
assert body["prompt"] == "a cat on the moon"
assert body["response_format"] == "b64_json"
assert body["n"] == 1
assert body["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_b64_json_response_uses_detected_mime() -> None:
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
fake = FakeClient(FakeResponse({"data": [{"b64_json": raw_b64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
@pytest.mark.asyncio
async def test_openai_url_download_fallback() -> None:
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
fake.get_response = FakeResponse({}, content=PNG_BYTES)
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert response.images[0].startswith("data:image/png;base64,")
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
@pytest.mark.asyncio
async def test_openai_multiple_images() -> None:
fake = FakeClient(FakeResponse({
"data": [
{"b64_json": RAW_B64},
{"b64_json": RAW_B64},
]
}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert len(response.images) == 2
assert response.images == [PNG_DATA_URL, PNG_DATA_URL]
@pytest.mark.asyncio
async def test_openai_aspect_ratio_to_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="1:1")
assert fake.calls[0]["json"]["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_dalle3_uses_supported_orientation_sizes() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="3:4")
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="4:3")
assert fake.calls[0]["json"]["size"] == "1024x1792"
assert fake.calls[1]["json"]["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_dalle2_uses_square_size_for_non_square_ratios() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-2", aspect_ratio="16:9")
assert fake.calls[0]["json"]["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_gpt_image_uses_supported_landscape_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="16:9")
assert fake.calls[0]["json"]["size"] == "1536x1024"
@pytest.mark.asyncio
async def test_openai_gpt_image_uses_supported_orientation_sizes() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="3:4")
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="4:3")
assert fake.calls[0]["json"]["size"] == "1024x1536"
assert fake.calls[1]["json"]["size"] == "1536x1024"
@pytest.mark.asyncio
async def test_openai_default_size_when_no_aspect_ratio() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3")
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_ignores_explicit_size_unsupported_by_model_family() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="draw",
model="dall-e-3",
aspect_ratio="16:9",
image_size="1536x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_uses_explicit_image_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="draw",
model="dall-e-3",
aspect_ratio="16:9",
image_size="1024x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_requires_api_key() -> None:
client = OpenAIImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="API key"):
await client.generate(prompt="draw", model="dall-e-3")
# ---------------------------------------------------------------------------
# OpenAI Codex (Responses API)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_codex_payload_and_response(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
sse_lines = [
'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}',
"",
f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1","type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}',
"",
'data: [DONE]',
"",
]
fake = FakeClient(FakeResponse({}, sse_lines=sse_lines))
client = CodexImageGenerationClient(
api_key=None,
api_base="https://chatgpt.com/backend-api",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="draw a cat",
model="gpt-5.4",
)
assert response.images == [PNG_DATA_URL]
assert response.content == ""
call = fake.calls[0]
assert call["url"] == "https://chatgpt.com/backend-api/codex/responses"
assert call["headers"]["Authorization"] == "Bearer oauth-token"
assert call["headers"]["chatgpt-account-id"] == "acct-123"
assert call["headers"]["OpenAI-Beta"] == "responses=experimental"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "gpt-5.4"
assert body["instructions"] == "Generate an image based on the user's request."
assert body["input"] == [{"role": "user", "content": "draw a cat"}]
assert body["tools"] == [{"type": "image_generation"}]
assert body["tool_choice"] == "auto"
assert body["store"] is False
assert body["stream"] is True
@pytest.mark.asyncio
async def test_codex_strips_model_prefix(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="openai-codex/gpt-5.4")
assert fake.calls[0]["json"]["model"] == "gpt-5.4"
@pytest.mark.asyncio
async def test_codex_requires_oauth(monkeypatch) -> None:
async def fake_to_thread(fn, *args, **kwargs):
raise RuntimeError("no token")
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
client = CodexImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="OAuth token"):
await client.generate(prompt="draw", model="gpt-5.4")
@pytest.mark.asyncio
async def test_codex_no_images_raises(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
'data: {"type":"response.completed","response":{"status":"completed"}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="gpt-5.4")
@pytest.mark.asyncio
async def test_codex_extracts_text_content(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
'data: {"type":"response.output_text.delta","delta":"Here "}',
"",
'data: {"type":"response.output_text.delta","delta":"is your cat image."}',
"",
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
response = await client.generate(prompt="draw a cat", model="gpt-5.4")
assert response.images == [PNG_DATA_URL]
assert response.content == "Here is your cat image."
@pytest.mark.asyncio
async def test_codex_json_result_format(monkeypatch) -> None:
"""image_generation_call result can be a dict with image_url key."""
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":{{"image_url":"{PNG_DATA_URL}"}}}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="gpt-5.4")
assert response.images == [PNG_DATA_URL]
@pytest.mark.asyncio
async def test_openai_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"data": []}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="dall-e-3")
+84 -12
View File
@@ -441,6 +441,15 @@ def test_openrouter_spec_is_gateway() -> None:
assert spec.default_api_base == "https://openrouter.ai/api/v1"
def test_novita_spec_uses_openai_compatible_gateway() -> None:
spec = find_by_name("novita")
assert spec is not None
assert spec.is_gateway is True
assert spec.backend == "openai_compat"
assert spec.env_key == "NOVITA_API_KEY"
assert spec.default_api_base == "https://api.novita.ai/openai"
def test_gemma_routes_to_gemini_provider() -> None:
"""gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set.
Users running gemma via the Gemini API endpoint expect automatic provider detection."""
@@ -449,27 +458,34 @@ def test_gemma_routes_to_gemini_provider() -> None:
assert "gemma" in spec.keywords
def test_openrouter_sets_default_attribution_headers() -> None:
def test_gemini_spec_keeps_openai_compat_base() -> None:
spec = find_by_name("gemini")
assert spec is not None
assert spec.default_api_base == "https://generativelanguage.googleapis.com/v1beta/openai/"
async def test_openrouter_sets_default_attribution_headers() -> None:
spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
OpenAICompatProvider(
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
provider = OpenAICompatProvider(
api_key="sk-or-test-key",
api_base="https://openrouter.ai/api/v1",
default_model="anthropic/claude-sonnet-4-5",
spec=spec,
)
await provider._ensure_client()
headers = MockClient.call_args.kwargs["default_headers"]
headers = mock_client_cls.call_args.kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://github.com/HKUDS/nanobot"
assert headers["X-OpenRouter-Title"] == "nanobot"
assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent"
assert "x-session-affinity" in headers
def test_openrouter_user_headers_override_default_attribution() -> None:
async def test_openrouter_user_headers_override_default_attribution() -> None:
spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
OpenAICompatProvider(
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
provider = OpenAICompatProvider(
api_key="sk-or-test-key",
api_base="https://openrouter.ai/api/v1",
default_model="anthropic/claude-sonnet-4-5",
@@ -480,8 +496,9 @@ def test_openrouter_user_headers_override_default_attribution() -> None:
},
spec=spec,
)
await provider._ensure_client()
headers = MockClient.call_args.kwargs["default_headers"]
headers = mock_client_cls.call_args.kwargs["default_headers"]
assert headers["HTTP-Referer"] == "https://nanobot.ai"
assert headers["X-OpenRouter-Title"] == "Nanobot Pro"
assert headers["X-OpenRouter-Categories"] == "cli-agent,personal-agent"
@@ -1005,6 +1022,41 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
def test_openai_compat_deduplicates_duplicate_tool_call_ids_in_history() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "check both files"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "ab1b45c2a",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
},
{
"id": "ab1b45c2a",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
},
],
},
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "a"},
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "b"},
{"role": "user", "content": "continue"},
])
tool_call_ids = [tc["id"] for tc in sanitized[1]["tool_calls"]]
tool_result_ids = [sanitized[2]["tool_call_id"], sanitized[3]["tool_call_id"]]
assert tool_call_ids[0] == "ab1b45c2a"
assert len(tool_call_ids) == len(set(tool_call_ids)) == 2
assert tool_result_ids == tool_call_ids
def test_openai_compat_stringifies_dict_tool_arguments() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
@@ -1374,12 +1426,15 @@ def test_kimi_k25_thinking_enabled() -> None:
"""kimi-k2.5 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
# Moonshot rejects both 'reasoning_effort' and 'thinking' (#3939)
assert "reasoning_effort" not in kw
def test_kimi_k25_thinking_disabled_for_minimal() -> None:
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
@@ -1389,21 +1444,36 @@ def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking."""
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
OR drops upstream-provider `thinking` fields, so the same intent also has
to go through OR's `reasoning.effort` shape (#3851 follow-up).
"""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert kw.get("extra_body") == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
# Even via OR, reasoning_effort wire kwarg is dropped for kimi models
assert "reasoning_effort" not in kw
def test_kimi_k26_thinking_enabled() -> None:
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking
via both upstream `thinking` and OR's `reasoning.effort`."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert kw.get("extra_body") == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
assert "reasoning_effort" not in kw
def test_moonshot_kimi_k26_temperature_override() -> None:
@@ -1422,6 +1492,7 @@ def test_kimi_k26_code_preview_thinking_enabled() -> None:
"""k2.6-code-preview also supports thinking; should behave like k2.5."""
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k2_series_no_thinking_injection() -> None:
@@ -1451,6 +1522,7 @@ def test_kimi_k25_thinking_disabled_for_none_string() -> None:
"""reasoning_effort='none' maps to thinking disabled for kimi-k2.5."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none")
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
assert "reasoning_effort" not in kw
def test_dashscope_thinking_disabled_for_none_string() -> None:
@@ -85,17 +85,18 @@ class TestIsLocalEndpoint:
class TestLocalKeepaliveConfig:
"""Verify that local endpoints get keepalive_expiry=0."""
def test_local_spec_disables_keepalive(self):
async def test_local_spec_disables_keepalive(self):
spec = _make_spec(is_local=True)
spec.env_key = ""
spec.default_api_base = "http://localhost:11434/v1"
provider = OpenAICompatProvider(
api_key="test", api_base="http://localhost:11434/v1", spec=spec,
)
await provider._ensure_client()
pool = provider._client._client._transport._pool
assert pool._keepalive_expiry == 0
def test_lan_ip_disables_keepalive(self):
async def test_lan_ip_disables_keepalive(self):
"""A generic 'openai' spec with a LAN IP should still disable keepalive."""
spec = _make_spec(is_local=False)
spec.env_key = ""
@@ -103,16 +104,18 @@ class TestLocalKeepaliveConfig:
provider = OpenAICompatProvider(
api_key="test", api_base="http://192.168.8.188:1234/v1", spec=spec,
)
await provider._ensure_client()
pool = provider._client._client._transport._pool
assert pool._keepalive_expiry == 0
def test_cloud_keeps_default_keepalive(self):
async def test_cloud_keeps_default_keepalive(self):
spec = _make_spec(is_local=False)
spec.env_key = ""
spec.default_api_base = "https://api.openai.com/v1"
provider = OpenAICompatProvider(
api_key="test", api_base=None, spec=spec,
)
await provider._ensure_client()
pool = provider._client._client._transport._pool
# Default httpx keepalive is 5.0s
assert pool._keepalive_expiry == 5.0
+97
View File
@@ -0,0 +1,97 @@
"""Tests for the Novita AI provider registration."""
from unittest.mock import patch
from nanobot.config.schema import Config, ProvidersConfig
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import PROVIDERS, find_by_name
def test_novita_config_field_exists() -> None:
config = ProvidersConfig()
assert hasattr(config, "novita")
def test_novita_provider_in_registry() -> None:
specs = {spec.name: spec for spec in PROVIDERS}
assert "novita" in specs
novita = specs["novita"]
assert novita.backend == "openai_compat"
assert novita.env_key == "NOVITA_API_KEY"
assert novita.display_name == "Novita AI"
assert novita.is_gateway is True
assert novita.detect_by_base_keyword == "novita"
assert novita.default_api_base == "https://api.novita.ai/openai"
assert novita.strip_model_prefix is False
def test_find_by_name_novita() -> None:
spec = find_by_name("novita")
assert spec is not None
assert spec.name == "novita"
def test_novita_forced_provider_uses_default_api_base() -> None:
config = Config.model_validate({
"providers": {
"novita": {
"apiKey": "novita-key",
},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
"provider": "novita",
},
},
})
assert config.get_provider_name("deepseek-v4-pro") == "novita"
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
def test_novita_gateway_routes_unprefixed_models_when_configured() -> None:
config = Config.model_validate({
"providers": {
"novita": {
"apiKey": "novita-key",
},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
},
},
})
assert config.get_provider_name("deepseek-v4-pro") == "novita"
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
def test_novita_preserves_model_api_id() -> None:
spec = find_by_name("novita")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="novita-key",
default_model="deepseek-v4-pro",
spec=spec,
)
kwargs = provider._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="deepseek-v4-pro",
max_tokens=1024,
temperature=0.7,
reasoning_effort=None,
tool_choice=None,
)
assert kwargs["model"] == "deepseek-v4-pro"
assert kwargs["max_tokens"] == 1024
assert "max_completion_tokens" not in kwargs
+12 -7
View File
@@ -8,16 +8,18 @@ def _assert_openai_compat_timeout(timeout) -> None:
assert timeout == 120.0
def test_openai_compat_provider_sets_sdk_timeout() -> None:
async def test_openai_compat_provider_defers_sdk_client_until_first_use() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
mock_async_openai.assert_not_called()
await provider._ensure_client()
kwargs = mock_async_openai.call_args.kwargs
_assert_openai_compat_timeout(kwargs["timeout"])
assert kwargs["http_client"] is None
def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
async def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
spec = ProviderSpec(
name="local",
keywords=(),
@@ -29,11 +31,13 @@ def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
with (
patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai,
patch(
"nanobot.providers.openai_compat_provider.httpx.AsyncClient",
"httpx.AsyncClient",
return_value=sentinel.http_client,
) as mock_http_client,
):
OpenAICompatProvider(spec=spec)
provider = OpenAICompatProvider(spec=spec)
mock_async_openai.assert_not_called()
await provider._ensure_client()
client_kwargs = mock_http_client.call_args.kwargs
_assert_openai_compat_timeout(client_kwargs["timeout"])
@@ -44,10 +48,11 @@ def test_openai_compat_provider_sets_timeout_on_local_http_client() -> None:
assert openai_kwargs["http_client"] is sentinel.http_client
def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypatch) -> None:
async def test_openai_compat_provider_timeout_can_be_overridden_by_env(monkeypatch) -> None:
monkeypatch.setenv("NANOBOT_OPENAI_COMPAT_TIMEOUT_S", "45")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_async_openai:
OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
provider = OpenAICompatProvider(api_key="test-key", api_base="https://example.com/v1")
await provider._ensure_client()
assert mock_async_openai.call_args.kwargs["timeout"] == 45.0
+43
View File
@@ -155,6 +155,49 @@ class TestConvertMessages:
assert items[0]["id"] == "fc_1"
assert items[0]["name"] == "get_weather"
def test_duplicate_response_item_ids_are_made_unique(self):
"""Codex rejects replayed Responses input items with duplicate ids."""
_, items = convert_messages([
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_a|rs_same",
"function": {"name": "first", "arguments": "{}"},
}],
},
{"role": "tool", "tool_call_id": "call_a|rs_same", "content": "ok"},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_b|rs_same",
"function": {"name": "second", "arguments": "{}"},
}],
},
{"role": "tool", "tool_call_id": "call_b|rs_same", "content": "ok"},
])
function_call_ids = [
item["id"] for item in items if item.get("type") == "function_call"
]
assert function_call_ids == ["rs_same", "rs_same_2"]
assert len(function_call_ids) == len(set(function_call_ids))
def test_fallback_response_item_ids_are_unique_with_multiple_tool_calls(self):
_, items = convert_messages([{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_a", "function": {"name": "first", "arguments": "{}"}},
{"id": "call_b", "function": {"name": "second", "arguments": "{}"}},
],
}])
function_call_ids = [
item["id"] for item in items if item.get("type") == "function_call"
]
assert function_call_ids == ["fc_0", "fc_0_2"]
assert len(function_call_ids) == len(set(function_call_ids))
def test_assistant_with_tool_calls_no_id(self):
"""Fallback IDs when tool_call.id is missing."""
_, items = convert_messages([{
+2 -2
View File
@@ -242,7 +242,7 @@ async def test_image_fallback_returns_error_on_second_failure() -> None:
@pytest.mark.asyncio
async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
"""When _meta is absent, fallback placeholder is '[image]'."""
"""When _meta is absent, fallback placeholder is '[image omitted]'."""
provider = ScriptedProvider([
LLMResponse(content="error", finish_reason="error"),
LLMResponse(content="ok"),
@@ -256,7 +256,7 @@ async def test_image_fallback_without_meta_uses_default_placeholder() -> None:
for msg in msgs_on_retry:
content = msg.get("content")
if isinstance(content, list):
assert any("[image]" in (b.get("text") or "") for b in content)
assert any("[image omitted]" in (b.get("text") or "") for b in content)
@pytest.mark.asyncio
@@ -5,9 +5,10 @@ from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
def test_openai_compat_disables_sdk_retries_by_default() -> None:
async def test_openai_compat_disables_sdk_retries_by_default() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client:
OpenAICompatProvider(api_key="sk-test", default_model="gpt-4o")
provider = OpenAICompatProvider(api_key="sk-test", default_model="gpt-4o")
await provider._ensure_client()
kwargs = mock_client.call_args.kwargs
assert kwargs["max_retries"] == 0
+2 -2
View File
@@ -24,7 +24,7 @@ def test_skywork_provider_in_registry() -> None:
assert skywork.display_name == "Skywork"
assert skywork.is_gateway is True
assert skywork.detect_by_base_keyword == "apifree.ai"
assert skywork.default_api_base == "https://api.apifree.ai/v1"
assert skywork.default_api_base == "https://api.apifree.ai/agent/v1"
assert skywork.supports_max_completion_tokens is False
@@ -53,7 +53,7 @@ def test_skywork_model_auto_matches_with_default_api_base() -> None:
assert config.get_provider_name("skywork-ai/skyclaw-v1") == "skywork"
assert config.get_api_key("skywork-ai/skyclaw-v1") == "sky-key"
assert config.get_api_base("skywork-ai/skyclaw-v1") == "https://api.apifree.ai/v1"
assert config.get_api_base("skywork-ai/skyclaw-v1") == "https://api.apifree.ai/agent/v1"
def test_skywork_preserves_model_id_and_uses_chat_completion_max_tokens() -> None:
+44 -9
View File
@@ -32,7 +32,7 @@ def _mimo_spec():
def _openrouter_spec():
"""Return the registered OpenRouter ProviderSpec (no thinking_style)."""
"""Return the registered OpenRouter ProviderSpec."""
specs = {s.name: s for s in PROVIDERS}
return specs["openrouter"]
@@ -77,6 +77,13 @@ def test_xiaomi_mimo_uses_thinking_type_style():
assert spec.default_api_base == "https://api.xiaomimimo.com/v1"
def test_openrouter_declares_gateway_reasoning_style():
"""OpenRouter uses its own reasoning.effort field for routed thinking models."""
spec = _openrouter_spec()
assert spec.thinking_style == ""
assert spec.gateway_reasoning_style == "reasoning_effort"
# ---------------------------------------------------------------------------
# _build_kwargs wire-format
# ---------------------------------------------------------------------------
@@ -142,9 +149,11 @@ def test_mimo_reasoning_effort_unset_preserves_provider_default():
def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro"; the openrouter spec
has no thinking_style, so the disable signal must come from the
model-name path (#3845)."""
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro" and does NOT forward
extra_body.thinking to upstream, so a disable signal must also reach OR
in its own `reasoning.effort` shape. Verifies both the upstream-MiMo
payload (#3845) and the OR-native payload (#3851 follow-up) are sent.
"""
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -152,11 +161,15 @@ def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert "reasoning_effort" not in kwargs
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
"""Same as the direct path: any non-none/minimal effort enables thinking."""
"""Non-none/minimal effort enables thinking and the OR `reasoning.effort`
field mirrors the requested effort level."""
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -164,7 +177,10 @@ def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
temperature=0.7, reasoning_effort="medium", tool_choice=None,
)
assert kwargs.get("reasoning_effort") == "medium"
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
assert kwargs["extra_body"] == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
def test_mimo_via_openrouter_bare_slug_also_matches():
@@ -176,12 +192,16 @@ def test_mimo_via_openrouter_bare_slug_also_matches():
tools=None, model=None, max_tokens=100,
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
def test_mimo_flash_via_openrouter_does_not_inject_thinking():
"""mimo-v2-flash has no thinking mode per Xiaomi docs; the allowlist
excludes it, so no thinking field should be injected on the gateway path."""
excludes it, so neither the upstream `thinking` field nor OR's
`reasoning.effort` should be injected on the gateway path."""
provider = _openrouter_provider("xiaomi/mimo-v2-flash")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -200,3 +220,18 @@ def test_non_mimo_model_via_openrouter_unaffected():
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert "extra_body" not in kwargs
def test_kimi_via_openrouter_also_injects_reasoning_effort():
"""Kimi has the same gateway problem as MiMo: OR drops the upstream
`thinking` field. The same OR-reasoning injection should fire."""
provider = _openrouter_provider("moonshotai/kimi-k2.5")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
tools=None, model=None, max_tokens=100,
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
-44
View File
@@ -29,47 +29,3 @@ def test_sanitize_persisted_blocks_truncate_text_shadowing_regression() -> None:
assert isinstance(out[0]["text"], str)
assert out[0]["text"] != content[0]["text"]
def test_sanitize_persisted_blocks_strips_audio_and_video() -> None:
"""Audio and video blocks with base64 payloads must be replaced with placeholders."""
from nanobot.agent.loop import AgentLoop
dummy = SimpleNamespace(max_tool_result_chars=1000)
content = [
{"type": "text", "text": "analyze this"},
{
"type": "input_audio",
"input_audio": {"data": "aGVsbG8=", "format": "wav"},
"_meta": {"path": "/tmp/voice.wav"},
},
{
"type": "video_url",
"video_url": {"url": "data:video/mp4;base64,aGVsbG8="},
"_meta": {"path": "/tmp/clip.mp4"},
},
]
out = AgentLoop._sanitize_persisted_blocks(dummy, content)
assert len(out) == 3
assert out[0] == content[0]
assert out[1] == {"type": "text", "text": "[audio: /tmp/voice.wav]"}
assert out[2] == {"type": "text", "text": "[video: /tmp/clip.mp4]"}
def test_sanitize_persisted_blocks_strips_audio_video_without_meta() -> None:
"""When _meta is absent, fallback placeholders use bare label."""
from nanobot.agent.loop import AgentLoop
dummy = SimpleNamespace(max_tool_result_chars=1000)
content = [
{"type": "input_audio", "input_audio": {"data": "aGVsbG8=", "format": "wav"}},
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,aGVsbG8="}},
]
out = AgentLoop._sanitize_persisted_blocks(dummy, content)
assert len(out) == 2
assert out[0] == {"type": "text", "text": "[audio]"}
assert out[1] == {"type": "text", "text": "[video]"}
+330
View File
@@ -0,0 +1,330 @@
from __future__ import annotations
import asyncio
from nanobot.agent.tools.apply_patch import ApplyPatchTool
def test_apply_patch_edits_replace(tmp_path):
target = tmp_path / "calc.py"
target.write_text("def add(a, b):\n return a + b\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "calc.py",
"action": "replace",
"old_text": " return a + b",
"new_text": " return a - b",
}
]
)
)
assert "update calc.py" in result
assert target.read_text() == "def add(a, b):\n return a - b\n"
def test_apply_patch_edits_add_new_file(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "config.py",
"action": "add",
"new_text": "DEBUG = True",
}
]
)
)
assert "add config.py" in result
assert (tmp_path / "config.py").read_text() == "DEBUG = True\n"
def test_apply_patch_edits_preserves_new_file_trailing_blank_lines(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "notes.txt",
"action": "add",
"new_text": "one\n\n",
}
]
)
)
assert "add notes.txt" in result
assert (tmp_path / "notes.txt").read_text() == "one\n\n"
def test_apply_patch_edits_add_to_existing_file(tmp_path):
target = tmp_path / "log.py"
target.write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "log.py",
"action": "add",
"new_text": "def debug(msg):\n logger.debug(msg)",
}
]
)
)
assert "update log.py" in result
assert (
target.read_text()
== "import logging\n\nlogger = logging.getLogger(__name__)\ndef debug(msg):\n logger.debug(msg)\n"
)
def test_apply_patch_edits_delete(tmp_path):
target = tmp_path / "utils.py"
target.write_text("def unused():\n pass\ndef used():\n return 1\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "utils.py",
"action": "delete",
"old_text": "def unused():\n pass\n",
}
]
)
)
assert "update utils.py" in result
assert target.read_text() == "def used():\n return 1\n"
def test_apply_patch_edits_delete_entire_file(tmp_path):
target = tmp_path / "obsolete.txt"
target.write_text("remove me\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "obsolete.txt",
"action": "delete",
"old_text": "remove me\n",
}
]
)
)
assert "delete obsolete.txt" in result
assert not target.exists()
def test_apply_patch_edits_delete_substring_with_surrounding_whitespace(tmp_path):
target = tmp_path / "keep_whitespace.txt"
target.write_text(" token \n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "keep_whitespace.txt",
"action": "delete",
"old_text": "token",
}
]
)
)
assert "update keep_whitespace.txt" in result
assert target.exists()
assert target.read_text() == " \n"
def test_apply_patch_edits_batch_multiple_files(tmp_path):
a = tmp_path / "a.py"
a.write_text("X = 1\n")
b = tmp_path / "b.py"
b.write_text("from a import X\nprint(X)\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "a.py",
"action": "replace",
"old_text": "X = 1",
"new_text": "Y = 1",
},
{
"path": "b.py",
"action": "replace",
"old_text": "from a import X",
"new_text": "from a import Y",
},
]
)
)
assert "update a.py" in result
assert "update b.py" in result
assert a.read_text() == "Y = 1\n"
assert b.read_text() == "from a import Y\nprint(X)\n"
def test_apply_patch_edits_rejects_ambiguous_old_text(tmp_path):
target = tmp_path / "repeated.txt"
target.write_text("target\nmiddle\ntarget\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "repeated.txt",
"action": "replace",
"old_text": "target",
"new_text": "changed",
}
]
)
)
assert "old_text appears multiple times" in result
assert target.read_text() == "target\nmiddle\ntarget\n"
def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
target = tmp_path / "dry.txt"
target.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "dry.txt",
"action": "replace",
"old_text": "before",
"new_text": "after",
},
{
"path": "added.txt",
"action": "add",
"new_text": "new",
},
],
dry_run=True,
)
)
assert "Patch dry-run succeeded" in result
assert target.read_text() == "before\n"
assert not (tmp_path / "added.txt").exists()
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
absolute = asyncio.run(
tool.execute(
edits=[
{
"path": "/tmp/owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
parent = asyncio.run(
tool.execute(
edits=[
{
"path": "../owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_absolute = asyncio.run(
tool.execute(
edits=[
{
"path": r"C:\owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_parent = asyncio.run(
tool.execute(
edits=[
{
"path": r"..\owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
assert "must be relative" in absolute
assert "must not contain '..'" in parent
assert "must be relative" in windows_absolute
assert "must not contain '..'" in windows_parent
assert not (tmp_path.parent / "owned.txt").exists()
def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
missing_path = asyncio.run(tool.execute(edits=[{"action": "add", "new_text": "x"}]))
missing_action = asyncio.run(tool.execute(edits=[{"path": "x.txt", "new_text": "x"}]))
non_object = asyncio.run(tool.execute(edits=["not an object"])) # type: ignore[list-item]
assert "path required for edit" in missing_path
assert "action required for edit: x.txt" in missing_action
assert "each edit must be an object" in non_object
def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
first = tmp_path / "first.txt"
first.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "first.txt",
"action": "replace",
"old_text": "before",
"new_text": "after",
},
{
"path": "missing.txt",
"action": "delete",
"old_text": "remove me",
},
]
)
)
assert "file to update does not exist: missing.txt" in result
assert first.read_text() == "before\n"
+12 -7
View File
@@ -1,5 +1,5 @@
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
.ipynb detection, and create-file semantics."""
notebook JSON editing, and create-file semantics."""
import pytest
@@ -108,22 +108,27 @@ class TestEditCreateFile:
# ---------------------------------------------------------------------------
# .ipynb detection
# .ipynb editing
# ---------------------------------------------------------------------------
class TestEditIpynbDetection:
"""edit_file should refuse .ipynb and suggest notebook_edit."""
class TestEditIpynbFiles:
"""edit_file edits notebooks as normal JSON files."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
async def test_ipynb_can_be_edited_as_json(self, tool, tmp_path):
f = tmp_path / "analysis.ipynb"
f.write_text('{"cells": []}', encoding="utf-8")
result = await tool.execute(path=str(f), old_text="x", new_text="y")
assert "notebook" in result.lower()
result = await tool.execute(
path=str(f),
old_text='"cells": []',
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
)
assert "Successfully edited" in result
assert '"source": "hi"' in f.read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
+9 -2
View File
@@ -5,6 +5,7 @@ strategy, and sandbox behaviour per platform — without actually running
platform-specific binaries (all subprocess calls are mocked).
"""
import asyncio
import sys
from unittest.mock import AsyncMock, patch
@@ -108,6 +109,9 @@ class TestSpawnUnix:
assert "-c" in args
assert "echo hi" in args
kwargs = mock_exec.call_args[1]
assert kwargs["stdin"] == asyncio.subprocess.DEVNULL
class TestSpawnWindows:
@@ -124,6 +128,9 @@ class TestSpawnWindows:
args = mock_shell.call_args[0]
assert "dir" in args
kwargs = mock_shell.call_args[1]
assert kwargs["stdin"] == asyncio.subprocess.DEVNULL
@pytest.mark.asyncio
async def test_passes_cwd_and_env(self):
env = {"PATH": "/usr/bin"}
@@ -155,7 +162,7 @@ class TestPathAppendPlatform:
captured_cmd = None
captured_env = {}
async def capture_spawn(cmd, cwd, env):
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
nonlocal captured_cmd
captured_cmd = cmd
captured_env.update(env)
@@ -183,7 +190,7 @@ class TestPathAppendPlatform:
captured_env = {}
async def capture_spawn(cmd, cwd, env):
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
captured_env.update(env)
return mock_proc
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
import asyncio
import re
import shlex
import subprocess
import sys
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.exec_session import ExecSessionManager, ListExecSessionsTool, WriteStdinTool
def _python_command(code: str) -> str:
if sys.platform == "win32":
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
def _session_id(output: str) -> str:
match = re.search(r"session_id:\s*([0-9a-f]+)", output)
assert match, output
return match.group(1)
def test_exec_keeps_one_shot_behavior_without_yield_time_ms(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo hello")
result = asyncio.run(run())
assert "hello" in result
assert "Exit code: 0" in result
assert "session_id:" not in result
def test_exec_accepts_command_aliases(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir="/")
return await tool.execute(
cmd=_python_command("import os; print(os.getcwd())"),
workdir=str(tmp_path),
)
result = asyncio.run(run())
assert str(tmp_path) in result
assert "Exit code: 0" in result
def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
result = await tool.execute(command="echo hello", yield_time_ms=1000)
if "session_id:" in result:
sid = _session_id(result)
result += "\n" + await stdin_tool.execute(
session_id=sid,
chars="",
yield_time_ms=1000,
)
return result
result = asyncio.run(run())
assert "hello" in result
assert "Exit code: 0" in result
assert "session_id:" not in result
def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
command = _python_command("print('A' * 2000)")
return await tool.execute(
command=command,
yield_time_ms=1000,
max_output_tokens=1000,
)
result = asyncio.run(run())
assert "chars truncated" in result
assert "Exit code: 0" in result
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
command = _python_command("print('A' * 2000)")
return await tool.execute(command=command, max_output_tokens=1000)
result = asyncio.run(run())
assert "chars truncated" in result
assert "Exit code: 0" in result
def test_exec_accepts_supported_shell_parameter(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo shell-ok", shell="sh", login=False)
if sys.platform == "win32":
return
result = asyncio.run(run())
assert "shell-ok" in result
assert "Exit code: 0" in result
def test_exec_rejects_unsupported_shell(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo no", shell="python")
if sys.platform == "win32":
return
result = asyncio.run(run())
assert "unsupported shell" in result
def test_exec_can_continue_with_stdin(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import sys; print('ready', flush=True); "
"line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "Process running" in initial
assert "Elapsed:" in initial
assert "got:ping" in result
assert "Exit code: 0" in result
assert "Elapsed:" in result
def test_write_stdin_can_close_stdin(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import sys; print('ready', flush=True); "
"data=sys.stdin.read(); print('got:' + data, flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(
session_id=sid,
chars="payload",
close_stdin=True,
yield_time_ms=1000,
)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "got:payload" in result
assert "Stdin closed." in result
assert "Exit code: 0" in result
def test_write_stdin_can_terminate_session(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); time.sleep(30)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(
session_id=sid,
terminate=True,
yield_time_ms=0,
)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "Session terminated." in result
assert "Exit code:" in result
def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('A' * 2000, flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=0)
sid = _session_id(initial)
poll = await stdin_tool.execute(
session_id=sid,
yield_time_ms=500,
max_output_tokens=1000,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, poll, cleanup
initial, poll, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "chars truncated" in poll
assert "Session terminated." in cleanup
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); "
"time.sleep(1.0); print('done', flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=300)
sid = _session_id(initial)
await asyncio.sleep(1.2)
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
return initial, final
initial, final = asyncio.run(run())
assert "ready" in initial
assert "done" in final
assert "Exit code: 0" in final
def test_write_stdin_can_wait_for_expected_output(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('booting', flush=True); "
"time.sleep(0.4); print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial)
waited = await stdin_tool.execute(
session_id=sid,
wait_for="ready",
wait_timeout_ms=3000,
yield_time_ms=0,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, waited, cleanup
initial, waited, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "booting" in initial + waited
assert "ready" in waited
assert "Wait target not observed" not in waited
assert "Session terminated." in cleanup
def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('booting', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial)
waited = await stdin_tool.execute(
session_id=sid,
wait_for="never-ready",
wait_timeout_ms=200,
yield_time_ms=0,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, waited, cleanup
initial, waited, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "booting" in initial + waited
assert "Process running" in waited
assert "Wait target not observed: 'never-ready'" in waited
assert "Session terminated." in cleanup
def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
manager = ExecSessionManager()
tool = ExecTool(
working_dir=str(tmp_path),
deny_patterns=[r"echo\s+blocked"],
session_manager=manager,
)
result = asyncio.run(tool.execute(command="echo blocked", yield_time_ms=0))
assert "blocked by deny pattern" in result
def test_write_stdin_reports_missing_session(tmp_path):
manager = ExecSessionManager()
tool = WriteStdinTool(manager=manager)
result = asyncio.run(tool.execute(session_id="missing", chars=""))
assert "exec session not found" in result
def test_list_exec_sessions_reports_running_commands(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
list_tool = ListExecSessionsTool(manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
listing = await list_tool.execute()
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return sid, listing, cleanup
sid, listing, cleanup = asyncio.run(run())
assert sid in listing
assert "running" in listing
assert "elapsed=" in listing
assert "remaining=" in listing
assert str(tmp_path) in listing
assert "Session terminated." in cleanup
def test_list_exec_sessions_reports_empty_state():
result = asyncio.run(ListExecSessionsTool(manager=ExecSessionManager()).execute())
assert result == "No active exec sessions."
@@ -0,0 +1,216 @@
from __future__ import annotations
import asyncio
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
def test_read_file_force_bypasses_dedup(tmp_path):
target = tmp_path / "data.txt"
target.write_text("alpha\n")
tool = ReadFileTool(workspace=tmp_path)
first = asyncio.run(tool.execute(path=str(target)))
second = asyncio.run(tool.execute(path=str(target)))
forced = asyncio.run(tool.execute(path=str(target), force=True))
assert "alpha" in first
assert "unchanged" in second.lower()
assert "alpha" in forced
assert "unchanged" not in forced.lower()
def test_edit_file_can_select_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("one\nsame\ntwo\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=2,
))
assert "Successfully edited" in result
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
def test_edit_file_expected_replacements_guards_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
replace_all=True,
expected_replacements=1,
))
assert "expected 1 replacements but would make 2" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_expected_replacements_allows_replace_all_when_count_matches(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
replace_all=True,
expected_replacements=2,
))
assert "Successfully edited" in result
assert target.read_text() == "changed\nchanged\n"
def test_edit_file_can_select_nearest_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("one\nsame\ntwo\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=4,
))
assert "Successfully edited" in result
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
def test_edit_file_can_edit_ipynb_as_json(tmp_path):
target = tmp_path / "analysis.ipynb"
target.write_text('{"cells": []}')
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text='"cells": []',
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
))
assert "Successfully edited" in result
assert '"source": "hi"' in target.read_text()
def test_edit_file_multiple_match_hint_mentions_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
))
assert "old_text appears 2 times" in result
assert "occurrence" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_ambiguous_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nmiddle\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=2,
))
assert "line_hint 2 is ambiguous" in result
assert target.read_text() == "same\nmiddle\nsame\n"
def test_edit_file_rejects_occurrence_with_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=1,
replace_all=True,
))
assert "occurrence cannot be used with replace_all" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_line_hint_with_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=1,
replace_all=True,
))
assert "line_hint cannot be used with replace_all" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_line_hint_with_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=1,
line_hint=1,
))
assert "line_hint cannot be used with occurrence" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_zero_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=0,
))
assert "occurrence must be >= 1" in result
assert target.read_text() == "same\n"
def test_edit_file_rejects_zero_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=0,
))
assert "line_hint must be >= 1" in result
assert target.read_text() == "same\n"
+33
View File
@@ -138,6 +138,39 @@ async def test_generate_image_tool_reports_missing_aihubmix_key(tmp_path: Path)
assert result.startswith("Error: AIHubMix API key is not configured")
@pytest.mark.asyncio
async def test_generate_image_tool_allows_ollama_without_api_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
set_config_path(tmp_path / "config.json")
FakeImageClient.instances = []
monkeypatch.setattr(
"nanobot.agent.tools.image_generation.get_image_gen_provider",
lambda name: FakeImageClient if name == "ollama" else None,
)
tool = ImageGenerationTool(
workspace=tmp_path,
config=ImageGenerationToolConfig(
enabled=True,
provider="ollama",
model="x/z-image-turbo",
),
provider_configs={"ollama": ProviderConfig(api_base="http://localhost:11434/v1")},
)
result = await tool.execute(prompt="draw a cat")
payload = json.loads(result)
assert len(payload["artifacts"]) == 1
fake = FakeImageClient.instances[0]
assert fake.kwargs["api_key"] is None
assert fake.kwargs["api_base"] == "http://localhost:11434/v1"
assert fake.calls[0]["aspect_ratio"] == "1:1"
assert fake.calls[0]["image_size"] == "1K"
@pytest.mark.asyncio
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
-147
View File
@@ -1,147 +0,0 @@
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
import json
import pytest
from nanobot.agent.tools.notebook import NotebookEditTool
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
"""Build a minimal valid .ipynb structure."""
return {
"nbformat": nbformat,
"nbformat_minor": nbformat_minor,
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
"cells": cells or [],
}
def _code_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
if cell_id:
cell["id"] = cell_id
return cell
def _md_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
if cell_id:
cell["id"] = cell_id
return cell
def _write_nb(tmp_path, name: str, nb: dict) -> str:
p = tmp_path / name
p.write_text(json.dumps(nb), encoding="utf-8")
return str(p)
class TestNotebookEdit:
@pytest.fixture()
def tool(self, tmp_path):
return NotebookEditTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replace_cell_content(self, tool, tmp_path):
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][0]["source"] == "print('world')"
assert saved["cells"][1]["source"] == "x = 1"
@pytest.mark.asyncio
async def test_insert_cell_after_target(self, tool, tmp_path):
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 3
assert saved["cells"][0]["source"] == "cell 0"
assert saved["cells"][1]["source"] == "inserted"
assert saved["cells"][2]["source"] == "cell 1"
@pytest.mark.asyncio
async def test_delete_cell(self, tool, tmp_path):
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 2
assert saved["cells"][0]["source"] == "A"
assert saved["cells"][1]["source"] == "C"
@pytest.mark.asyncio
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
path = str(tmp_path / "new.ipynb")
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
assert "Successfully" in result or "created" in result.lower()
saved = json.loads((tmp_path / "new.ipynb").read_text())
assert saved["nbformat"] == 4
assert len(saved["cells"]) == 1
assert saved["cells"][0]["cell_type"] == "markdown"
assert saved["cells"][0]["source"] == "# Hello"
@pytest.mark.asyncio
async def test_invalid_cell_index_error(self, tool, tmp_path):
nb = _make_notebook([_code_cell("only cell")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=5, new_source="x")
assert "Error" in result
@pytest.mark.asyncio
async def test_non_ipynb_rejected(self, tool, tmp_path):
f = tmp_path / "script.py"
f.write_text("pass")
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
assert "Error" in result
assert ".ipynb" in result
@pytest.mark.asyncio
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
cell = _code_cell("old")
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
cell["execution_count"] = 42
nb = _make_notebook([cell])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="new")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["metadata"]["kernelspec"]["language"] == "python"
@pytest.mark.asyncio
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
nb = _make_notebook([], nbformat_minor=5)
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert "id" in saved["cells"][0]
assert len(saved["cells"][0]["id"]) > 0
@pytest.mark.asyncio
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][1]["cell_type"] == "markdown"
@pytest.mark.asyncio
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
assert "Error" in result
assert "edit_mode" in result
@pytest.mark.asyncio
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
assert "Error" in result
assert "cell_type" in result
+65 -1
View File
@@ -12,7 +12,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.search import GrepTool
from nanobot.agent.tools.search import FindFilesTool, GrepTool
from nanobot.agent.tools.web import WebSearchTool
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import WebSearchConfig
@@ -33,6 +33,68 @@ async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> N
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
@pytest.mark.asyncio
async def test_find_files_filters_by_query_glob_and_type(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "settings_view.tsx").write_text("export {}\n", encoding="utf-8")
(tmp_path / "src" / "settings_api.py").write_text("pass\n", encoding="utf-8")
(tmp_path / "README.md").write_text("settings\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(
path=".",
query="settings",
glob="src/**",
type="ts",
)
assert result.splitlines() == ["src/settings_view.tsx"]
@pytest.mark.asyncio
async def test_find_files_can_include_directories(tmp_path: Path) -> None:
(tmp_path / "src" / "settings").mkdir(parents=True)
(tmp_path / "src" / "settings" / "index.ts").write_text("export {}\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(path="src", query="settings", include_dirs=True)
assert "src/settings/" in result.splitlines()
assert "src/settings/index.ts" in result.splitlines()
@pytest.mark.asyncio
async def test_find_files_supports_modified_sort_and_pagination(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
for idx, name in enumerate(("a.py", "b.py", "c.py"), start=1):
file_path = tmp_path / "src" / name
file_path.write_text("pass\n", encoding="utf-8")
os.utime(file_path, (idx, idx))
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(
path="src",
type="py",
sort="modified",
head_limit=1,
offset=1,
)
assert result.splitlines()[0] == "src/b.py"
assert "pagination: limit=1, offset=1" in result
@pytest.mark.asyncio
async def test_find_files_rejects_paths_outside_workspace(tmp_path: Path) -> None:
outside = tmp_path.parent / "outside-find-files.txt"
outside.write_text("secret\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(path=str(outside))
assert result.startswith("Error:")
@pytest.mark.asyncio
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
@@ -249,6 +311,7 @@ def test_agent_loop_registers_grep(tmp_path: Path) -> None:
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
assert "find_files" in loop.tools.tool_names
assert "grep" in loop.tools.tool_names
@@ -280,6 +343,7 @@ async def test_subagent_registers_grep(tmp_path: Path) -> None:
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
assert "find_files" in captured["tool_names"]
assert "grep" in captured["tool_names"]
+46
View File
@@ -0,0 +1,46 @@
from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.exec_session import ListExecSessionsTool, WriteStdinTool
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.search import FindFilesTool, GrepTool
from nanobot.agent.tools.shell import ExecTool
def test_coding_tool_descriptions_steer_editing_priority() -> None:
apply_patch = ApplyPatchTool().description.lower()
edit_file = EditFileTool().description.lower()
write_file = WriteFileTool().description.lower()
assert "default tool for code edits" in apply_patch
assert "multi-file" in apply_patch
assert "dry_run=true" in apply_patch
assert "edit_file only for small exact replacements" in apply_patch
assert "small, exact replacement" in edit_file
assert "copied from read_file" in edit_file
assert "prefer apply_patch" in edit_file
assert "replace an entire file" in write_file
assert "prefer apply_patch" in write_file
def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None:
read_file = ReadFileTool().description.lower()
find_files = FindFilesTool().description.lower()
grep = GrepTool().description.lower()
exec_tool = ExecTool().description.lower()
write_stdin = WriteStdinTool().description.lower()
list_sessions = ListExecSessionsTool().description.lower()
assert "find_files/list_dir first" in read_file
assert "before editing" in read_file
assert "prefer it over shell find/ls" in find_files
assert "prefer this over shell grep" in grep
assert "tests, builds" in exec_tool
assert "prefer read_file/find_files/grep" in exec_tool
assert "apply_patch/write_file/edit_file" in exec_tool
assert "yield_time_ms" in exec_tool
assert "do not use this to start new commands" in write_stdin
assert "wait_for" in write_stdin
assert "recover a session_id" in list_sessions
+6 -1
View File
@@ -89,9 +89,12 @@ def test_discover_finds_concrete_tools():
loader = ToolLoader()
discovered = loader.discover()
class_names = {cls.__name__ for cls in discovered}
assert "ApplyPatchTool" in class_names
assert "ExecTool" in class_names
assert "CliAppsTool" in class_names
assert "MessageTool" in class_names
assert "SpawnTool" in class_names
assert "WriteStdinTool" in class_names
def test_discover_excludes_abstract_and_mcp():
@@ -364,6 +367,7 @@ def test_config_defaults():
assert config.tools.my.enable is True
assert config.tools.my.allow_set is False
assert config.tools.image_generation.enabled is False
assert config.tools.cli_apps.enable is True
assert config.tools.restrict_to_workspace is False
@@ -406,7 +410,8 @@ def test_loader_registers_same_tools_as_old_hardcoded():
expected = {
"read_file", "write_file", "edit_file", "list_dir",
"grep", "notebook_edit", "exec", "web_search", "web_fetch",
"find_files", "grep", "exec", "write_stdin", "list_exec_sessions",
"web_search", "web_fetch",
"message", "spawn", "cron",
}
actual = set(registered)
+36
View File
@@ -3,6 +3,8 @@ import subprocess
import sys
from typing import Any
import pytest
from nanobot.agent.tools import (
ArraySchema,
IntegerSchema,
@@ -15,6 +17,7 @@ from nanobot.agent.tools import (
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.security.network import configure_ssrf_whitelist
class SampleTool(Tool):
@@ -218,6 +221,39 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None:
assert "/bin/python" not in paths
def test_exec_extract_absolute_paths_ignores_urls() -> None:
cmd = 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com'
paths = ExecTool._extract_absolute_paths(cmd)
assert paths == ["/dev/null"]
@pytest.mark.parametrize(
"command",
[
'curl -s -o /dev/null -w "%{http_code}" https://www.google.com',
'wget -q -O - http://example.com 2>&1 | head -c 100',
'python3 -c "import urllib.request; print(urllib.request.urlopen(\'http://example.com\').read()[:100])"',
],
)
def test_exec_guard_allows_public_urls(tmp_path, command: str) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command(command, str(tmp_path))
assert error is None
def test_exec_guard_allows_whitelisted_internal_urls(tmp_path) -> None:
configure_ssrf_whitelist(["10.10.10.0/24"])
try:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command(
'curl -s -H "Authorization: Bearer ..." http://10.10.10.3:8123/api/',
str(tmp_path),
)
assert error is None
finally:
configure_ssrf_whitelist([])
def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
cmd = "cat /tmp/data.txt > /tmp/out.txt"
paths = ExecTool._extract_absolute_paths(cmd)
+124 -12
View File
@@ -6,11 +6,15 @@ import json
import socket
from unittest.mock import patch
import httpx
import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig
_REAL_GETADDRINFO = socket.getaddrinfo
def _fake_resolve_private(hostname, port, family=0, type_=0):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
@@ -54,6 +58,7 @@ async def test_web_fetch_result_contains_untrusted_flag():
url = "https://example.com/page"
text = fake_html
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self): pass
def json(self): return {}
@@ -81,6 +86,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
raise AssertionError("Jina Reader should be skipped when disabled")
class FakeStreamResponse:
status_code = 200
headers = {"content-type": "text/html"}
url = "https://example.com/page"
@@ -90,11 +96,15 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
async def __aexit__(self, exc_type, exc, tb):
return False
async def aread(self):
raise AssertionError("non-image prefetch body should not be read")
class FakeResponse:
status_code = 200
url = "https://example.com/page"
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self):
return None
@@ -109,11 +119,11 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None):
def stream(self, method, url, headers=None, **kwargs):
seen_headers.append(headers or {})
return FakeStreamResponse()
async def get(self, url, headers=None):
async def get(self, url, headers=None, **kwargs):
seen_headers.append(headers or {})
return FakeResponse()
@@ -132,13 +142,14 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
tool = WebFetchTool()
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
requested: list[str] = []
class FakeStreamResponse:
headers = {"content-type": "image/png"}
url = "http://127.0.0.1/secret.png"
content = b"\x89PNG\r\n\x1a\n"
status_code = 200
headers = {"content-type": "text/html"}
url = "https://attacker.example/start"
async def __aenter__(self):
return self
@@ -147,9 +158,14 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
return False
async def aread(self):
return self.content
raise AssertionError("non-image prefetch body should not be read")
def raise_for_status(self):
class FakeRedirectResponse:
status_code = 302
headers = {"location": "http://127.0.0.1:8765/metadata"}
url = "https://attacker.example/start"
async def aclose(self):
return None
class FakeClient:
@@ -162,14 +178,110 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None):
def stream(self, method, url, headers=None, **kwargs):
return FakeStreamResponse()
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
async def get(self, url, headers=None, **kwargs):
requested.append(url)
if url == "http://127.0.0.1:8765/metadata":
raise AssertionError("private redirect target should not be requested")
return FakeRedirectResponse()
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
result = await tool.execute(url="https://attacker.example/start")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
assert requested == ["https://attacker.example/start"]
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == "https://example.com/image.png":
return httpx.Response(
302,
headers={"Location": "http://127.0.0.1/secret.png"},
request=request,
)
if str(request.url) == "http://127.0.0.1/secret.png":
return httpx.Response(
200,
headers={"content-type": "image/png"},
content=b"\x89PNG\r\n\x1a\n",
request=request,
)
return httpx.Response(404, request=request)
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
class TransportAsyncClient(real_async_client):
def __init__(self, *args, **kwargs):
kwargs.pop("proxy", None)
super().__init__(*args, transport=transport, **kwargs)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "example.com":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
result = await tool.execute(url="https://example.com/image.png")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
@pytest.mark.asyncio
async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
requested: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requested.append(str(request.url))
if str(request.url) == "https://attacker.example/start":
return httpx.Response(
302,
headers={"Location": "http://127.0.0.1:8765/metadata"},
request=request,
)
if str(request.url) == "http://127.0.0.1:8765/metadata":
return httpx.Response(200, content=b"internal secret", request=request)
return httpx.Response(404, request=request)
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
class TransportAsyncClient(real_async_client):
def __init__(self, *args, **kwargs):
kwargs["transport"] = transport
super().__init__(*args, **kwargs)
monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
result = await tool.execute(url="https://attacker.example/start")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
assert requested == ["https://attacker.example/start"]
+148 -1
View File
@@ -5,12 +5,13 @@ from pathlib import Path
from types import SimpleNamespace
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
build_file_edit_start_event,
line_diff_stats,
prepare_file_edit_tracker,
prepare_file_edit_trackers,
read_file_snapshot,
StreamingFileEditTracker,
)
@@ -81,6 +82,63 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert (event["added"], event["deleted"]) == (0, 0)
def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
existing = tmp_path / "src" / "existing.py"
existing.write_text("old\nkeep\n", encoding="utf-8")
delete_me = tmp_path / "src" / "delete_me.py"
delete_me.write_text("gone\n", encoding="utf-8")
edits = [
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
]
trackers = prepare_file_edit_trackers(
call_id="call-patch",
tool_name="apply_patch",
tool=None,
workspace=tmp_path,
params={"edits": edits},
)
assert [tracker.display_path for tracker in trackers] == [
"src/new.py",
"src/existing.py",
"src/delete_me.py",
]
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
existing.write_text("new\nkeep\n", encoding="utf-8")
delete_me.unlink()
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
by_path = {event["path"]: event for event in events}
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
(tmp_path / "file.txt").write_text("old\n", encoding="utf-8")
trackers = prepare_file_edit_trackers(
call_id="call-patch",
tool_name="apply_patch",
tool=None,
workspace=tmp_path,
params={
"dry_run": True,
"edits": [
{"path": "file.txt", "action": "replace", "old_text": "old", "new_text": "new"}
],
},
)
assert trackers == []
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
target = tmp_path / "large.txt"
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
@@ -140,6 +198,58 @@ def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) ->
assert events[-1]["deleted"] == 0
def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "existing.py").write_text("old\nkeep\n", encoding="utf-8")
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}'
',{"path":"src/new.py","action":"add","new_text":"fresh"}]}'
),
})
asyncio.run(run())
by_path = {event["path"]: event for event in events}
assert by_path["src/existing.py"]["tool"] == "apply_patch"
assert by_path["src/existing.py"]["status"] == "editing"
assert by_path["src/existing.py"]["approximate"] is True
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}'
),
})
asyncio.run(run())
assert events == []
def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
events: list[dict] = []
@@ -308,6 +418,43 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat
asyncio.run(run())
def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"a.md","content":"one\\n"}',
})
await tracker.update({
"index": 1,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"b.md","content":"two\\n"}',
})
final_a = SimpleNamespace(
id="call_dup",
name="write_file",
arguments={"path": "a.md", "content": "one\n"},
)
final_b = SimpleNamespace(
id="call_unique",
name="write_file",
arguments={"path": "b.md", "content": "two\n"},
)
tracker.apply_final_call_ids([final_a, final_b])
assert final_a.id == "call_dup"
assert final_b.id == "call_unique"
asyncio.run(run())
def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
target = tmp_path / "small.py"
target.write_text("old\n", encoding="utf-8")
+68
View File
@@ -42,6 +42,30 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_augments_assistant_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-img", "text": "draw"},
{"event": "delta", "chat_id": "t-img", "text": "![Diagram](diagram.png)"},
{"event": "stream_end", "chat_id": "t-img"},
],
augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"),
)
assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)"
def test_replay_uses_stream_end_final_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-img", "text": "draw"},
{"event": "stream_end", "chat_id": "t-img", "text": "![Diagram](/api/media/sig/payload)"},
],
)
assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)"
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file"
@@ -143,6 +167,50 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
'exec({"cmd": "ls"})',
'read_file({"path": "notes.md"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "end"
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
},
],
},
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "error",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
"error": "Error: CLI app 'github' not found",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["traces"] == [
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "error"
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
+40 -20
View File
@@ -43,6 +43,7 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings";
@@ -411,6 +412,10 @@ function Shell({
setDesktopSidebarOpen(false);
}, []);
const openDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(true);
}, []);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
}, []);
@@ -560,6 +565,21 @@ function Shell({
setSessionSearchOpen(true);
}, []);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const plainCommandK =
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
if (!plainCommandK) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
onOpenSessionSearch();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onOpenSessionSearch]);
const onSelectSearchResult = useCallback(
(key: string) => {
setSessionSearchOpen(false);
@@ -732,17 +752,19 @@ function Shell({
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
style={{
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
<Sidebar
{...sidebarProps}
collapsed={!desktopSidebarOpen}
onCollapse={closeDesktopSidebar}
onExpand={openDesktopSidebar}
/>
</div>
</aside>
) : null}
@@ -769,17 +791,15 @@ function Shell({
</Sheet>
) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
sessions={sessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
@@ -797,7 +817,7 @@ function Shell({
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
hideSidebarToggleOnDesktop
/>
</div>
{view === "settings" && (
+112 -18
View File
@@ -1,3 +1,9 @@
import {
memo,
useEffect,
useMemo,
useState,
} from "react";
import {
Archive,
ArchiveRestore,
@@ -19,6 +25,9 @@ import { deriveTitle, relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
@@ -42,7 +51,7 @@ interface ChatListProps {
emptyLabel?: string;
}
export function ChatList({
export const ChatList = memo(function ChatList({
sessions,
activeKey,
onSelect,
@@ -65,6 +74,52 @@ export function ChatList({
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
() => groupSessions(sessions, labels, {
pinnedKeys,
archivedKeys,
titleOverrides,
showArchived,
sort,
}),
[
archivedKeys,
labels,
pinnedKeys,
sessions,
showArchived,
sort,
titleOverrides,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey),
[activeKey, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce((total, group) => total + group.sessions.length, 0),
[groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -81,21 +136,6 @@ export function ChatList({
);
}
const groups = groupSessions(sessions, {
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
fallbackTitle: t("chat.newChat"),
}, {
pinnedKeys,
archivedKeys,
titleOverrides,
showArchived,
sort,
});
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
@@ -105,7 +145,7 @@ export function ChatList({
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{groups.map((group) => (
{limitedGroups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
@@ -228,10 +268,25 @@ export function ChatList({
</ul>
</section>
))}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
type="button"
onClick={() =>
setVisibleLimit((limit) =>
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
</div>
) : null}
</div>
</div>
);
}
});
function SessionActivityIndicator({
state,
@@ -366,6 +421,45 @@ function groupSessions(
return groups;
}
function limitGroups(
groups: Array<{ label: string; sessions: ChatSummary[] }>,
limit: number,
activeKey: string | null,
): Array<{ label: string; sessions: ChatSummary[] }> {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
for (const group of groups) {
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ label: group.label, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.label === group.label);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ label: group.label, sessions: [active] });
}
return out;
}
return out;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import type { CliAppInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
): CliAppMentionSegment[] {
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
const appsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
if (appsByName.size === 0) return [{ kind: "text", text: value }];
const segments: CliAppMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const app = appsByName.get(name.toLowerCase());
if (!app) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
}: {
text: string;
cliApps: CliAppInfo[];
}) {
const segments = splitCliAppMentionSegments(text, cliApps);
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function CliAppMentionToken({
app,
label,
variant,
isHero = false,
}: {
app: CliAppInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const showLogo = Boolean(app.logo_url) && !failed;
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={app.logo_url ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
@@ -109,6 +109,46 @@ export default function MarkdownTextRenderer({
</a>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node;
const source = typeof src === "string" ? src : "";
if (!source) return null;
const label = typeof alt === "string" ? alt : "";
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<a
href={source}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={label ? `Open ${label}` : "Open image"}
>
<img
src={source}
alt={label}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
imgClassName,
)}
{...props}
/>
</a>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
);
},
}),
[highlightCode],
);
+40 -2
View File
@@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -8,16 +9,18 @@ import {
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
}
/**
@@ -32,11 +35,16 @@ interface MessageBubbleProps {
export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
useEffect(() => {
return () => {
@@ -88,7 +96,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
{message.content}
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
</p>
) : null}
</div>
@@ -158,6 +166,36 @@ export function MessageBubble({
);
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
): CliAppInfo[] {
if (!attachments?.length) return cliApps;
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "cli",
description: existing?.description || "",
requires: existing?.requires || "",
source: existing?.source || "attached",
entry_point: attachment.entry_point || existing?.entry_point || "",
install_supported: existing?.install_supported ?? true,
installed: true,
available: existing?.available ?? true,
status: existing?.status || "installed",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
skill_installed: existing?.skill_installed ?? true,
});
}
return Array.from(byName.values());
}
function MessageMedia({
media,
align,
+86 -66
View File
@@ -37,13 +37,16 @@ export function SessionSearchDialog({
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const results = useMemo(() => {
const sessionResults = useMemo(() => {
if (!open) return [];
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, sessions, titleOverrides]);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
useEffect(() => {
if (!open) return;
@@ -58,9 +61,9 @@ export function SessionSearchDialog({
useEffect(() => {
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index, results.length - 1),
itemCount === 0 ? 0 : Math.min(index, itemCount - 1),
);
}, [results.length]);
}, [itemCount]);
const handleSelect = (key: string) => {
onOpenChange(false);
@@ -71,17 +74,19 @@ export function SessionSearchDialog({
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
itemCount === 0 ? 0 : (index + 1) % itemCount,
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) => Math.max(index - 1, 0));
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
);
return;
}
if (event.key === "Enter") {
const highlighted = results[highlightedIndex];
const highlighted = sessionResults[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
@@ -125,70 +130,75 @@ export function SessionSearchDialog({
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
/>
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{shortcutLabel}
</kbd>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
<section>
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
{loading && sessions.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : results.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{results.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
const preview = session.preview.trim();
const showPreview =
preview.length > 0 &&
preview.toLowerCase() !== title.trim().toLowerCase();
const highlighted = index === highlightedIndex;
const active = session.key === activeKey;
return (
<li key={session.key}>
<button
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
highlighted
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium leading-5">
{title}
</span>
{showPreview ? (
<span
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
>
{preview}
{loading && sessions.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : sessionResults.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{sessionResults.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
const preview = session.preview.trim();
const showPreview =
preview.length > 0 &&
preview.toLowerCase() !== title.trim().toLowerCase();
const highlighted = index === highlightedIndex;
const active = session.key === activeKey;
return (
<li key={session.key}>
<button
type="button"
onClick={() => handleSelect(session.key)}
onMouseEnter={() => setHighlightedIndex(index)}
aria-current={active ? "page" : undefined}
className={cn(
"flex min-h-12 w-full min-w-0 rounded-xl px-3 py-2.5 text-left transition-colors",
highlighted
? "bg-accent text-accent-foreground"
: "text-popover-foreground hover:bg-accent/75 hover:text-accent-foreground",
)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[14px] font-medium leading-5">
{title}
</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
)}
{showPreview ? (
<span
className={cn(
"block truncate text-[12px] leading-4",
highlighted
? "text-accent-foreground/70"
: "text-muted-foreground",
)}
>
{preview}
</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
)}
</section>
</div>
</DialogContent>
</Dialog>
@@ -211,3 +221,13 @@ function sessionMatchesTerms(
return terms.every((term) => haystack.includes(term));
}
function getSearchShortcutLabel() {
if (typeof navigator === "undefined") return "Ctrl K";
const platform = navigator.platform.toLowerCase();
const apple =
platform.includes("mac") ||
platform.includes("iphone") ||
platform.includes("ipad");
return apple ? "⌘K" : "Ctrl K";
}
+181 -78
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import {
Archive,
ListFilter,
@@ -28,6 +28,7 @@ import type {
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
interface SidebarProps {
sessions: ChatSummary[];
@@ -44,7 +45,9 @@ interface SidebarProps {
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
@@ -59,6 +62,8 @@ export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null);
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
return (
<nav
@@ -66,108 +71,189 @@ export function Sidebar(props: SidebarProps) {
aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<div
className={cn(
"flex items-center px-3 pb-2.5 pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
<button
type="button"
aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true}
title={collapsed ? toggleLabel : undefined}
onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5",
)}
>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-6 w-auto select-none object-contain opacity-95"
src="/brand/nanobot_icon.png"
alt=""
className="h-8 w-8 select-none object-contain"
draggable={false}
/>
</picture>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Menu className="h-3.5 w-3.5" />
</Button>
</button>
{!collapsed && (
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Menu className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div className="space-y-1.5 px-2 pb-2">
<Button
<div
className={cn(
"space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
<Button
type="button"
icon={<SquarePen className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.searchAria")}
onClick={props.onOpenSearch}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Search className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.searchAria")}
</Button>
icon={<Search className="h-4 w-4" />}
/>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<Button
type="button"
<SidebarActionButton
collapsed={collapsed}
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
onClick={props.onToggleArchived}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Archive className="h-3.5 w-3.5" aria-hidden />
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
</Button>
icon={<Archive className="h-4 w-4" />}
/>
) : null}
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
collapsed && "pointer-events-none opacity-0",
)}
>
{!collapsed && (
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
onToggleArchive={props.onToggleArchive}
pinnedKeys={props.pinnedKeys}
archivedKeys={props.archivedKeys}
titleOverrides={props.titleOverrides}
runningChatIds={props.runningChatIds}
completedChatIds={props.completedChatIds}
density={props.viewState?.density}
showPreviews={props.viewState?.show_previews}
showTimestamps={props.viewState?.show_timestamps}
sort={props.viewState?.sort}
showArchived={props.showArchived}
actionMenuPortalContainer={
props.containActionMenus ? menuPortalContainer : undefined
}
/>
)}
</div>
<Separator className="bg-sidebar-border/50" />
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
<Button
type="button"
variant="ghost"
<div
className={cn(
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
collapsed && "w-14 flex-col px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.settings")}
onClick={props.onOpenSettings}
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.settings")}
</Button>
className={collapsed ? undefined : "flex-1"}
icon={<Settings className="h-4 w-4" />}
/>
<ConnectionBadge />
</div>
</nav>
);
}
function SidebarActionButton({
collapsed,
label,
icon,
onClick,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
className?: string;
}) {
return (
<Button
type="button"
variant="ghost"
aria-label={label}
title={collapsed ? label : undefined}
onClick={onClick}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
className,
)}
>
<span
className={cn(
"flex shrink-0 items-center justify-center transition-transform duration-300 ease-out",
collapsed ? "translate-x-0" : "translate-x-0",
)}
aria-hidden
>
{icon}
</span>
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
collapsed
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{label}
</span>
</Button>
);
}
function SidebarViewMenu({
compact = false,
view,
onUpdateView,
}: {
compact?: boolean;
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
@@ -182,11 +268,28 @@ function SidebarViewMenu({
<DropdownMenuTrigger asChild>
<Button
type="button"
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
variant="ghost"
>
<ListFilter className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.viewOptions")}
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{t("sidebar.viewOptions")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
+587 -2
View File
@@ -32,6 +32,8 @@ import {
Loader2,
LogOut,
Moon,
Package,
PlayCircle,
Orbit,
Palette,
Pencil,
@@ -41,6 +43,7 @@ import {
ShieldCheck,
SlidersHorizontal,
Sparkles,
Trash2,
Triangle,
Waves,
Zap,
@@ -59,14 +62,19 @@ import {
import { Input } from "@/components/ui/input";
import {
fetchSettings,
fetchCliApps,
runCliAppAction,
updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
SettingsPayload,
WebSearchSettingsUpdate,
@@ -79,6 +87,7 @@ type SettingsSectionKey =
| "providers"
| "image"
| "web"
| "cliApps"
| "runtime"
| "advanced";
@@ -89,6 +98,7 @@ interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
@@ -110,6 +120,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
};
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
@@ -146,6 +157,7 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
};
} catch {
return DEFAULT_LOCAL_PREFS;
@@ -177,8 +189,11 @@ export function SettingsView({
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
@@ -186,6 +201,12 @@ export function SettingsView({
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState("");
const [cliAppsQuery, setCliAppsQuery] = useState("");
const [cliAppsCategory, setCliAppsCategory] = useState("all");
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
@@ -285,6 +306,27 @@ export function SettingsView({
};
}, [applyPayload, token]);
useEffect(() => {
let cancelled = false;
setCliAppsLoading(true);
fetchCliApps(token)
.then((payload) => {
if (!cancelled) {
setCliApps(payload);
setCliAppsError(null);
}
})
.catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message);
})
.finally(() => {
if (!cancelled) setCliAppsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
@@ -574,6 +616,29 @@ export function SettingsView({
});
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
}
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
@@ -618,6 +683,7 @@ export function SettingsView({
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
@@ -677,6 +743,26 @@ export function SettingsView({
requiresRestartPending={pendingRestartSections.web}
/>
);
case "cliApps":
return (
<CliAppsSettings
payload={cliApps}
loading={cliAppsLoading}
query={cliAppsQuery}
category={cliAppsCategory}
installFilter={cliAppsInstallFilter}
actionKey={cliAppsAction}
message={cliAppsMessage}
error={cliAppsError}
focusName={cliAppsFocusName}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setCliAppsQuery}
onCategoryChange={setCliAppsCategory}
onInstallFilterChange={setCliAppsInstallFilter}
onAction={handleCliAppAction}
onBackToChat={onBackToChat}
/>
);
case "runtime":
return (
<RuntimeSettings
@@ -752,6 +838,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
{ key: "providers", icon: KeyRound, fallback: "Providers" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "web", icon: Globe2, fallback: "Web" },
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
{ key: "runtime", icon: Server, fallback: "Runtime" },
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
];
@@ -1077,6 +1164,16 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
@@ -1211,6 +1308,7 @@ function ProvidersSettings({
editingProviderKeys,
providerSaving,
query,
showBrandLogos,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
@@ -1229,6 +1327,7 @@ function ProvidersSettings({
editingProviderKeys: Record<string, boolean>;
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
@@ -1272,7 +1371,10 @@ function ProvidersSettings({
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon provider={provider.name} />
<ProviderIcon
provider={provider.name}
showBrandLogos={showBrandLogos}
/>
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
@@ -1437,6 +1539,7 @@ function ProvidersSettings({
>
{filteredUnconfigured.map(renderProviderRow)}
</ProviderSection>
<ThirdPartyBrandNotice />
</div>
);
}
@@ -1831,6 +1934,383 @@ function WebSettings({
);
}
function CliAppsSettings({
payload,
loading,
query,
category,
installFilter,
actionKey,
message,
error,
focusName,
showBrandLogos,
onQueryChange,
onCategoryChange,
onInstallFilterChange,
onAction,
onBackToChat,
}: {
payload: CliAppsPayload | null;
loading: boolean;
query: string;
category: string;
installFilter: "all" | "installed" | "notInstalled";
actionKey: string | null;
message: string | null;
error: string | null;
focusName: string | null;
showBrandLogos: boolean;
onQueryChange: (value: string) => void;
onCategoryChange: (value: string) => void;
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const apps = payload?.apps ?? [];
const categories = useMemo(
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
[apps],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
const categoryMatch = category === "all" || app.category === category;
if (!categoryMatch) return false;
if (installFilter === "installed" && !app.installed) return false;
if (installFilter === "notInstalled" && app.installed) return false;
if (!normalizedQuery) return true;
return (
app.display_name.toLowerCase().includes(normalizedQuery) ||
app.name.toLowerCase().includes(normalizedQuery) ||
app.description.toLowerCase().includes(normalizedQuery) ||
app.category.toLowerCase().includes(normalizedQuery)
);
});
const categoryLabel =
category === "all"
? tx("settings.cliApps.allCategories", "All categories")
: category;
const installFilterOptions = [
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
];
const focusedApp = focusName
? apps.find((app) => app.name === focusName && app.installed)
: null;
const visibleStatusMessage = error || (!focusedApp ? message : null);
return (
<div className="space-y-5">
<section className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
<p className="mt-1 text-[13px] text-muted-foreground">
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
.replace("{{installed}}", String(payload?.installed_count ?? 0))
.replace("{{total}}", String(apps.length))}
</p>
</div>
<SegmentedControl
value={installFilter}
options={installFilterOptions}
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
<span className="max-w-[180px] truncate">{categoryLabel}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
{categories.map((item) => (
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</section>
{visibleStatusMessage ? (
<div
className={cn(
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{visibleStatusMessage}
</div>
) : null}
{focusedApp ? (
<CliAppReadyPanel
app={focusedApp}
showBrandLogos={showBrandLogos}
onBackToChat={onBackToChat}
/>
) : null}
{loading ? (
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
</div>
) : (
<section>
<div className="grid gap-2">
{filteredApps.map((app) => (
<CliAppCard
key={app.name}
app={app}
actionKey={actionKey}
showBrandLogos={showBrandLogos}
onAction={onAction}
/>
))}
</div>
{!filteredApps.length ? (
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
</div>
) : null}
</section>
)}
<ThirdPartyBrandNotice />
</div>
);
}
function CliAppReadyPanel({
app,
showBrandLogos,
onBackToChat,
}: {
app: CliAppInfo;
showBrandLogos: boolean;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const prompt = t("settings.cliApps.readyPrompt", {
name: app.name,
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
});
const copyPrompt = () => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
});
};
return (
<section
className={cn(
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
</span>
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="font-mono">@{app.name}</span>
<span aria-hidden>·</span>
<span className="truncate font-mono">{app.entry_point || app.name}</span>
<span aria-hidden>·</span>
<span>{app.category}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={copyPrompt}
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
>
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{copied
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
</Button>
<Button
type="button"
size="sm"
onClick={onBackToChat}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
</section>
);
}
function CliAppCard({
app,
actionKey,
showBrandLogos,
onAction,
}: {
app: CliAppInfo;
actionKey: string | null;
showBrandLogos: boolean;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const installBusy = actionKey === `install:${app.name}`;
const updateBusy = actionKey === `update:${app.name}`;
const uninstallBusy = actionKey === `uninstall:${app.name}`;
const testBusy = actionKey === `test:${app.name}`;
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
return (
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
{app.category}
</span>
</div>
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
{app.entry_point || app.name}
</div>
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
{app.requires
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
</p>
</div>
<div className="shrink-0">
{app.installed ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.cliApps.statusInstalled", "CLI installed")}
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : app.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => onAction("install", app.name)}
className="h-8 rounded-full px-4 text-[12px] font-semibold"
>
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{tx("settings.cliApps.install", "Install CLI")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{tx("settings.cliApps.unavailable", "Unavailable")}
</Button>
)}
</div>
</article>
);
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
const [failed, setFailed] = useState(false);
const bg = app.brand_color || "hsl(var(--muted))";
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
if (showBrandLogos && app.logo_url && !failed) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
>
<img
src={app.logo_url}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
function RuntimeSettings({
form,
setForm,
@@ -2079,6 +2559,18 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
@@ -2126,6 +2618,63 @@ function providerLabel(
return providers.find((provider) => provider.name === value)?.label ?? value;
}
interface ProviderBrand {
logoUrl: string;
color: string;
initials: string;
}
function faviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
}
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
byteplus_coding_plan: "byteplus",
minimax_anthropic: "minimax",
openai_codex: "openai",
volcengine_coding_plan: "volcengine",
};
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
};
function providerBrand(provider: string): ProviderBrand | null {
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
return PROVIDER_BRANDS[key] ?? null;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@@ -2160,8 +2709,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {
function ProviderIcon({
provider,
showBrandLogos,
}: {
provider: string;
showBrandLogos: boolean;
}) {
const [failed, setFailed] = useState(false);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
if (showBrandLogos && brand?.logoUrl && !failed) {
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
>
<img
src={brand.logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />

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