Commit Graph
100 Commits
Author SHA1 Message Date
chengyongruandXubin Ren 2d64aa7dd8 docs(pairing): consolidate access control docs — MECE allowFrom + pairing 2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 8aff3d6151 docs(pairing): add user-friendly pairing documentation 2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren cab4bdbf33 simplify(pairing): unify allow_list lookup in BaseChannel.is_allowed()
Merge the three-branch dict lookup (allow_from key check, allowFrom
fallback, getattr) into a single `or` chain. Same semantics, less
branching.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren ada11b38c4 simplify(pairing): deduplicate Slack pairing code — delegate to BaseChannel
Slack hand-rolled the same generate_code + format_pairing_reply + send
sequence already in BaseChannel._handle_message. Replace with
delegation to _handle_message(is_dm=True), matching Feishu's pattern.
Removes 3 unused imports (generate_code, format_pairing_reply,
PAIRING_CODE_META_KEY) from slack.py.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 22a0df0c53 simplify(pairing): address review findings — constants, TOCTOU, nesting
- Remove TOCTOU exists() check in _load(); rely on FileNotFoundError
- Define PAIRING_CODE_META_KEY and PAIRING_COMMAND_META_KEY constants
  in nanobot.pairing, replacing magic strings across base.py, slack.py,
  and builtin.py
- Flatten nested revoke logic in handle_pairing_command()
- Trim redundant docstring/comment noise in is_allowed() and generate_code()
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren b9522e0a4d refactor(pairing): remove redundant CLI commands
CLI pairing commands (list/approve/deny/revoke) are fully replaceable by
`nanobot agent -m "/pairing ..."`, which routes through the same
CommandRouter and handle_pairing_command() backend. Removing them
cuts 86 lines of duplicate surface area without losing any functionality.

- Remove pairing_app and its 4 subcommands from cli/commands.py
- Update format_pairing_reply() to drop the "Via CLI" line
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 88ff64be48 feat(pairing): allow omitted allowFrom — pairing-only mode by default
Previously _validate_allow_from raised SystemExit when allowFrom was
missing, forcing every channel to declare an explicit allowlist.
With the pairing feature this is no longer necessary: a channel with
no allowFrom simply operates in pairing-only mode, letting users
approve senders via /pairing approve <code> from the WebUI or CLI.

- Replace SystemExit with an info log in _validate_allow_from
- Add test_validate_allow_from_allows_missing_allow_from
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 199a1bb8fa docs(pairing): address reviewer comments — comments, error msg, __all__ test
- Clarify SystemExit message for missing/null allowFrom (manager.py)
- Document why Feishu passes content="" for unauthorized DMs
- Document exact-match semantics in BaseChannel.is_allowed()
- Document negligible collision probability in generate_code()
- Add test_all_exports_are_importable for nanobot.pairing.__all__
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren ac9a2d0c25 test(pairing): cover _PENDING_USER_TURN_KEY cleanup and None allow_from
- Assert pending_user_turn is cleared from session metadata after
  shortcut commands (e.g. /help) in test_auto_compact.py.
- Add test for None allow_from / allowFrom values in
  test_base_channel.py to prevent TypeError regressions.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren eab35af9f3 fix(review): apply PR #3774 review fixes
- Clear pending_user_turn after shortcut command persistence
- Guard is_allowed against None allow_from values
- Update pairing help text for two-arg revoke
- Reuse format_expiry in CLI pairing list
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren b68e9fa21e fix(pairing): persist shortcut commands and avoid Feishu side effects
- AgentLoop._state_command now persists user message and assistant
  response for shortcut commands (e.g. /pairing) so WebUI history
  hydration after _turn_end no longer shows an empty chat.  /new is
  excluded because it intentionally clears the session.

- Feishu _on_message sends pairing codes for unauthorized DMs before
  any media side effects (reactions, downloads, transcription).
  Group chat unauthorized senders are still silently ignored early.

- Update test_feishu_reply to assert the new DM pairing behavior.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 589792f41e feat(pairing): friendlier pairing reply with slash command hint
Update format_pairing_reply() to be more conversational and explicitly
mention both ways an owner can approve:
- In-chat: /pairing approve <code>
- CLI: nanobot pairing approve <code>
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f9d404618b refactor(pairing): move /pairing from BaseChannel to CommandRouter
/pairing is now a first-class built-in command dispatched through
CommandRouter, just like /status, /model, /dream, etc.

Benefits:
- WebUI automatically shows /pairing in the slash command palette
  (because builtin_command_palette() feeds /api/commands).
- All channels (Telegram, Discord, WebSocket, etc.) use the same
  dispatch path for /pairing; no more channel-level interception.
- The command still only works for already-authorised users because
  is_allowed() gates message ingestion before the bus.

Changes:
- Add handle_pairing_command() to nanobot.pairing.store — pure
  function callable from CLI, CommandRouter, and tests.
- Add cmd_pairing to nanobot.command.builtin and register in
  BUILTIN_COMMAND_SPECS + register_builtin_commands().
- Remove BaseChannel._handle_pairing_command() and the /pairing
  interception logic from _handle_message().
- Clean up unused pairing imports from base.py.
- Add unit tests for handle_pairing_command and cmd_pairing dispatch.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f3cae85bb1 fix(feishu): propagate is_dm and remove early is_allowed check
Feishu was doing its own is_allowed check before _handle_message
without considering is_dm, so unrecognised p2p senders were silently
ignored instead of receiving a pairing code.

