mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +00:00
refactor(core): remove redundant runtime scaffolding (#5127)
This commit is contained in:
parent
4c77126b3d
commit
ef9e687f19
@ -87,9 +87,9 @@ class ContextBuilder:
|
|||||||
|
|
||||||
parts.append(render_template("agent/tool_contract.md"))
|
parts.append(render_template("agent/tool_contract.md"))
|
||||||
|
|
||||||
memory = self.memory.get_memory_context()
|
memory = self.memory.read_memory()
|
||||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||||
parts.append(f"# Memory\n\n{memory}")
|
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||||
|
|
||||||
always_skills = self.skills.get_always_skills()
|
always_skills = self.skills.get_always_skills()
|
||||||
if always_skills:
|
if always_skills:
|
||||||
|
|||||||
@ -26,7 +26,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
|
|
||||||
SNIP_SAFETY_BUFFER = 1024
|
SNIP_SAFETY_BUFFER = 1024
|
||||||
MICROCOMPACT_KEEP_RECENT = 10
|
|
||||||
MICROCOMPACT_MIN_CHARS = 500
|
MICROCOMPACT_MIN_CHARS = 500
|
||||||
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
|
||||||
COMPACTABLE_TOOLS = frozenset({
|
COMPACTABLE_TOOLS = frozenset({
|
||||||
@ -498,14 +497,7 @@ class ContextGovernor:
|
|||||||
continue
|
continue
|
||||||
compactable.append((idx, str(tool_call_id)))
|
compactable.append((idx, str(tool_call_id)))
|
||||||
|
|
||||||
if not compactable:
|
return compactable
|
||||||
return []
|
|
||||||
primary_count = max(0, len(compactable) - MICROCOMPACT_KEEP_RECENT)
|
|
||||||
primary = compactable[:primary_count]
|
|
||||||
# Hard overflow beats the keep-recent preference. Return recent results
|
|
||||||
# after stale ones so the newest result is naturally last.
|
|
||||||
fallback = compactable[primary_count:]
|
|
||||||
return primary + fallback
|
|
||||||
|
|
||||||
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
|
||||||
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])
|
||||||
|
|||||||
@ -43,11 +43,7 @@ from nanobot.agent.turn_hooks import AgentTurnHookSpec, build_agent_turn_hook
|
|||||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.bus.runtime_events import (
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
RuntimeEventBus,
|
|
||||||
RuntimeEventPublisher,
|
|
||||||
ensure_runtime_event_publisher,
|
|
||||||
)
|
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider
|
from nanobot.providers.base import LLMProvider
|
||||||
@ -378,8 +374,8 @@ class AgentLoop:
|
|||||||
self._mcp_stacks: dict[str, MCPConnection] = {}
|
self._mcp_stacks: dict[str, MCPConnection] = {}
|
||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, list[asyncio.Task]] = {} # session_key -> tasks
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._background_tasks: list[asyncio.Task] = []
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._session_locks: dict[str, asyncio.Lock] = {}
|
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||||
# Per-session pending queues for mid-turn message injection.
|
# Per-session pending queues for mid-turn message injection.
|
||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
@ -544,7 +540,7 @@ class AgentLoop:
|
|||||||
return
|
return
|
||||||
if self._runtime_model_publisher is not None:
|
if self._runtime_model_publisher is not None:
|
||||||
self._runtime_model_publisher(runtime.model, runtime.model_preset)
|
self._runtime_model_publisher(runtime.model, runtime.model_preset)
|
||||||
self._runtime_events().runtime_model_changed(
|
self.runtime_event_publisher.runtime_model_changed(
|
||||||
runtime.model,
|
runtime.model,
|
||||||
runtime.model_preset,
|
runtime.model_preset,
|
||||||
)
|
)
|
||||||
@ -621,9 +617,6 @@ class AgentLoop:
|
|||||||
if provider not in self._runtime_context_providers:
|
if provider not in self._runtime_context_providers:
|
||||||
self._runtime_context_providers.append(provider)
|
self._runtime_context_providers.append(provider)
|
||||||
|
|
||||||
def _runtime_events(self) -> RuntimeEventPublisher:
|
|
||||||
return ensure_runtime_event_publisher(self)
|
|
||||||
|
|
||||||
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
async def submit_cron_turn(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
return await self._cron_turns.submit(msg)
|
return await self._cron_turns.submit(msg)
|
||||||
|
|
||||||
@ -687,7 +680,6 @@ class AgentLoop:
|
|||||||
current_message=ctx.msg.content,
|
current_message=ctx.msg.content,
|
||||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||||
channel=ctx.delivery.route.channel,
|
channel=ctx.delivery.route.channel,
|
||||||
current_role="user",
|
|
||||||
session_summary=ctx.pending_summary,
|
session_summary=ctx.pending_summary,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
@ -759,7 +751,7 @@ class AgentLoop:
|
|||||||
|
|
||||||
Returns the total number of cancelled tasks + subagents.
|
Returns the total number of cancelled tasks + subagents.
|
||||||
"""
|
"""
|
||||||
tasks = self._active_tasks.pop(key, [])
|
tasks = self._active_tasks.pop(key, set())
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
with suppress(asyncio.CancelledError, Exception):
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
@ -1153,13 +1145,9 @@ class AgentLoop:
|
|||||||
# Compute the effective session key before dispatching
|
# Compute the effective session key before dispatching
|
||||||
# This ensures /stop command can find tasks correctly when unified session is enabled
|
# This ensures /stop command can find tasks correctly when unified session is enabled
|
||||||
task = asyncio.create_task(self._dispatch(msg))
|
task = asyncio.create_task(self._dispatch(msg))
|
||||||
self._active_tasks.setdefault(effective_key, []).append(task)
|
active_tasks = self._active_tasks.setdefault(effective_key, set())
|
||||||
task.add_done_callback(
|
active_tasks.add(task)
|
||||||
lambda t, k=effective_key: self._active_tasks.get(k, [])
|
task.add_done_callback(active_tasks.discard)
|
||||||
and self._active_tasks[k].remove(t)
|
|
||||||
if t in self._active_tasks.get(k, [])
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them.
|
# MCP stdio transports use AnyIO cancel scopes; close them from the task that opened them.
|
||||||
await self.close_mcp()
|
await self.close_mcp()
|
||||||
@ -1302,8 +1290,8 @@ class AgentLoop:
|
|||||||
def _schedule_background(self, coro) -> None:
|
def _schedule_background(self, coro) -> None:
|
||||||
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
|
||||||
task = asyncio.create_task(coro)
|
task = asyncio.create_task(coro)
|
||||||
self._background_tasks.append(task)
|
self._background_tasks.add(task)
|
||||||
task.add_done_callback(self._background_tasks.remove)
|
task.add_done_callback(self._background_tasks.discard)
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
"""Stop the agent loop."""
|
"""Stop the agent loop."""
|
||||||
@ -2036,5 +2024,5 @@ class AgentLoop:
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
await self._runtime_events().run_status_changed(msg, session_key, "idle")
|
await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle")
|
||||||
self._runtime_events().clear_turn(session_key)
|
self.runtime_event_publisher.clear_turn(session_key)
|
||||||
|
|||||||
@ -67,14 +67,6 @@ class MessageTool(Tool):
|
|||||||
self._fallback_message_id = default_message_id
|
self._fallback_message_id = default_message_id
|
||||||
self._fallback_metadata: dict[str, Any] = {}
|
self._fallback_metadata: dict[str, Any] = {}
|
||||||
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
|
||||||
self._turn_delivered_media_var: ContextVar[tuple[str, ...]] = ContextVar(
|
|
||||||
"message_turn_delivered_media",
|
|
||||||
default=(),
|
|
||||||
)
|
|
||||||
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
|
|
||||||
"message_record_channel_delivery",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
|
self._suppress_delivery_var: ContextVar[bool] = ContextVar(
|
||||||
"message_suppress_delivery",
|
"message_suppress_delivery",
|
||||||
default=False,
|
default=False,
|
||||||
@ -96,19 +88,6 @@ class MessageTool(Tool):
|
|||||||
def start_turn(self) -> None:
|
def start_turn(self) -> None:
|
||||||
"""Reset per-turn send tracking."""
|
"""Reset per-turn send tracking."""
|
||||||
self._sent_in_turn = False
|
self._sent_in_turn = False
|
||||||
self._turn_delivered_media_var.set(())
|
|
||||||
|
|
||||||
def turn_delivered_media_paths(self) -> list[str]:
|
|
||||||
"""Absolute paths attached via this tool to the active chat in the current turn."""
|
|
||||||
return list(self._turn_delivered_media_var.get())
|
|
||||||
|
|
||||||
def set_record_channel_delivery(self, active: bool):
|
|
||||||
"""Mark tool-sent messages as proactive channel deliveries."""
|
|
||||||
return self._record_channel_delivery_var.set(active)
|
|
||||||
|
|
||||||
def reset_record_channel_delivery(self, token) -> None:
|
|
||||||
"""Restore previous proactive delivery recording state."""
|
|
||||||
self._record_channel_delivery_var.reset(token)
|
|
||||||
|
|
||||||
def set_suppress_delivery(self, active: bool):
|
def set_suppress_delivery(self, active: bool):
|
||||||
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
|
||||||
@ -241,7 +220,7 @@ class MessageTool(Tool):
|
|||||||
metadata = dict(default_metadata) if same_target else {}
|
metadata = dict(default_metadata) if same_target else {}
|
||||||
if message_id:
|
if message_id:
|
||||||
metadata["message_id"] = message_id
|
metadata["message_id"] = message_id
|
||||||
if self._record_channel_delivery_var.get() or media:
|
if media:
|
||||||
metadata["_record_channel_delivery"] = True
|
metadata["_record_channel_delivery"] = True
|
||||||
|
|
||||||
msg = OutboundMessage(
|
msg = OutboundMessage(
|
||||||
@ -261,9 +240,6 @@ class MessageTool(Tool):
|
|||||||
await self._send_callback(msg)
|
await self._send_callback(msg)
|
||||||
if channel == default_channel and chat_id == default_chat_id:
|
if channel == default_channel and chat_id == default_chat_id:
|
||||||
self._sent_in_turn = True
|
self._sent_in_turn = True
|
||||||
if media:
|
|
||||||
prev = self._turn_delivered_media_var.get()
|
|
||||||
self._turn_delivered_media_var.set(prev + tuple(str(p) for p in media))
|
|
||||||
media_info = f" with {len(media)} attachments" if media else ""
|
media_info = f" with {len(media)} attachments" if media else ""
|
||||||
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
|
||||||
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
|
||||||
|
|||||||
@ -233,19 +233,3 @@ class RuntimeEventPublisher:
|
|||||||
self.bus.publish_nowait(
|
self.bus.publish_nowait(
|
||||||
RuntimeModelChanged(model=model, model_preset=model_preset)
|
RuntimeModelChanged(model=model, model_preset=model_preset)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_runtime_event_publisher(owner: Any) -> RuntimeEventPublisher:
|
|
||||||
"""Return an owner's runtime publisher, creating missing state lazily."""
|
|
||||||
publisher = getattr(owner, "runtime_event_publisher", None)
|
|
||||||
if isinstance(publisher, RuntimeEventPublisher):
|
|
||||||
return publisher
|
|
||||||
|
|
||||||
bus = getattr(owner, "runtime_events", None)
|
|
||||||
if not isinstance(bus, RuntimeEventBus):
|
|
||||||
bus = RuntimeEventBus()
|
|
||||||
owner.runtime_events = bus
|
|
||||||
|
|
||||||
publisher = RuntimeEventPublisher(bus)
|
|
||||||
owner.runtime_event_publisher = publisher
|
|
||||||
return publisher
|
|
||||||
|
|||||||
@ -43,15 +43,13 @@ def _provider_extra_headers(
|
|||||||
def _make_provider_core(
|
def _make_provider_core(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
preset_name: str | None = None,
|
preset: ModelPresetConfig,
|
||||||
preset: ModelPresetConfig | None = None,
|
|
||||||
model: str | None = None,
|
model: str | None = None,
|
||||||
) -> LLMProvider:
|
) -> LLMProvider:
|
||||||
"""Create a plain LLM provider without failover wrapping."""
|
"""Create a plain LLM provider without failover wrapping."""
|
||||||
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
model = model or preset.model
|
||||||
model = model or resolved.model
|
provider_name = config.get_provider_name(model, preset=preset)
|
||||||
provider_name = config.get_provider_name(model, preset=resolved)
|
p = config.get_provider(model, preset=preset)
|
||||||
p = config.get_provider(model, preset=resolved)
|
|
||||||
spec = find_by_name(provider_name) if provider_name else None
|
spec = find_by_name(provider_name) if provider_name else None
|
||||||
if provider_name and not spec and p:
|
if provider_name and not spec and p:
|
||||||
if not p.api_base:
|
if not p.api_base:
|
||||||
@ -120,7 +118,7 @@ def _make_provider_core(
|
|||||||
|
|
||||||
provider = AnthropicProvider(
|
provider = AnthropicProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=p.api_key if p else None,
|
||||||
api_base=config.get_api_base(model, preset=resolved),
|
api_base=config.get_api_base(model, preset=preset),
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=_provider_extra_headers(spec, p),
|
extra_headers=_provider_extra_headers(spec, p),
|
||||||
)
|
)
|
||||||
@ -140,7 +138,7 @@ def _make_provider_core(
|
|||||||
|
|
||||||
provider = OpenAICompatProvider(
|
provider = OpenAICompatProvider(
|
||||||
api_key=p.api_key if p else None,
|
api_key=p.api_key if p else None,
|
||||||
api_base=config.get_api_base(model, preset=resolved),
|
api_base=config.get_api_base(model, preset=preset),
|
||||||
default_model=model,
|
default_model=model,
|
||||||
extra_headers=_provider_extra_headers(spec, p),
|
extra_headers=_provider_extra_headers(spec, p),
|
||||||
spec=spec,
|
spec=spec,
|
||||||
@ -150,7 +148,7 @@ def _make_provider_core(
|
|||||||
proxy=p.proxy if p else None,
|
proxy=p.proxy if p else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
provider.generation = resolved.to_generation_settings()
|
provider.generation = preset.to_generation_settings()
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
@ -197,16 +195,14 @@ def make_provider(
|
|||||||
the failover path to create providers for fallback models.
|
the failover path to create providers for fallback models.
|
||||||
"""
|
"""
|
||||||
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
||||||
provider = _make_provider_core(config, preset_name=preset_name, preset=preset, model=model)
|
provider = _make_provider_core(config, preset=resolved, model=model)
|
||||||
fallback_presets = _resolve_fallback_presets(config, resolved)
|
fallback_presets = _resolve_fallback_presets(config, resolved)
|
||||||
|
|
||||||
if fallback_presets:
|
if fallback_presets:
|
||||||
provider = FallbackProvider(
|
provider = FallbackProvider(
|
||||||
primary=provider,
|
primary=provider,
|
||||||
fallback_presets=fallback_presets,
|
fallback_presets=fallback_presets,
|
||||||
provider_factory=lambda fb: _make_provider_core(
|
provider_factory=lambda fb: _make_provider_core(config, preset=fb),
|
||||||
config, preset_name=preset_name, preset=fb
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
|
|||||||
@ -13,7 +13,6 @@ from nanobot.providers.base import LLMProvider, LLMResponse
|
|||||||
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
||||||
_PRIMARY_FAILURE_THRESHOLD = 3
|
_PRIMARY_FAILURE_THRESHOLD = 3
|
||||||
_PRIMARY_COOLDOWN_S = 60
|
_PRIMARY_COOLDOWN_S = 60
|
||||||
_MISSING = object()
|
|
||||||
_FALLBACK_ERROR_KINDS = frozenset({
|
_FALLBACK_ERROR_KINDS = frozenset({
|
||||||
"timeout",
|
"timeout",
|
||||||
"connection",
|
"connection",
|
||||||
@ -279,25 +278,17 @@ class FallbackProvider(LLMProvider):
|
|||||||
|
|
||||||
await self._notify_fallback_model(fallback_model)
|
await self._notify_fallback_model(fallback_model)
|
||||||
|
|
||||||
original_values = {
|
fallback_kwargs = {
|
||||||
name: kwargs.get(name, _MISSING)
|
**kwargs,
|
||||||
for name in ("model", "max_tokens", "temperature", "reasoning_effort")
|
"model": fallback_model,
|
||||||
|
"max_tokens": fallback.max_tokens,
|
||||||
|
"temperature": fallback.temperature,
|
||||||
}
|
}
|
||||||
kwargs["model"] = fallback_model
|
|
||||||
kwargs["max_tokens"] = fallback.max_tokens
|
|
||||||
kwargs["temperature"] = fallback.temperature
|
|
||||||
if fallback.reasoning_effort is None:
|
if fallback.reasoning_effort is None:
|
||||||
kwargs.pop("reasoning_effort", None)
|
fallback_kwargs.pop("reasoning_effort", None)
|
||||||
else:
|
else:
|
||||||
kwargs["reasoning_effort"] = fallback.reasoning_effort
|
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
|
||||||
try:
|
fallback_response = await call(fallback_provider, fallback_kwargs)
|
||||||
fallback_response = await call(fallback_provider, kwargs)
|
|
||||||
finally:
|
|
||||||
for name, value in original_values.items():
|
|
||||||
if value is _MISSING:
|
|
||||||
kwargs.pop(name, None)
|
|
||||||
else:
|
|
||||||
kwargs[name] = value
|
|
||||||
|
|
||||||
if fallback_response.finish_reason != "error":
|
if fallback_response.finish_reason != "error":
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@ -304,7 +304,6 @@ class WebuiTurnCoordinator:
|
|||||||
bus: MessageBus
|
bus: MessageBus
|
||||||
sessions: SessionManager
|
sessions: SessionManager
|
||||||
schedule_background: Callable[[Awaitable[None]], None]
|
schedule_background: Callable[[Awaitable[None]], None]
|
||||||
_title_contexts: dict[str, LLMRuntime] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
def subscribe(self, runtime_events: RuntimeEventBus) -> Callable[[], None]:
|
||||||
"""Subscribe this coordinator to runtime events."""
|
"""Subscribe this coordinator to runtime events."""
|
||||||
@ -408,18 +407,6 @@ class WebuiTurnCoordinator:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def capture_title_context(
|
|
||||||
self,
|
|
||||||
session_key: str,
|
|
||||||
msg: InboundMessage,
|
|
||||||
llm: LLMRuntime,
|
|
||||||
) -> None:
|
|
||||||
if msg.channel == "websocket" and msg.metadata.get("webui") is True:
|
|
||||||
self._title_contexts[session_key] = llm
|
|
||||||
|
|
||||||
def discard(self, session_key: str) -> None:
|
|
||||||
self._title_contexts.pop(session_key, None)
|
|
||||||
|
|
||||||
async def publish_run_status(
|
async def publish_run_status(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
@ -451,32 +438,6 @@ class WebuiTurnCoordinator:
|
|||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._schedule_title_update(msg, session_key=session_key)
|
|
||||||
|
|
||||||
def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
|
|
||||||
title_context = self._title_contexts.pop(session_key, None)
|
|
||||||
if msg.metadata.get("webui") is not True or title_context is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
async def _generate_title_and_notify(
|
|
||||||
title_llm: LLMRuntime = title_context,
|
|
||||||
) -> None:
|
|
||||||
generated = await maybe_generate_webui_title_after_turn(
|
|
||||||
channel=msg.channel,
|
|
||||||
metadata=msg.metadata,
|
|
||||||
sessions=self.sessions,
|
|
||||||
session_key=session_key,
|
|
||||||
provider=title_llm.provider,
|
|
||||||
model=title_llm.model,
|
|
||||||
)
|
|
||||||
if generated:
|
|
||||||
await self._publish_session_metadata_updated(
|
|
||||||
channel=msg.channel,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
metadata=msg.metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.schedule_background(_generate_title_and_notify())
|
|
||||||
|
|
||||||
def _schedule_title_update_from_event(self, event: TurnCompleted) -> None:
|
def _schedule_title_update_from_event(self, event: TurnCompleted) -> None:
|
||||||
title_context = event.runtime
|
title_context = event.runtime
|
||||||
|
|||||||
@ -369,6 +369,25 @@ class TestBuildMessages:
|
|||||||
assert messages[1]["role"] == "user"
|
assert messages[1]["role"] == "user"
|
||||||
assert "hello" in str(messages[1]["content"])
|
assert "hello" in str(messages[1]["content"])
|
||||||
|
|
||||||
|
def test_public_builder_preserves_assistant_role_compatibility(self, tmp_path):
|
||||||
|
from nanobot.agent import ContextBuilder as PublicContextBuilder
|
||||||
|
|
||||||
|
builder = PublicContextBuilder(tmp_path)
|
||||||
|
messages = builder.build_messages(
|
||||||
|
history=[{"role": "assistant", "content": "previous result"}],
|
||||||
|
current_message="subagent result",
|
||||||
|
current_role="assistant",
|
||||||
|
runtime_context_blocks=[
|
||||||
|
RuntimeContextBlock(source="test", content="user-only runtime context"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert messages[-1]["role"] == "assistant"
|
||||||
|
assert messages[-1]["content"] == "previous result\n\nsubagent result"
|
||||||
|
assert "user-only runtime context" not in messages[-1]["content"]
|
||||||
|
assert "_meta" not in messages[-1]
|
||||||
|
|
||||||
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages([], "hello", channel="cli")
|
messages = builder.build_messages([], "hello", channel="cli")
|
||||||
|
|||||||
@ -340,21 +340,6 @@ def test_system_prompt_keeps_message_tool_out_of_current_chat_replies(tmp_path)
|
|||||||
assert "Wait for the tool results, then answer once" in prompt
|
assert "Wait for the tool results, then answer once" in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path) -> None:
|
|
||||||
workspace = _make_workspace(tmp_path)
|
|
||||||
builder = ContextBuilder(workspace)
|
|
||||||
|
|
||||||
messages = builder.build_messages(
|
|
||||||
history=[{"role": "assistant", "content": "previous result"}],
|
|
||||||
current_message="subagent result",
|
|
||||||
channel="cli",
|
|
||||||
current_role="assistant",
|
|
||||||
)
|
|
||||||
|
|
||||||
for left, right in zip(messages, messages[1:]):
|
|
||||||
assert not (left.get("role") == right.get("role") == "assistant")
|
|
||||||
|
|
||||||
|
|
||||||
def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None:
|
def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None:
|
||||||
"""Memory search guidance should be discoverable without loading its full body."""
|
"""Memory search guidance should be discoverable without loading its full body."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
@ -398,7 +383,7 @@ def test_template_memory_md_is_skipped(tmp_path) -> None:
|
|||||||
assert "This file is automatically updated by nanobot" not in prompt
|
assert "This file is automatically updated by nanobot" not in prompt
|
||||||
|
|
||||||
|
|
||||||
def test_customized_memory_md_is_injected(tmp_path) -> None:
|
def test_customized_memory_md_is_injected(tmp_path, monkeypatch) -> None:
|
||||||
"""A Dream-populated MEMORY.md should be injected normally."""
|
"""A Dream-populated MEMORY.md should be injected normally."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
from nanobot.utils.helpers import sync_workspace_templates
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
@ -409,7 +394,17 @@ def test_customized_memory_md_is_injected(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
read_memory = builder.memory.read_memory
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def tracked_read_memory() -> str:
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
return read_memory()
|
||||||
|
|
||||||
|
monkeypatch.setattr(builder.memory, "read_memory", tracked_read_memory)
|
||||||
prompt = builder.build_system_prompt()
|
prompt = builder.build_system_prompt()
|
||||||
|
|
||||||
assert "# Memory\n\n## Long-term Memory" in prompt
|
assert "# Memory\n\n## Long-term Memory" in prompt
|
||||||
assert "User prefers dark mode" in prompt
|
assert "User prefers dark mode" in prompt
|
||||||
|
assert calls == 1
|
||||||
|
|||||||
@ -34,7 +34,7 @@ from nanobot.session.keys import (
|
|||||||
LAST_CHANNEL_METADATA_KEY,
|
LAST_CHANNEL_METADATA_KEY,
|
||||||
UNIFIED_SESSION_KEY,
|
UNIFIED_SESSION_KEY,
|
||||||
)
|
)
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
INTERNAL_CONTINUATION_META,
|
INTERNAL_CONTINUATION_META,
|
||||||
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
INTERNAL_CONTINUATION_RUN_STARTED_AT_META,
|
||||||
@ -49,7 +49,6 @@ from nanobot.session.webui_turns import (
|
|||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
from nanobot.triggers.local_session_turns import LOCAL_TRIGGER_META
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
|
||||||
|
|
||||||
|
|
||||||
def _mk_loop() -> AgentLoop:
|
def _mk_loop() -> AgentLoop:
|
||||||
@ -314,55 +313,6 @@ async def test_generate_webui_title_ignores_cron_internal_turns(tmp_path: Path)
|
|||||||
loop.provider.chat_with_retry.assert_not_awaited()
|
loop.provider.chat_with_retry.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_webui_title_update_uses_captured_llm_runtime(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
bus = MessageBus()
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
scheduled: list[object] = []
|
|
||||||
captured: dict[str, object] = {}
|
|
||||||
|
|
||||||
async def fake_title_after_turn(**kwargs: object) -> bool:
|
|
||||||
captured.update(kwargs)
|
|
||||||
return False
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.session.webui_turns.maybe_generate_webui_title_after_turn",
|
|
||||||
fake_title_after_turn,
|
|
||||||
)
|
|
||||||
coordinator = WebuiTurnCoordinator(
|
|
||||||
bus=bus,
|
|
||||||
sessions=sessions,
|
|
||||||
schedule_background=lambda coro: scheduled.append(coro),
|
|
||||||
)
|
|
||||||
provider = MagicMock()
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="u1",
|
|
||||||
chat_id="chat1",
|
|
||||||
content="say hello",
|
|
||||||
metadata={"webui": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
coordinator.capture_title_context(
|
|
||||||
"websocket:chat1",
|
|
||||||
msg,
|
|
||||||
LLMRuntime.capture(provider, "turn-model", context_window_tokens=32_768),
|
|
||||||
)
|
|
||||||
asyncio.run(coordinator.handle_turn_end(
|
|
||||||
msg,
|
|
||||||
session_key="websocket:chat1",
|
|
||||||
latency_ms=None,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert len(scheduled) == 1
|
|
||||||
asyncio.run(scheduled[0]) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
assert captured["provider"] is provider
|
|
||||||
assert captured["model"] == "turn-model"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
def test_save_turn_keeps_multimodal_runtime_context_for_model_replay() -> None:
|
||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(key="test:runtime-only")
|
session = Session(key="test:runtime-only")
|
||||||
@ -1386,7 +1336,7 @@ async def test_stop_preserves_runtime_checkpoint_for_next_turn(tmp_path: Path) -
|
|||||||
|
|
||||||
first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress")
|
first_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="keep progress")
|
||||||
task = asyncio.create_task(loop._process_message(first_msg))
|
task = asyncio.create_task(loop._process_message(first_msg))
|
||||||
loop._active_tasks[first_msg.session_key] = [task]
|
loop._active_tasks[first_msg.session_key] = {task}
|
||||||
await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0)
|
await asyncio.wait_for(checkpoint_saved.wait(), timeout=1.0)
|
||||||
|
|
||||||
stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop")
|
stop_msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="c4", content="/stop")
|
||||||
@ -1451,7 +1401,7 @@ async def test_system_subagent_followup_is_persisted_before_prompt_assembly(tmp_
|
|||||||
|
|
||||||
runtime = loop.llm_runtime()
|
runtime = loop.llm_runtime()
|
||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
record_runtime = MagicMock(wraps=loop._runtime_events().record_turn_runtime)
|
record_runtime = MagicMock(wraps=loop.runtime_event_publisher.record_turn_runtime)
|
||||||
loop.runtime_event_publisher.record_turn_runtime = record_runtime
|
loop.runtime_event_publisher.record_turn_runtime = record_runtime
|
||||||
|
|
||||||
async def fake_run_agent_loop(initial_messages, **kwargs):
|
async def fake_run_agent_loop(initial_messages, **kwargs):
|
||||||
@ -1691,7 +1641,6 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
|
|||||||
projected = builder.build_messages(
|
projected = builder.build_messages(
|
||||||
history=history,
|
history=history,
|
||||||
current_message="subagent result",
|
current_message="subagent result",
|
||||||
current_role="user",
|
|
||||||
channel="cli",
|
channel="cli",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import pytest
|
|||||||
from agent.runner_helpers import make_run_spec
|
from agent.runner_helpers import make_run_spec
|
||||||
from nanobot.agent.context_governance import (
|
from nanobot.agent.context_governance import (
|
||||||
BACKFILL_CONTENT,
|
BACKFILL_CONTENT,
|
||||||
MICROCOMPACT_KEEP_RECENT,
|
|
||||||
ContextGovernanceConfig,
|
ContextGovernanceConfig,
|
||||||
ContextGovernor,
|
ContextGovernor,
|
||||||
)
|
)
|
||||||
@ -495,7 +494,7 @@ def test_microcompact_skips_when_prompt_under_hard_budget(monkeypatch):
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
total = 15
|
||||||
long_content = "x" * 600
|
long_content = "x" * 600
|
||||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||||
spec = make_run_spec(provider,
|
spec = make_run_spec(provider,
|
||||||
@ -529,7 +528,7 @@ def test_microcompact_overflow_compacts_to_low_watermark(monkeypatch):
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
total = 18
|
||||||
long_content = "x" * 600
|
long_content = "x" * 600
|
||||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||||
spec = make_run_spec(provider,
|
spec = make_run_spec(provider,
|
||||||
@ -617,7 +616,7 @@ def test_context_governor_keeps_compaction_boundary_stable(monkeypatch):
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
total = MICROCOMPACT_KEEP_RECENT + 8
|
total = 18
|
||||||
long_content = "x" * 600
|
long_content = "x" * 600
|
||||||
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
messages = _microcompact_messages(total=total, tool_name="read_file", content=long_content)
|
||||||
spec = make_run_spec(provider,
|
spec = make_run_spec(provider,
|
||||||
@ -658,7 +657,7 @@ def test_microcompact_preserves_short_results(monkeypatch):
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
total = 15
|
||||||
messages = _microcompact_messages(total=total, tool_name="exec", content="short")
|
messages = _microcompact_messages(total=total, tool_name="exec", content="short")
|
||||||
spec = make_run_spec(provider,
|
spec = make_run_spec(provider,
|
||||||
initial_messages=messages,
|
initial_messages=messages,
|
||||||
@ -690,7 +689,7 @@ def test_microcompact_skips_non_compactable_tools(monkeypatch):
|
|||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
|
|
||||||
total = MICROCOMPACT_KEEP_RECENT + 5
|
total = 15
|
||||||
long_content = "y" * 1000
|
long_content = "y" * 1000
|
||||||
messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
|
messages = _microcompact_messages(total=total, tool_name="message", content=long_content)
|
||||||
spec = make_run_spec(provider,
|
spec = make_run_spec(provider,
|
||||||
|
|||||||
@ -73,7 +73,7 @@ class TestHandleStop:
|
|||||||
|
|
||||||
task = asyncio.create_task(slow_task())
|
task = asyncio.create_task(slow_task())
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
loop._active_tasks["test:c1"] = [task]
|
loop._active_tasks["test:c1"] = {task}
|
||||||
|
|
||||||
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
||||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
||||||
@ -100,7 +100,7 @@ class TestHandleStop:
|
|||||||
|
|
||||||
tasks = [asyncio.create_task(slow(i)) for i in range(2)]
|
tasks = [asyncio.create_task(slow(i)) for i in range(2)]
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
loop._active_tasks["test:c1"] = tasks
|
loop._active_tasks["test:c1"] = set(tasks)
|
||||||
|
|
||||||
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
msg = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="/stop")
|
||||||
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/stop", loop=loop)
|
||||||
|
|||||||
@ -454,7 +454,7 @@ class TestStopCommandWithUnifiedSession:
|
|||||||
# Simulate the task creation flow (from _run loop)
|
# Simulate the task creation flow (from _run loop)
|
||||||
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
|
effective_key = UNIFIED_SESSION_KEY if loop._unified_session and not msg.session_key_override else msg.session_key
|
||||||
task = asyncio.create_task(loop._dispatch(msg))
|
task = asyncio.create_task(loop._dispatch(msg))
|
||||||
loop._active_tasks.setdefault(effective_key, []).append(task)
|
loop._active_tasks.setdefault(effective_key, set()).add(task)
|
||||||
|
|
||||||
# Wait for task to complete
|
# Wait for task to complete
|
||||||
await task
|
await task
|
||||||
@ -475,7 +475,7 @@ class TestStopCommandWithUnifiedSession:
|
|||||||
await asyncio.sleep(10) # Will be cancelled
|
await asyncio.sleep(10) # Will be cancelled
|
||||||
|
|
||||||
task = asyncio.create_task(long_running())
|
task = asyncio.create_task(long_running())
|
||||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
loop._active_tasks[UNIFIED_SESSION_KEY] = {task}
|
||||||
|
|
||||||
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
|
# Create a message that would have session_key=UNIFIED_SESSION_KEY after dispatch
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
@ -506,7 +506,7 @@ class TestStopCommandWithUnifiedSession:
|
|||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
task = asyncio.create_task(long_running())
|
task = asyncio.create_task(long_running())
|
||||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task]
|
loop._active_tasks[UNIFIED_SESSION_KEY] = {task}
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
channel="telegram",
|
channel="telegram",
|
||||||
chat_id="123456",
|
chat_id="123456",
|
||||||
@ -533,7 +533,7 @@ class TestStopCommandWithUnifiedSession:
|
|||||||
|
|
||||||
task1 = asyncio.create_task(long_running())
|
task1 = asyncio.create_task(long_running())
|
||||||
task2 = asyncio.create_task(long_running())
|
task2 = asyncio.create_task(long_running())
|
||||||
loop._active_tasks[UNIFIED_SESSION_KEY] = [task1, task2]
|
loop._active_tasks[UNIFIED_SESSION_KEY] = {task1, task2}
|
||||||
|
|
||||||
# /stop from discord should cancel tasks started from telegram
|
# /stop from discord should cancel tasks started from telegram
|
||||||
msg = InboundMessage(
|
msg = InboundMessage(
|
||||||
|
|||||||
@ -285,7 +285,7 @@ class TestRestartCommand:
|
|||||||
finished_task.done.return_value = True
|
finished_task.done.return_value = True
|
||||||
|
|
||||||
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
|
msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/status")
|
||||||
loop._active_tasks[msg.session_key] = [running_task, finished_task]
|
loop._active_tasks[msg.session_key] = {running_task, finished_task}
|
||||||
loop.subagents.get_running_count_by_session.return_value = 2
|
loop.subagents.get_running_count_by_session.return_value = 2
|
||||||
|
|
||||||
response = await loop._process_message(msg)
|
response = await loop._process_message(msg)
|
||||||
|
|||||||
@ -132,12 +132,11 @@ class TestEnforceRoleAlternation:
|
|||||||
assert len(msgs) == 2
|
assert len(msgs) == 2
|
||||||
|
|
||||||
def test_trailing_assistant_recovered_as_user_when_only_system_remains(self):
|
def test_trailing_assistant_recovered_as_user_when_only_system_remains(self):
|
||||||
"""Subagent result injected as assistant message must not be silently dropped.
|
"""A trailing assistant message must not be silently dropped.
|
||||||
|
|
||||||
When build_messages(current_role="assistant") produces [system, assistant],
|
An externally supplied [system, assistant] sequence would otherwise leave
|
||||||
_enforce_role_alternation would drop the assistant, leaving only [system].
|
only [system]. Most providers reject such requests, so the trailing
|
||||||
Most providers (e.g. Zhipu/GLM error 1214) reject such requests.
|
assistant should be recovered as a user message instead.
|
||||||
The trailing assistant should be recovered as a user message instead.
|
|
||||||
"""
|
"""
|
||||||
msgs = [
|
msgs = [
|
||||||
{"role": "system", "content": "You are helpful."},
|
{"role": "system", "content": "You are helpful."},
|
||||||
|
|||||||
@ -454,10 +454,12 @@ async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
|
|||||||
async def test_process_direct_accepts_media() -> None:
|
async def test_process_direct_accepts_media() -> None:
|
||||||
"""process_direct should forward media paths to _process_message."""
|
"""process_direct should forward media paths to _process_message."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
|
from nanobot.bus.runtime_events import RuntimeEventPublisher
|
||||||
|
|
||||||
loop = AgentLoop.__new__(AgentLoop)
|
loop = AgentLoop.__new__(AgentLoop)
|
||||||
loop._connect_mcp = AsyncMock()
|
loop._connect_mcp = AsyncMock()
|
||||||
loop._session_locks = {}
|
loop._session_locks = {}
|
||||||
|
loop.runtime_event_publisher = RuntimeEventPublisher()
|
||||||
|
|
||||||
captured_msg = None
|
captured_msg = None
|
||||||
|
|
||||||
|
|||||||
@ -584,7 +584,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(agent_context, "close_mcp", lambda _state: asyncio.sleep(0))
|
monkeypatch.setattr(agent_context, "close_mcp", lambda _state: asyncio.sleep(0))
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = []
|
loop._background_tasks = set()
|
||||||
loop._exec_session_manager = manager
|
loop._exec_session_manager = manager
|
||||||
loop.subagents = SimpleNamespace(close=AsyncMock())
|
loop.subagents = SimpleNamespace(close=AsyncMock())
|
||||||
|
|
||||||
@ -601,7 +601,7 @@ def test_agent_loop_shutdown_closes_exec_sessions(tmp_path, monkeypatch):
|
|||||||
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
def test_agent_loop_shutdown_attempts_all_cleanup_after_errors(monkeypatch):
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = []
|
loop._background_tasks = set()
|
||||||
loop.subagents = SimpleNamespace(
|
loop.subagents = SimpleNamespace(
|
||||||
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
||||||
)
|
)
|
||||||
@ -754,7 +754,7 @@ def test_terminate_by_owner_skips_sessions_without_owner_key(tmp_path):
|
|||||||
def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
|
def test_agent_loop_shutdown_preserves_single_cleanup_error(monkeypatch):
|
||||||
async def run() -> None:
|
async def run() -> None:
|
||||||
loop = object.__new__(AgentLoop)
|
loop = object.__new__(AgentLoop)
|
||||||
loop._background_tasks = []
|
loop._background_tasks = set()
|
||||||
loop.subagents = SimpleNamespace(
|
loop.subagents = SimpleNamespace(
|
||||||
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
close=AsyncMock(side_effect=RuntimeError("subagent cleanup failed")),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -61,26 +61,6 @@ async def test_message_tool_suppresses_delivery_when_active() -> None:
|
|||||||
assert sent[0].content == "real"
|
assert sent[0].content == "real"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_message_tool_marks_channel_delivery_only_when_enabled() -> None:
|
|
||||||
sent: list[OutboundMessage] = []
|
|
||||||
|
|
||||||
async def _send(msg: OutboundMessage) -> None:
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
tool = MessageTool(send_callback=_send)
|
|
||||||
|
|
||||||
await tool.execute(content="normal", channel="telegram", chat_id="1")
|
|
||||||
token = tool.set_record_channel_delivery(True)
|
|
||||||
try:
|
|
||||||
await tool.execute(content="cron", channel="telegram", chat_id="1")
|
|
||||||
finally:
|
|
||||||
tool.reset_record_channel_delivery(token)
|
|
||||||
|
|
||||||
assert sent[0].metadata == {}
|
|
||||||
assert sent[1].metadata == {"_record_channel_delivery": True}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_message_tool_records_media_deliveries() -> None:
|
async def test_message_tool_records_media_deliveries() -> None:
|
||||||
sent: list[OutboundMessage] = []
|
sent: list[OutboundMessage] = []
|
||||||
@ -330,60 +310,6 @@ async def test_message_tool_resolves_mixed_media_paths() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_message_tool_tracks_turn_media_for_same_target(tmp_path) -> None:
|
|
||||||
sent: list[OutboundMessage] = []
|
|
||||||
|
|
||||||
async def _send(msg: OutboundMessage) -> None:
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
tool = MessageTool(send_callback=_send)
|
|
||||||
f = tmp_path / "doc.md"
|
|
||||||
f.write_text("hello", encoding="utf-8")
|
|
||||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
|
||||||
tool.start_turn()
|
|
||||||
await tool.execute(
|
|
||||||
content="see file",
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="chat-1",
|
|
||||||
media=[str(f)],
|
|
||||||
)
|
|
||||||
assert tool.turn_delivered_media_paths() == [str(f.resolve())]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_message_tool_start_turn_clears_tracked_media(tmp_path) -> None:
|
|
||||||
async def _send(msg: OutboundMessage) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
tool = MessageTool(send_callback=_send)
|
|
||||||
f = tmp_path / "doc.md"
|
|
||||||
f.write_text("hello", encoding="utf-8")
|
|
||||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
|
||||||
tool.start_turn()
|
|
||||||
await tool.execute(content="see file", media=[str(f)])
|
|
||||||
tool.start_turn()
|
|
||||||
assert tool.turn_delivered_media_paths() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_message_tool_cross_target_does_not_track_turn_media(tmp_path) -> None:
|
|
||||||
async def _send(msg: OutboundMessage) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
tool = MessageTool(send_callback=_send)
|
|
||||||
f = tmp_path / "doc.md"
|
|
||||||
f.write_text("hello", encoding="utf-8")
|
|
||||||
with request_context(RequestContext(channel="websocket", chat_id="chat-1", metadata={})):
|
|
||||||
await tool.execute(
|
|
||||||
content="see file",
|
|
||||||
channel="telegram",
|
|
||||||
chat_id="tg-other",
|
|
||||||
media=[str(f)],
|
|
||||||
)
|
|
||||||
assert tool.turn_delivered_media_paths() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
|
async def test_message_tool_rejects_wrong_explicit_ws_chat_id(tmp_path) -> None:
|
||||||
sent: list[OutboundMessage] = []
|
sent: list[OutboundMessage] = []
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user