The tool execution path caught BaseException, which includes
KeyboardInterrupt, SystemExit, MemoryError, and GeneratorExit.
These should never be caught and converted into conversational
error messages. CancelledError is already handled separately.
Change except BaseException to except Exception so fatal signals
propagate instead of being swallowed.
Adds parametrized regression test for KeyboardInterrupt and
SystemExit propagation.
Fixes#4788
_get_copilot_access_token had a check-then-act race: concurrent chat()
calls after token expiry both fetched new tokens and clobbered each other.
Add asyncio.Lock with double-checked locking so only one fetch happens
per expiry window.
Closes#4677
apply_final_call_ids iterated over all final tool calls, including
non-file-edit tools like read_file. The greedy path-match in
matches_final_tool_call could overwrite a correct unique id with a
stale one from a different streaming state, producing duplicate
tool_use ids that poison the persisted session.
Guard the loop with is_file_edit_tool() so only tracked file-edit
tools (write_file, edit_file, apply_patch) are subject to canonical
id remapping. Non-file-edit tools keep their authoritative id from
get_final_message().
Fixes#4595
Remove retained and new_last_consolidated from RetentionResult.
Both were populated but never read by any caller. The authoritative
state remains self.messages and self.last_consolidated, which the
method mutates in place. Update docstring accordingly.
Replace the tuple(list[dict], int) return of
Session.retain_recent_legal_suffix with a named RetentionResult
dataclass that exposes retained, dropped,
already_consolidated_count, and new_last_consolidated fields.
The tuple return was easy to misuse because the second value only
made sense relative to the first and the old last_consolidated
cursor. The named fields make the archive-skip semantics explicit
at every call site.
No behavior change. All existing tests pass unchanged in semantics.
Refs #4136
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
list_sessions() silently dropped corrupt session files whose filename
stem was a legacy non-base64 name (e.g. telegram_12345.jsonl from the
old lossy path scheme). The repair path called _repair(fallback_key),
but _repair re-encodes the key via _storage_key(), producing a
different base64 filename that never matches the actual file on disk.
Add an optional path parameter to _repair so callers can pass the
actual file path directly, bypassing the key-to-filename round trip.
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
After a gateway restart or websocket reconnect, the UI stays stuck in
processing state because reconnecting clients only replay running status
when a turn is active, never push idle when no turn is running.
Fix _hydrate_after_subscribe to always push goal_status (running with
started_at when turn is active, idle when no turn is running) so the
frontend can reset its processing indicator on reconnect.
Also fix cmd_stop reporting 'No active task to stop' when a task is
actually processing by draining the pending injection queue in addition
to cancelling active tasks. This prevents mid-turn injection deadlocks
and gives accurate task counts.
safe_key() replaces ':' with '_', causing collisions between distinct
keys (e.g. telegram:a_b vs telegram:a:b both become telegram_a_b).
Use base64url (no padding) for collision-resistant encoding while
maintaining backward compatibility: _get_session_path and
_get_legacy_session_path check the new path first, then fall back
to the old lossy encoding for existing session files.
_assistant_blocks appends dict items from content lists directly
without checking for the required 'type' field. A block like
{'text': 'hi'} reaches the Anthropic payload without a 'type',
causing a 400 rejection.
Add the same missing-type check that _convert_user_content already has,
so bare dicts in assistant content lists are coerced to text blocks
instead of triggering API validation errors.
Co-authored-by: nanobot-issues <issues@nanobot.dev>
ChannelManager coalesces _stream_delta messages by (channel, chat_id)
only. Overlapping streams in the same chat can be merged incorrectly
because deltas from distinct _stream_id values share one buffer.
Include _stream_id in the coalescing key so distinct streams in the
same channel and chat are delivered separately.
Duplicate tool call ID normalization exists in the streaming parser path
but is not shared with the non-stream parser. Non-stream parsing appends
raw provider IDs into ToolCallRequest objects without deduplication.
Some OpenAI-compatible providers reuse the same tool_call_id for parallel
tool calls in non-streaming responses. Without dedup, runner executes
both tools with the same ID, producing duplicate tool results with the
same tool_call_id, which can fail strict provider validation.
Add the same _seen_tc_ids dedup pattern used in _parse_chunks to the
_parse method so both paths handle duplicate IDs consistently.
- Removes match_text regex that stripped # comments before pattern matching
(broke on quoted # inside strings)
- allow_patterns now run re.fullmatch against the full lowercased command
- deny_patterns search the original lowercased command
- Replaces comment-stripping test with comment-tail bypass regression
(touch canary # echo allowlisted must be blocked)
- Adds Re-bin regression for quoted hash + blocked command
(echo "#" followed by blocked command must be caught)
- All 10 tests pass
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
The exec tool defaults login=True for bash/zsh, which causes the shell
to source ~/.bash_profile and similar startup files. This reintroduces
secrets from shell startup files into the exec environment, even though
_build_env() intentionally starts with a curated environment.
Change the default to login=False in both _prepare_command() and _spawn(),
and update the schema default accordingly.
Add fail_on_tool_error to AgentDefaults and wire it through
AgentLoop -> SubagentManager -> AgentRunSpec.
Previously hardcoded to True in SubagentManager._run_subagent.
Now configurable via config.json with default True for backward
compatibility. When set to False, subagents can retry on minor
tool errors instead of immediately failing.
Changes:
- nanobot/config/schema.py: add fail_on_tool_error field (default True)
- nanobot/agent/subagent.py: accept and forward fail_on_tool_error
- nanobot/agent/loop.py: pass config through to SubagentManager
- tests/agent/test_subagent.py: add regression test
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
Widen thinking_style from Literal to str | None and add a
@field_validator that produces a helpful error message listing
valid options when an invalid value is provided.
Addresses the review feedback on #4482.
ProviderConfig.thinking_style defaults to None (Optional field), but
create_dynamic_spec expects a string. Coalesce None to "" at all call
sites (factory.py, settings_api.py) and fix the test assertion to
expect None from the config default.
Address review: avoid overwriting cursor on every restart when Dream
is disabled. Now only advances if current cursor is behind the latest
position, so repeated restarts don't permanently skip entries.
When dream.enabled is false, the Dream cron job never runs, so the
dream cursor (.dream_cursor) stays at its initial value (0). This
causes read_recent_history_for_prompt() to treat every history entry
as unprocessed, injecting the full chat history into every system
prompt and growing without bound.
Fix: fast-forward the dream cursor to the latest history entry at
gateway startup when Dream is disabled.
When _stream_end fires and a streaming preview already exists, the
sendRichMessage path deletes the preview and sends a fresh message.
This causes line break loss and visible flickering. Gate the rich
path on not buf.message_id so existing previews use the legacy
edit_message_text path instead.
The temperature suppression was hardcoded to only match opus-4-7. Newer
Anthropic models (opus-4-8, fable) also reject the parameter with a 400.
Normalize model_name to lowercase before matching so mixed-case configs
do not fall through. Add tests for opus-4-8 and fable across adaptive,
enabled, and no-thinking paths, plus a negative test confirming ordinary
models still send temperature.
Fixes#4333
Move the fenced-code-block-aware splitting logic out of the shared
split_message helper (used by Signal, Slack, Discord, Weixin, etc.)
and into a Telegram-specific _split_telegram_markdown function.
The shared split_message remains a plain-text chunker. The Telegram
channel now uses _split_telegram_markdown for its raw Markdown paths
that feed _markdown_to_telegram_html, preventing broken HTML rendering
when splits fall inside fenced code blocks.
Also fixes a regression where content beginning with whitespace before
a fence could emit a whitespace-only chunk.
Addresses review feedback on #4257.
When split_message splits a long message, it now checks whether the
split point falls inside a fenced code block. If so, it either moves
the split to before the opening fence or closes/reopens the fence
across chunks, preventing broken HTML rendering.
Addresses #4250
Adds ProviderConfig.extra_query, threaded into AsyncOpenAI(default_query)
so that Azure-style gateways requiring query params like api-version can
be configured without URL hacks.
Also updates provider_signature to track extra_query changes so per-turn
refresh rebuilds the provider when the value changes.
Addresses the extra_query portion of #4204. The max_completion_tokens
model-awareness enhancement is intentionally left separate.
The SDK opened MCP connections through AgentLoop.process_direct but
never called close_mcp, leaving stdio MCP generators to be finalized
during asyncio shutdown from a different task, producing a RuntimeError
about exiting a cancel scope in a different task.
Add aclose() that delegates to AgentLoop.close_mcp (which already
drains background tasks and closes MCP stacks), plus __aenter__ and
__aexit__ so the SDK works as an async context manager.
Fixes#4211
uv pip uninstall does not support the -y (assume-yes) flag. Remove it
from the uv fallback argv while keeping it for the python -m pip
uninstall path.
Reported-by: chengyongru
CI's uv-managed Python does not have pip importable, so the runtime
falls back to uv pip. Four tests that verify the python -m pip path
were failing because _pip_available() returned False in CI.
Monkeypatch _pip_available to True in tests that intentionally verify
the pip code path, so they pass regardless of the CI Python
environment.
When nanobot is installed via uv tool install, sys.executable points to
a Python that does not have pip available as a module. _pip_install_argv
and _pip_uninstall_argv always used [sys.executable, -m, pip, ...]
which fails in that environment.
Add _pip_available() helper that checks importlib.util.find_spec('pip').
When pip is not available and uv is on PATH, fall back to:
uv pip install --python <sys.executable> ...
uv pip uninstall --python <sys.executable> -y ...
If neither pip nor uv is available, raise CliAppError.
Fixes#4158
Add Cmd/Ctrl+Shift+O shortcut to start a new chat, matching the
convention used by ChatGPT, Claude.ai, and Gemini.
Addresses #4178
Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>