- Remove the early self.is_allowed() return so BaseChannel can handle
permission checks and pairing uniformly.
- Pass is_dm=chat_type == "p2p" to _handle_message so DM pairing
works for Feishu/Lark private chats.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f47b8f0819 fix(websocket): do not trigger pairing on authenticated WS connections
WebSocket already authenticates clients at handshake time via token
or issued-token validation. Setting is_dm=True caused unrecognised
clients to receive a pairing code after they had already passed
token auth, which is nonsensical for a browser-tab client.

Treat WebSocket as non-DM so pairing is never offered; access control
remains at the WS handshake level (allow_from + token gate).
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 9bc86ee825 refactor(pairing): apply simplify review fixes
- Extract format_pairing_reply() and format_expiry() to eliminate
duplication between BaseChannel and SlackChannel.
- Use _write_text_atomic() from helpers.py instead of hand-rolled
fsync logic in pairing store.
- Convert approved lists to in-memory sets for O(1) lookup.
- Remove collision retry loop (8-char entropy is sufficient).
- Fix /pairing command parsing to split prefix exactly.
- Remove unused import time from base.py.
- Fix tests to pass subcommand_text, not full /pairing string.
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren f8e7e50759 code-review fixes: fsync, entropy, is_dm propagation, tests
- Add os.fsync with Windows-compatible directory flush in pairing store
- Increase pairing code length from 6 -> 8 characters for higher entropy
- Remove SystemExit on empty allowFrom; empty list now defers to pairing
- Update is_allowed docstring to document pairing fallback semantics
- Propagate is_dm to Matrix (direct rooms) and Slack (im channels)
- Slack _is_allowed now checks pairing store for DM allowlist mode
- Fix /pairing revoke to accept optional channel argument
- Move inline import time to module top-level
- Add WebSocket comment explaining is_dm=True assumption
- Add comprehensive tests for store and BaseChannel pairing integration
- Fix existing tests that expected empty allowFrom to hard-exit

Refs #3774
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 4c4a9ae590 feat(pairing): chat-native DM sender approval
Replace the file-editing onboarding workflow with a chat-native pairing flow:

- New pairing store (nanobot/pairing/store.py) persists approved senders
  and pending codes in ~/.nanobot/pairing.json.
- DM messages from unknown senders receive a short pairing code instead of
  silent denial. Group chats remain silently ignored.
- Existing allowFrom semantics are fully preserved; approved pairing users
  are merged at runtime so no config migration is needed.
- nanobot pairing list/approve/deny/revoke CLI commands for bootstrap and
  emergency management.
- /pairing slash commands intercepted in-channel so owners can approve
  senders without leaving the chat.
- is_dm flag added to BaseChannel._handle_message; Telegram, Discord and
  WebSocket updated to pass it.

Closes #3768
2026-05-15 15:46:44 +08:00
chengyongruandXubin Ren 26665823e3 fix(agent): persist shortcut commands without polluting LLM context
Shortcut commands (e.g. /help, /pairing) skip BUILD and SAVE states,
so their turns were never persisted to the session.  This caused WebUI
chats to appear empty after _turn_end because history hydration reads
from the session file.

Fix by persisting the user message and assistant response inside
_state_command, but tag them with _command=True so Session.get_history
filters them out of LLM context.  /new is excluded because it
intentionally clears the session.

- AgentLoop._persist_user_message_early now accepts **kwargs so
  _state_command can pass _command=True for the user turn.
- Session.get_history skips messages with _command=True.
2026-05-14 23:51:58 +08:00
chengyongruandXubin Ren 8b724d510e fix(feishu): register no-op handlers for bot member events
Register handlers for im.chat.member.bot.added_v1 and
im.chat.member.bot.deleted_v1 to silence "processor not found"
errors that appear when any bot is added to or removed from a group.

Closes #3772
2026-05-14 23:10:16 +08:00
chengyongru 913b0774d8 feat(runner): add model failover with fallback_models
When the primary model returns a non-transient error and no content
has been streamed yet, the runner now tries each model listed in the
active preset's fallback_models in order.  Each fallback model may
reside on a different provider — a temporary provider instance is
created on-the-fly via make_provider(config, model=...).

Key design:
- Failover is request-scoped (does not affect subagents/dream/consolidator)
- Provider is restored via try/finally after each fallback attempt
- Skipped when content was already streamed to avoid duplicate output
- Recursive failover prevented by clearing fallback_models on fallback spec
- Circuit breaker trips open after 3 consecutive primary failures (60s cooldown)
- Cross-provider routing: fallback model prefix (e.g. groq/) determines provider

Fixes: cross-provider fallback was broken because the factory passed the
original preset (with provider forced to primary's provider) when creating
fallback providers.  Now uses provider="auto" so the model string prefix
correctly routes to the right provider.

Also fixes: log messages now distinguish between primary-failed,
previous-fallback-failed, and circuit-open scenarios.

closes: https://github.com/HKUDS/nanobot/issues/3376
2026-05-13 17:30:49 +08:00
chengyongruandXubin Ren 99cc6ee808 test(agent): expand coverage and refactor test structure
- Add 42 tests for ContextBuilder (context.py: 0→42 tests)
- Add 37 tests for SubagentManager lifecycle (subagent.py: 2→37 tests)
- Add 42 unit tests for AutoCompact in isolation
- Split monolithic test_runner.py (3313 lines) into 9 focused files:
  test_runner_core, test_runner_hooks, test_runner_errors,
  test_runner_safety, test_runner_persistence, test_runner_governance,
  test_runner_tool_execution, test_runner_injections,
  test_loop_runner_integration
- Add 3 config passthrough tests (temperature/max_tokens/reasoning_effort)
- Fix fragile patch.object(__init__) in test_stop_preserves_context
- Create shared conftest.py with make_provider/make_loop factories

Total: 934 tests passing, 0 regressions
2026-05-13 12:49:17 +08:00
chengyongruandXubin Ren 9e15925cf4 refactor(agent): remove ask_user tool
The ask_user tool used AskUserInterrupt(BaseException) for mid-turn
blocking, creating heavy coupling across runner, loop, and session
management. The model now asks questions naturally in response text,
the turn ends normally, and the user's next message starts a new turn
with session history providing continuity.

Removed:
- nanobot/agent/tools/ask.py (tool, interrupt, helpers)
- tests/agent/test_ask_user.py
- webui/src/components/thread/AskUserPrompt.tsx
- AskUserInterrupt handling in runner.py
- Dual-path message building in loop.py
- Pending ask detection via history scanning
- button_prompt/buttons emission in WebSocket channel
- ask_user references in Slack channel docstrings

Preserved (MessageTool uses these independently):
- OutboundMessage.buttons field
- Channel button rendering (Telegram, Slack, WebSocket)
2026-05-12 22:48:26 +08:00
chengyongruandXubin Ren ef268f47d2 chore: remove dead code identified by vulture + coverage cross-validation
Remove unused code confirmed dead via vulture scan, grep verification,
and coverage analysis:

- _get_bridge_dir (cli/commands.py): 82-line function with zero callers
- add_assistant_message (agent/context.py): method body never executed,
  also removed now-unused build_assistant_message import
- _tool_parameters_schema (agent/tools/base.py): redundant copy of schema
  already exposed via the `parameters` property
- MSTEAMS_REF_TTL_S (channels/msteams.py): unused constant (production
  uses config.ref_ttl_days directly); inlined in test
- MESSAGE_TYPE_USER (channels/weixin.py): unused constant
2026-05-12 20:52:48 +08:00
chengyongruandXubin Ren 6f78267c82 feat(config): add ModelPresetConfig and runtime preset switching
- Add `ModelPresetConfig` schema for named model presets
- Add `model_presets` dict to `Config` and `model_preset` field to `AgentDefaults`
- Add `resolve_preset()` to return effective model params from preset or defaults
- Add `@model_validator` to reject unknown preset names
- Update `_match_provider()` to use resolved preset model/provider
- Update `make_provider()` and `provider_signature()` to use `resolve_preset()`
- Add `model_preset` property to `AgentLoop` for atomic runtime switching
- Update `AgentLoop.from_config()` to inject a runtime `default` preset
- Wire self-tool to inspect/clear preset state
- Update CLI display strings to show active preset
2026-05-12 20:06:22 +08:00
chengyongruandXubin Ren 043f0e67f7 feat(tools): introduce plugin-based tool discovery and runtime context protocol
This commit implements a progressive refactoring of the tool system to support
plugin discovery, scoped loading, and protocol-driven runtime context injection.

Key changes:
- Add Tool ABC metadata (tool_name, _scopes) and ToolContext dataclass for
dependency injection.
- Introduce ToolLoader with pkgutil-based builtin discovery and
entry_points-based third-party plugin loading.
- Add scope filtering (core/subagent/memory) so different contexts load
appropriate tool sets.
- Introduce ContextAware protocol and RequestContext dataclass to replace
hardcoded per-tool context injection in AgentLoop.
- Add RuntimeState / MutableRuntimeState protocols to decouple MyTool from
AgentLoop.
- Migrate all built-in tools to declare scopes and implement create()/enabled()
hooks.
- Migrate MessageTool, SpawnTool, CronTool, and MyTool to ContextAware.
- Refactor AgentLoop to use ToolLoader and protocol-driven context injection.
- Refactor SubagentManager to use ToolLoader(scope="subagent") with per-run
FileStates isolation.
- Register all built-in tools via pyproject.toml entry_points.
- Add comprehensive tests for loader scopes, entry_points, ContextAware,
subagent tools, and runtime state sync.
2026-05-12 11:28:20 +08:00
chengyongruandXubin Ren 49f85f5c23 docs(schema,config): clarify reasoning_effort semantics for MiMo thinking mode
- Update AgentDefaults.reasoning_effort comment to document "none"
  (disable) and None (preserve provider default).
- Add configuration.md tip explaining MiMo thinking mode behavior.
2026-05-11 14:38:28 +08:00
chengyongruandXubin Ren a6e993df25 fix(agent): move archived summary into system prompt for KV cache stability
- Append [Archived Context Summary] to system prompt instead of injecting
  it into the user message runtime context, improving KV cache reuse across
  turns and avoiding consecutive same-role messages.
- _last_summary persists in metadata (no pop) for restart survival;
  summary is re-injected every turn via the stable system prompt.
- Remove dynamic "Inactive for X minutes" from _format_summary — use
  static last_active timestamp instead to preserve KV cache stability.
- Pass session_summary through build_messages() so both normal and
  ask_user paths receive the archived summary in the system prompt.
- estimate_session_prompt_tokens now reads _last_summary from metadata
  to include the summary in token budget estimation.
- Remove obsolete session_summary parameter from
  maybe_consolidate_by_tokens and estimate_session_prompt_tokens
  call sites in loop.py (summary flows through build_messages instead).
- Ensure /new (session.clear()) clears _last_summary from metadata.
2026-05-11 01:25:15 +08:00
chengyongruandXubin Ren 73a8d8a875 fix(utils): remove unreachable dead code in find_legal_message_start
The for loop at line 168 never executes because start is assigned
i + 1 immediately before slicing messages[start : i + 1], which
is always an empty list. Remove the dead code.

Fixes #3716
2026-05-09 18:53:13 +08:00
chengyongruandXubin Ren de13e72e15 refactor(loop): log turn completion with state count 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 728d837e4e refactor(loop): add turn_id for trace correlation
- TurnContext now carries a turn_id (session_key:time_ns)
- All state transition debug logs include [turn_id] prefix
- RuntimeError messages also include turn_id for observability
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 5327f5e1a0 refactor(loop): event-driven state transitions + trace logging
- State handlers now return event strings ('ok', 'dispatch', 'shortcut')
- Driver loop uses _TRANSITIONS lookup table: (state, event) -> next_state
- State graph is centralized and visible at a glance
- Added StateTraceEntry to record per-state timing and events
- Driver loop logs state duration + event at debug level
- Exception paths are traced with error field for observability
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 6ef1b2c842 refactor(loop): address code review nits
- Fix _assemble_outbound on_stream type annotation (Callable[[str], Awaitable[None]] | None)
- Use last_msg consistently in _state_save instead of re-indexing
- Remove dead  fallback in _state_respond (guaranteed non-None by _state_save)
- Change pending_summary type from Any to str | None
- Make session optional in TurnContext to avoid redundant fetch
- Add defensive dispatch with RuntimeError for missing handlers
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 8a6b769219 refactor(loop): fix line length in state handlers 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 02443ca208 refactor(loop): convert _process_message to functional state machine
- Extract TurnState enum and TurnContext dataclass
- Extract state handlers: _state_restore, _state_compact, _state_command,
  _state_build, _state_run, _state_save, _state_respond
- Extract _process_system_message for system message short-circuit
- Driver loop uses getattr dispatch over explicit state transitions
- Preserve all existing behavior (794 tests passing)
2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 9fb9f53147 refactor(loop): add TurnState and TurnContext 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 88cf8db164 refactor(loop): extract _assemble_outbound 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren 0124c94d19 refactor(loop): extract _build_initial_messages 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren ce52070fcf refactor(loop): extract _persist_user_message_early 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren d2cb8ac17f refactor(loop): extract _build_retry_wait_callback 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren b2fb776a68 refactor(loop): extract _build_bus_progress_callback 2026-05-09 17:15:23 +08:00
chengyongruandXubin Ren ff6b014a07 refactor: allow model/context_window_tokens override in from_config()
- Pop model and context_window_tokens from extra kwargs before
  forwarding to __init__, allowing callers like _run_gateway to
  pass snapshot-derived values instead of config defaults
- _run_gateway now explicitly passes model/context_window_tokens
  from provider_snapshot to preserve pre-refactor behavior
2026-05-09 15:30:48 +08:00
chengyongruandXubin Ren 733b34d685 refactor: address code review feedback on AgentLoop.from_config()
- Accept optional `provider` kwarg in from_config() to avoid double
  instantiation in _run_gateway (which already builds provider_snapshot)
- Restore try/except ValueError wrappers in serve() and agent() for
  clean error messages on provider creation failure
- Update test: _FakeAgentLoop captures provider from kwargs, restore
  strong assertion (seen["provider"] is provider)
2026-05-09 15:30:48 +08:00
chengyongruandXubin Ren 3202f58c41 refactor: introduce AgentLoop.from_config() to centralize loop assembly
Extract duplicated bus/provider/loop initialization from CLI commands
(serve, _run_gateway, agent) and Nanobot facade into a single
AgentLoop.from_config() classmethod.

- Remove _make_provider() from cli/commands.py and nanobot.py
- Remove inline provider creation in all three CLI entry points
- AgentLoop.from_config() creates MessageBus, calls make_provider(),
  and assembles AgentLoop with all standard config-derived parameters
- Supports **extra overrides for callers that need custom args
  (e.g. cron_service, session_manager, provider_snapshot_loader)
- Update tests to mock make_provider at nanobot.providers.factory
  and add from_config classmethod to _FakeAgentLoop fixtures

This is PR 1/4 of the model-preset feature decomposition.
2026-05-09 15:30:48 +08:00
chengyongruandXubin Ren e5a1416a37 fix(agent): persist _last_summary across restarts with used sentinel
The previous implementation popped _last_summary from session.metadata
after injecting it into the prompt, then saved the session. This caused
the summary to be permanently lost after a process restart, making the
AI forget archived context and appear to ignore memory or reference
non-existent previous messages.

Replace the destructive pop with a _last_summary_used sentinel:
- _last_summary stays in metadata for restart survival
- _last_summary_used prevents duplicate injection within the same turn
- Clear the sentinel whenever a new summary is generated

Updates tests to match the new persistence behavior.
2026-05-09 14:58:38 +08:00
chengyongruandXubin Ren 6eef3d0f15 docs: add CLAUDE.md and .agent/ guides for AI contributors
Add CLAUDE.md at the repository root to orient future Claude Code
instances, and split detailed constraints into .agent/:

- .agent/design.md    — architectural constraints (core small, duplication
  over abstraction, minimal changes, explicit over magical)
- .agent/security.md  — workspace/SSRF/shell sandbox boundaries
- .agent/gotchas.md   — config ${VAR}, Windows compat, templates,
  heartbeat virtual tool call, atomic writes, ruff format warning,
  skills extension point

Also updates .gitignore to not ignore .agent/.
2026-05-09 14:00:32 +08:00
chengyongruandXubin Ren 3a2f47d720 fix(onboard): allow empty strings and falsy values in input fields
Fixes two related input-handling bugs in the onboard wizard:

1. _input_text treated "" as None, preventing users from clearing
   optional string fields or entering empty strings intentionally.

2. _input_model_with_autocomplete used `if value else None`, which
   discarded falsy values such as empty strings or 0.

To support clearing optional string fields, add _is_str_or_none() and
normalize empty strings to None inside _configure_pydantic_model only
when the field annotation is `str | None`. Required str fields keep
"" as a valid value.

Also included:
- Remember last selected item in provider/channel/model menus for
  better UX when configuring multiple items.
- Rename _SIMPLE_TYPES and _MENU_DISPATCH to lowercase to follow
  Python naming conventions (they are local variables, not constants).
- Remove unused imports in test file.

Extracted from PR #3358.
2026-05-08 13:21:51 +08:00
chengyongruandXubin Ren 536c456e5e fix(channels): restore bound logger in discord and websocket
PR introduced module-level logger in static methods, which drops
the channel context bound by BaseChannel.__init__. Revert to
self._channel.logger / self.logger to preserve log labels.

Also remove @staticmethod since these methods legitimately need
instance access (F821 was the real issue, not the logger source).
2026-05-07 13:07:22 +08:00
chengyongruandXubin Ren 9a8c4da0c4 refactor(logging): preserve tracebacks in remaining except blocks
Follow-up to PR #3651:

- Replace logger.error with logger.exception inside except blocks
  so stack traces are no longer lost:
  - providers/transcription.py (5 occurrences)
  - agent/tools/mcp.py (1 occurrence)

- Replace stdlib logging.getLogger with loguru logger in
  providers/openai_compat_provider.py for consistency.
2026-05-07 13:06:59 +08:00
chengyongruandXubin Ren 4fad19dc17 fix: use sequential MCP server connections to prevent CPU spin
asyncio.create_task in connect_mcp_servers creates child tasks for
each MCP server, but close_mcp calls stack.aclose() from the main
task. anyio CancelScope requires enter/exit in the same task, so the
cross-task exit raises RuntimeError which gets silently caught. The
orphaned cancel scope keeps retrying via call_soon on every event
loop tick, consuming 100% CPU.

Fix: remove create_task/gather and connect servers sequentially in the
caller task. MCP servers are typically 1-2, so parallel connection
provides negligible benefit while introducing the cancel scope hazard.

Closes #3638
2026-05-06 21:18:51 +08:00
chengyongru 3437ff273f fix(transcription): address review nits on PR #3253
- Correct api_key type hint to str | None in _post_transcription_with_retry
- Remove unreachable final return ""
- Fix test_openai_missing_api_key_short_circuits to actually test
  missing-key path (use audio_file fixture so file exists)
- Fix PermissionError patch for Windows (patch class method instead
  of instance attribute)
2026-05-06 15:52:29 +08:00
chengyongruandXubin Ren 74270bb8a8 refactor(channels): resolve progress overrides at init-time like transcription 2026-04-29 16:43:09 +08:00
28f9bbff31 feat(web_search): add olostep provider
Adds Olostep (https://www.olostep.com) as an optional web_search backend
using the official olostep Python SDK (client.answers.create()).

Changes:
- pyproject.toml: adds olostep>=0.1.0 optional dependency
- schema.py: adds olostep to provider comment in WebSearchConfig
- web.py: adds _search_olostep() with lazy import and provider branching
- docs/configuration.md: documents Olostep setup under web search config
- tests: unit tests for the new provider

Backward compatible: existing users see no behavior change unless they
opt into provider: "olostep". No hard dependency at runtime path.

Co-authored-by: umerkay <umerkk164@gmail.com>
2026-04-28 19:09:38 +08:00
chengyongruandXubin Ren 9b3e2524ac fix(agent): resolve relative media paths in MessageTool
When deployed with Docker and workspace mounted as a volume, sending
media files failed because relative paths (e.g. output/image.png) were
not resolved against the workspace directory. The process CWD differs
from the workspace in containerized environments, causing os.path.isfile
checks to fail in channel handlers. Normalize relative media paths at
the MessageTool entry point using get_workspace_path().
2026-04-27 14:31:39 +08:00
chengyongruandXubin Ren 6eb178113e fix(mcp): sanitize MCP capability names for model API compatibility
MCP resource/prompt/tool names containing spaces or special characters
(e.g. "PostgreSQL System Information") were forwarded verbatim to model
provider APIs, causing validation errors from both Anthropic and OpenAI
which require names matching ^[a-zA-Z0-9_-]{1,128}$.

Add _sanitize_name() that replaces invalid characters with underscores
and collapses consecutive underscores. Applied in MCPToolWrapper,
MCPResourceWrapper, MCPPromptWrapper constructors and the enabled_tools
filtering logic.

Closes #3468
2026-04-27 11:49:50 +08:00
chengyongruandXubin Ren 42c4af2118 fix(agent): prevent duplicate responses when sub-agents complete concurrently
When the main agent spawns multiple sub-agents, each completion
independently triggered a new _dispatch, causing 3-4 user-visible
responses instead of a single comprehensive report.

- Extend _drain_pending to block-wait on pending_queue when sub-agents
  are still running, keeping the runner loop alive for in-order injection
- Pass pending_queue in the system message path so subsequent sub-agent
  results can still be injected mid-turn via a new dispatch
2026-04-22 20:02:19 +08:00
chengyongruandXubin Ren f6a417e77d fix(transcription): harden language parameter validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
2026-04-22 12:41:32 +08:00
chengyongruandXubin Ren 37ea8b8f5b fix(retry): recognize ZhiPu 1302 rate-limit error for retry
ZhiPu API returns code 1302 with Chinese text "速率限制" instead of
standard HTTP 429 + "rate limit", causing the retry engine to treat
it as non-transient and fail immediately.
2026-04-21 21:23:20 +08:00
chengyongruandXubin Ren 409afe1a3d test(tools): add basic regression tests for ContextVar routing context 2026-04-21 13:25:30 +08:00
chengyongruandXubin Ren f900c5bb8e fix(telegram): address code review issues from cherry-pick merge
- Fix critical plain-text fallback that was sending raw HTML tags to
  users: keep raw markdown available for the fallback path
- Extract TELEGRAM_HTML_MAX_LEN (4096) constant to replace hardcoded
  magic number and document the difference from TELEGRAM_MAX_MESSAGE_LEN
- Add fallback to _send_text for extra HTML chunks when HTML parse fails
- Add missing @pytest.mark.asyncio decorator on
  test_send_delta_stream_end_html_expansion_does_not_overflow
2026-04-20 16:58:46 +08:00
chengyongruandXubin Ren ecfbb0ed4f refactor(email): use _remember_processed_uid in SPF/DKIM reject paths
Replaces inline dedup logic with the existing helper to match the
style of _is_self_address and other reject branches, and to keep the
_processed_uids eviction logic in one place.
2026-04-20 16:46:49 +08:00
chengyongruandXubin Ren 68466b1c2a fix(agent): propagate effective session key through subagent pipeline
The previous fix hardcoded session_key_override as channel:chat_id which
broke unified session mode where pending queues use "unified:default".
Propagate the effective key from _set_tool_context through SpawnTool
into the origin dict so _announce_result routes to the correct pending
queue in both normal and unified session modes.
2026-04-20 14:47:14 +08:00
chengyongruandXubin Ren 2193a64c80 fix(agent): align subagent result session key with main agent for mid-turn injection
When mid-turn message injection (PR #2985) was introduced, the pending
queue routing uses the effective session key to match incoming messages
against active sessions. Subagent results, however, use channel="system"
which produces a session key of "system:feishu:ou_..." instead of the
main agent's "feishu:ou_...", causing the result to bypass the pending
queue and be dispatched as a competing independent task.

Fix: set session_key_override to the original channel:chat_id so
_effective_session_key returns the correct key and the subagent result
gets routed into the main agent's pending queue.
2026-04-20 14:47:14 +08:00
chengyongruandXubin Ren 79821a571f fix: suppress intermediate progress output in cron jobs
Cron jobs now pass on_progress=_silent to process_direct, matching
the heartbeat pattern. Previously, tool hints and streaming deltas
were published to the user channel via bus during execution, but the
final response could be rejected by evaluate_response — leaving users
with confusing partial output and no conclusion.

Closes #3319
2026-04-20 11:43:54 +08:00
Cheng YongruandXubin Ren aabc3d5017 fix(memory): fall back to raw_archive on LLM error response
When chat_with_retry returns an error response (finish_reason='error')
instead of raising an exception, archive() previously treated the error
message as a valid summary and wrote it to history.jsonl, while the
original session data was already cleared by /new — causing irreversible
data loss.

Fix: check finish_reason after the LLM call and raise RuntimeError on
error responses, which naturally falls through to the existing raw_archive
fallback. This preserves the original messages in history.jsonl instead
of losing them.

Fixes #3244
2026-04-17 20:15:07 +08:00
chengyongruandXubin Ren 8c0c4e5b31 refactor(agent): tighten comments, extract constant, strengthen edge case test
- Extract synthetic user message string to module-level constant
- Tighten comments in _snip_history recovery branch
- Strengthen no-user edge case test to verify safety net interaction
2026-04-17 16:20:53 +08:00
44b526c4ee fix(agent): preserve user message in _snip_history to prevent GLM error 1214
When _snip_history truncates the message history and the only user message
ends up outside the kept window, providers like GLM reject the resulting
system→assistant sequence with error 1214 ("messages 参数非法").

Two-layer fix:
1. _snip_history now walks backwards through non_system messages to recover
   the nearest user message when none exists in the kept window.
2. _enforce_role_alternation inserts a synthetic user message
   "(conversation continued)" when the first non-system message is a bare
   assistant (no tool_calls), serving as a safety net for any edge cases
   that slip through.

Co-authored-by: darlingbud <darlingbud@users.noreply.github.com>
2026-04-17 16:20:53 +08:00
chengyongruandXubin Ren 35f3084c03 feat(dream): per-line age annotations + dedup-aware prompt + max_iter=15
Three improvements to Dream's memory consolidation:

1. Per-line git-blame age annotations: MEMORY.md lines get `← Nd` suffixes
   (N>14) from dulwich annotate. SOUL.md/USER.md excluded as permanent.
   LLM uses content judgment, not just age, to decide what to prune.

2. Dedup-aware Phase 1 prompt: reframed as dual-task (extract facts +
   deduplicate existing files) with explicit redundancy patterns to scan for.
   Validated through 20 experiments (exp-002 prompt + max_iter=15 was best,
   averaging -1643 chars/5.4% compression per run).

3. Phase 1 analysis as commit body: dream git commits now include the full
   Phase 1 analysis for transparency via /dream-log.

4. max_iterations raised from 10 to 15: 30% improvement over 10 with no
   risk; 20 showed diminishing returns (exp-020: -701 vs exp-017: -1643).
2026-04-17 13:45:38 +08:00
chengyongruandXubin Ren e1fdca7d40 fix(status): correct context percentage calculation and sync consolidator
- Pass resolved self.context_window_tokens to Consolidator instead of
  raw parameter that could be None, preventing consolidation failures
- Calculate percentage against input budget (ctx - max_completion - 1024)
  instead of raw context window, consistent with Consolidator/snip formulas
- Pass actual max_completion_tokens from provider to build_status_content
- Cap percentage display at 999 to prevent runaway values
- Add tests for budget-based percentage and cap behavior
2026-04-16 20:30:39 +08:00
chengyongruandXubin Ren abe0145f99 fix(msteams): harden availability check and migrate docs to README
- Check both jwt and cryptography in MSTEAMS_AVAILABLE guard so
  partial installs fail early with a clear message instead of at runtime
- Add aclose() to test FakeHttpClient so stop() won't crash
- Move MSTEAMS.md into README.md following the same details/summary
  pattern used by every other channel
- Note in README that validateInboundAuth defaults to false
2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren 49223e639e fix(msteams): add auth warning and restore unrelated pyproject change
Warn when validate_inbound_auth is disabled (default) so operators are
aware the webhook accepts unverified requests.  Restore pymupdf to the
dev optional-dependencies group — its removal in the original PR was
unrelated to the Teams channel feature.
2026-04-16 13:22:07 +08:00
chengyongruandXubin Ren d64e963258 test(memory): add regression tests for missing cursor key
Cover read_unprocessed_history skipping cursorless entries and
_next_cursor safe fallback when last entry has no cursor.
2026-04-16 12:32:38 +08:00
chengyongruandXubin Ren 524c097f76 refactor(memory): simplify read_unprocessed_history cursor guard
Replace verbose loop with one-liner list comprehension using
e.get("cursor", 0) to handle missing cursor keys.
2026-04-16 12:32:38 +08:00
chengyongruandXubin Ren 6fbada5363 refactor(context): deduplicate system prompt — markdown skills index, skip template MEMORY.md
- Convert skills summary from verbose XML (4-5 lines/skill) to compact
  markdown list (1 line/skill) with inline path for read_file lookup
- Exclude always-loaded skills (e.g. memory) from the skills index to
  avoid duplicating content already in the Active Skills section
- Skip injecting the Memory section when MEMORY.md still matches the
  bundled template (i.e. Dream hasn't populated it yet)
2026-04-15 15:49:30 +08:00
chengyongruandXubin Ren 0adce5405b fix(feishu): remove resuming to avoid 10-min streaming card timeout
Feishu streaming cards auto-close after 10 minutes from creation,
regardless of update activity. With resuming enabled, a single card
lives across multiple tool-call rounds and can exceed this limit,
causing the final response to be silently lost.

Remove the _resuming logic from send_delta so each tool-call round
gets its own short-lived streaming card (well under 10 min). Add a
fallback that sends a regular interactive card when the final
streaming update fails.
2026-04-14 16:53:42 +08:00
chengyongruandXubin Ren 3c06db7e4e fix(log): remove noisy no-op logs from auto-compact
Remove two debug log lines that fire on every idle channel check:
- "scheduling archival" (logged before knowing if there's work)
- "skipping, no un-consolidated messages" (the common no-op path)

The meaningful "archived" info log (only on real work) is preserved.
2026-04-13 20:14:58 +08:00
chengyongruandchengyongru b3288fbc87 fix(log): only log auto-compact when messages are actually archived 2026-04-13 16:52:47 +08:00
chengyongruandchengyongru b311759e87 fix(log): remove noisy no-op logs from auto-compact
Remove two debug log lines that fire on every idle channel check:
- "scheduling archival" (logged before knowing if there's work)
- "skipping, no un-consolidated messages" (the common no-op path)

The meaningful "archived" info log (only on real work) is preserved.
2026-04-13 16:09:42 +08:00
chengyongruandXubin Ren ac714803f6 fix(provider): recover trailing assistant message as user to prevent empty request
When a subagent result is injected with current_role="assistant",
_enforce_role_alternation drops the trailing assistant message, leaving
only the system prompt. Providers like Zhipu/GLM reject such requests
with error 1214 ("messages parameter invalid"). Now the last popped
assistant message is recovered as a user message when no user/tool
messages remain.
2026-04-13 12:54:39 +08:00
chengyongruandXubin Ren becaff3e9d fix(agent): skip auto-compact for sessions with active agent tasks
Prevent proactive compaction from archiving sessions that have an
in-flight agent task, avoiding mid-turn context truncation when a
task runs longer than the idle TTL.
2026-04-13 12:51:37 +08:00
chengyongruandchengyongru 89ea2375fd fix(provider): recover trailing assistant message as user to prevent empty request
When a subagent result is injected with current_role="assistant",
_enforce_role_alternation drops the trailing assistant message, leaving
only the system prompt. Providers like Zhipu/GLM reject such requests
with error 1214 ("messages parameter invalid"). Now the last popped
assistant message is recovered as a user message when no user/tool
messages remain.
2026-04-13 12:01:45 +08:00
chengyongruandchengyongru 62bd54ac4a fix(agent): skip auto-compact for sessions with active agent tasks
Prevent proactive compaction from archiving sessions that have an
in-flight agent task, avoiding mid-turn context truncation when a
task runs longer than the idle TTL.
2026-04-13 12:01:29 +08:00
chengyongruandXubin Ren 0d03f10fa0 test(channels): add media support tests for QQ and WeCom channels
Cover helpers (sanitize_filename, guess media type), outbound send
(exception handling, media-then-text order, fallback), inbound message
processing (attachments, dedup, empty content), _post_base64file
payload filtering, and WeCom upload/download flows.
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren f6f712a2ae fix(wecom): harden upload/download, extract media type helper
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Use memoryview for upload chunking to reduce peak memory
- Add inbound download size check to prevent OOM
- Use asyncio.to_thread for write_bytes in download path
- Extract inline media_type detection to _guess_wecom_media_type()
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren f900e4f259 fix(wecom): harden upload and inbound media handling
- Use asyncio.to_thread for file I/O to avoid blocking event loop
- Add 200MB upload size limit with early rejection
- Fix file handle leak by using context manager
- Free raw bytes early after chunking to reduce memory pressure
- Add file attachments to media_paths (was text-only, inconsistent with image)
- Use robust _sanitize_filename() instead of os.path.basename() for path safety
- Remove re-raise in send() for consistency with QQ channel
- Fix truncated media_id logging for short IDs
2026-04-11 21:47:19 +08:00
chengyongruandXubin Ren d03458f034 fix(agent): eliminate race condition in auto compact summary retrieval
Make Consolidator.archive() return the summary string directly instead
of writing to history.jsonl then reading back via get_last_history_entry().
This eliminates a race condition where concurrent _archive calls for
different sessions could read each other's summaries from the shared
history file (cross-user context leak in multi-user deployments).

Also removes Consolidator.get_last_history_entry() — no longer needed.
2026-04-11 15:56:41 +08:00
chengyongruandXubin Ren 69d60e2b06 fix(agent): handle UnicodeDecodeError in _read_last_entry
history.jsonl may contain non-UTF-8 bytes (e.g. from email channel
binary content), causing auto compact to fail when reading the last
entry for summary generation. Catch UnicodeDecodeError alongside
FileNotFoundError and JSONDecodeError.
2026-04-11 15:56:41 +08:00
chengyongruandXubin Ren 27e7a338a3 docs(feishu): add toolHintPrefix to README config example 2026-04-10 12:29:43 +08:00
chengyongruandXubin Ren 6fd2511c8a refactor(feishu): simplify tool hint to append-only, delegate to send_delta for throttling
- Make tool_hint_prefix configurable in FeishuConfig (default: 🔧)
- Delegate tool hint card updates from send() to send_delta() so hints
  automatically benefit from _STREAM_EDIT_INTERVAL throttling
- Fix staticmethod calls to use self.__class__ instead of self
- Document all supported metadata keys in send_delta docstring
- Add test for empty/whitespace-only tool hint with active stream buffer
2026-04-10 12:29:43 +08:00
chengyongruandXubin Ren e0c6e6f180 test: add regression tests for <thought> tag stripping 2026-04-10 12:10:23 +08:00
chengyongruandXubin Ren 42de13a1a9 docs(websocket): add WebSocket channel documentation
Comprehensive guide covering wire protocol, configuration reference,
token issuance, security notes, and common deployment patterns.
2026-04-09 18:22:35 +08:00
chengyongruandXubin Ren 56a5906db5 fix(websocket): harden security and robustness
- Use hmac.compare_digest for timing-safe static token comparison
- Add issued token capacity limit (_MAX_ISSUED_TOKENS=10000) with 429 response
- Use atomic pop in _take_issued_token_if_valid to eliminate TOCTOU window
- Enforce TLSv1.2 minimum version for SSL connections
- Extract _safe_send helper for consistent ConnectionClosed handling
- Move connection registration after ready send to prevent out-of-order delivery
- Add HTTP-level allow_from check and client_id truncation in process_request
- Make stop() idempotent with graceful shutdown error handling
- Normalize path via validator instead of leaving raw value
- Default websocket_requires_token to True for secure-by-default behavior
- Add integration tests and ws_test_client helper
- Refactor tests to use shared _ch factory and bus fixture
2026-04-09 18:22:35 +08:00
chengyongruandXubin Ren e0ccc401c0 fix(websocket): handle ConnectionClosed gracefully in send and send_delta 2026-04-09 18:22:35 +08:00
b16865722b fix(tool-hint): fold paths in exec commands and deduplicate by formatted string
1. exec tool hints previously used val[:40] blind character truncation,
   cutting paths mid-segment. Now detects file paths via regex and
   abbreviates them with abbreviate_path. Supports Windows, Unix
   absolute, and ~/ home paths.

2. Deduplication now compares fully formatted hint strings instead of
   tool names alone. Fixes ls /Desktop and ls /Downloads being
   incorrectly merged as "ls /Desktop × 2".

Co-authored-by: xzq.xu <zhiqiang.xu@nodeskai.com>
2026-04-08 23:05:52 +08:00
chengyongruandXubin Ren 3e3a7654f8 fix(agent): address code review findings for tool hint enhancement
- C1: Fix IndexError on empty list arguments via _get_args() helper
- I1: Remove redundant branch in _fmt_known
- I2: Export abbreviate_path from nanobot.utils.__init__
- I3: Fix _abbreviate_url negative-budget format consistency
- S1: Move FORMATS to class-level _TOOL_HINT_FORMATS constant
- S2: Add list_dir to FORMATS registry (ls path)
- G1-G5: Add tests for empty list args, None args, URL edge cases,
  mixed folding groups, and list_dir format
2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren b1d3c00deb test(feishu): add compatibility tests for new tool hint format 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 238a9303d0 test: update tool_hint assertion to match new format 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 8ca9960077 feat(agent): rewrite _tool_hint with registry, path abbreviation, and call folding 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren f452af6c62 feat(utils): add abbreviate_path for smart path/URL truncation 2026-04-07 15:15:07 +08:00
chengyongruandXubin Ren 5479a44691 fix: stop leaking reasoning_content to stream output
The streaming path in OpenAICompatProvider.chat_stream() was passing
reasoning_content deltas through on_content_delta(), causing model
internal reasoning to be displayed to the user alongside the actual
response content.

reasoning_content is already collected separately in _parse_chunks()
and stored in LLMResponse.reasoning_content for session history.
It should never be forwarded to the user-facing stream.
2026-04-05 17:27:14 +08:00