mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
Merge remote-tracking branch 'origin/main' into codex/unified-extension-platform
This commit is contained in:
+1
-6
@@ -186,9 +186,7 @@ Dream is configured under `agents.defaults.dream`:
|
|||||||
"defaults": {
|
"defaults": {
|
||||||
"dream": {
|
"dream": {
|
||||||
"intervalH": 2,
|
"intervalH": 2,
|
||||||
"modelOverride": null,
|
"modelOverride": null
|
||||||
"maxBatchSize": 20,
|
|
||||||
"maxIterations": 10
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,15 +198,12 @@ Dream is configured under `agents.defaults.dream`:
|
|||||||
| `intervalH` | How often Dream runs, in hours |
|
| `intervalH` | How often Dream runs, in hours |
|
||||||
| `cron` | Cron expression override (takes precedence over `intervalH`) |
|
| `cron` | Cron expression override (takes precedence over `intervalH`) |
|
||||||
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
|
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
|
||||||
| `maxBatchSize` | *(Deprecated — not used)* |
|
|
||||||
| `maxIterations` | *(Deprecated — not used)* |
|
|
||||||
|
|
||||||
In practical terms:
|
In practical terms:
|
||||||
|
|
||||||
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
|
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
|
||||||
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
|
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
|
||||||
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
|
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
|
||||||
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
|
|
||||||
|
|
||||||
## In Practice
|
## In Practice
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ContextBuilder:
|
|||||||
|
|
||||||
def build_system_prompt(
|
def build_system_prompt(
|
||||||
self,
|
self,
|
||||||
skill_names: list[str] | None = None,
|
*,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
@@ -196,14 +196,11 @@ class ContextBuilder:
|
|||||||
self,
|
self,
|
||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
current_message: str,
|
current_message: str,
|
||||||
skill_names: list[str] | None = None,
|
*,
|
||||||
media: list[str] | None = None,
|
media: list[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
chat_id: str | None = None,
|
|
||||||
current_role: str = "user",
|
current_role: str = "user",
|
||||||
sender_id: str | None = None,
|
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
session_metadata: Mapping[str, Any] | None = None,
|
|
||||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
@@ -219,7 +216,6 @@ class ContextBuilder:
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": self.build_system_prompt(
|
"content": self.build_system_prompt(
|
||||||
skill_names,
|
|
||||||
channel=channel,
|
channel=channel,
|
||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
workspace=root,
|
workspace=root,
|
||||||
|
|||||||
+43
-111
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
|
|||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -103,15 +103,7 @@ if TYPE_CHECKING:
|
|||||||
)
|
)
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
class TurnState(Enum):
|
_T = TypeVar("_T")
|
||||||
RESTORE = auto()
|
|
||||||
COMPACT = auto()
|
|
||||||
COMMAND = auto()
|
|
||||||
BUILD = auto()
|
|
||||||
RUN = auto()
|
|
||||||
SAVE = auto()
|
|
||||||
RESPOND = auto()
|
|
||||||
DONE = auto()
|
|
||||||
|
|
||||||
|
|
||||||
class TurnKind(Enum):
|
class TurnKind(Enum):
|
||||||
@@ -119,20 +111,10 @@ class TurnKind(Enum):
|
|||||||
SYSTEM = auto()
|
SYSTEM = auto()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StateTraceEntry:
|
|
||||||
state: TurnState
|
|
||||||
started_at: float
|
|
||||||
duration_ms: float
|
|
||||||
event: str
|
|
||||||
error: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TurnContext:
|
class TurnContext:
|
||||||
msg: InboundMessage
|
msg: InboundMessage
|
||||||
session_key: str
|
session_key: str
|
||||||
state: TurnState
|
|
||||||
turn_id: str
|
turn_id: str
|
||||||
runtime: LLMRuntime | None
|
runtime: LLMRuntime | None
|
||||||
kind: TurnKind
|
kind: TurnKind
|
||||||
@@ -146,7 +128,6 @@ class TurnContext:
|
|||||||
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||||
|
|
||||||
final_content: str | None = None
|
final_content: str | None = None
|
||||||
tools_used: list[str] = field(default_factory=list)
|
|
||||||
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
stop_reason: str = ""
|
stop_reason: str = ""
|
||||||
had_injections: bool = False
|
had_injections: bool = False
|
||||||
@@ -178,8 +159,6 @@ class TurnContext:
|
|||||||
visible_run_started_at: float | None = None
|
visible_run_started_at: float | None = None
|
||||||
turn_latency_ms: int | None = None
|
turn_latency_ms: int | None = None
|
||||||
|
|
||||||
trace: list[StateTraceEntry] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
"""
|
"""
|
||||||
@@ -244,19 +223,6 @@ class AgentLoop:
|
|||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
# Event-driven state transition table.
|
|
||||||
# Handlers return an event string; the driver looks up the next state here.
|
|
||||||
_TRANSITIONS: dict[tuple[TurnState, str], TurnState] = {
|
|
||||||
(TurnState.RESTORE, "ok"): TurnState.COMPACT,
|
|
||||||
(TurnState.COMPACT, "ok"): TurnState.COMMAND,
|
|
||||||
(TurnState.COMMAND, "dispatch"): TurnState.BUILD,
|
|
||||||
(TurnState.COMMAND, "shortcut"): TurnState.DONE,
|
|
||||||
(TurnState.BUILD, "ok"): TurnState.RUN,
|
|
||||||
(TurnState.RUN, "ok"): TurnState.SAVE,
|
|
||||||
(TurnState.SAVE, "ok"): TurnState.RESPOND,
|
|
||||||
(TurnState.RESPOND, "ok"): TurnState.DONE,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
@@ -712,13 +678,8 @@ 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,
|
||||||
chat_id=str(
|
|
||||||
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
|
|
||||||
),
|
|
||||||
current_role="user",
|
current_role="user",
|
||||||
sender_id=ctx.msg.sender_id,
|
|
||||||
session_summary=ctx.pending_summary,
|
session_summary=ctx.pending_summary,
|
||||||
session_metadata=ctx.session.metadata,
|
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
@@ -1379,7 +1340,6 @@ class AgentLoop:
|
|||||||
msg=msg,
|
msg=msg,
|
||||||
session=None,
|
session=None,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
state=TurnState.RESTORE,
|
|
||||||
turn_id=f"{key}:{time.time_ns()}",
|
turn_id=f"{key}:{time.time_ns()}",
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -1449,65 +1409,47 @@ class AgentLoop:
|
|||||||
ctx.on_stream = _tracked_stream
|
ctx.on_stream = _tracked_stream
|
||||||
ctx.on_stream_end = _tracked_stream_end
|
ctx.on_stream_end = _tracked_stream_end
|
||||||
|
|
||||||
while ctx.state is not TurnState.DONE:
|
await self._run_turn_stage(ctx, "restore", self._restore_turn)
|
||||||
handler_name = f"_state_{ctx.state.name.lower()}"
|
await self._run_turn_stage(ctx, "compact", self._compact_session)
|
||||||
handler = getattr(self, handler_name, None)
|
if await self._run_turn_stage(ctx, "command", self._dispatch_command):
|
||||||
if handler is None:
|
return ctx.outbound
|
||||||
raise RuntimeError(f"Missing state handler for {ctx.state}")
|
await self._run_turn_stage(ctx, "build", self._build_turn)
|
||||||
|
await self._run_turn_stage(ctx, "run", self._run_turn)
|
||||||
|
await self._run_turn_stage(ctx, "save", self._persist_turn)
|
||||||
|
await self._run_turn_stage(ctx, "respond", self._prepare_outbound)
|
||||||
|
return ctx.outbound
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
async def _run_turn_stage(
|
||||||
|
self,
|
||||||
|
ctx: TurnContext,
|
||||||
|
name: str,
|
||||||
|
handler: Callable[[TurnContext], Awaitable[_T]],
|
||||||
|
) -> _T:
|
||||||
|
started_at = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
event = await handler(ctx)
|
result = await handler(ctx)
|
||||||
except Exception:
|
except Exception:
|
||||||
duration = (time.perf_counter() - t0) * 1000
|
duration_ms = (time.perf_counter() - started_at) * 1000
|
||||||
ctx.trace.append(
|
logger.debug(
|
||||||
StateTraceEntry(
|
"[turn {}] Stage {} failed after {:.1f}ms",
|
||||||
state=ctx.state,
|
ctx.turn_id,
|
||||||
started_at=t0,
|
name,
|
||||||
duration_ms=duration,
|
duration_ms,
|
||||||
event="",
|
|
||||||
error="exception",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
duration_ms = (time.perf_counter() - started_at) * 1000
|
||||||
duration = (time.perf_counter() - t0) * 1000
|
|
||||||
ctx.trace.append(
|
|
||||||
StateTraceEntry(
|
|
||||||
state=ctx.state,
|
|
||||||
started_at=t0,
|
|
||||||
duration_ms=duration,
|
|
||||||
event=event,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[turn {}] State {} took {:.1f}ms -> event {}",
|
"[turn {}] Stage {} completed in {:.1f}ms",
|
||||||
ctx.turn_id,
|
ctx.turn_id,
|
||||||
ctx.state.name,
|
name,
|
||||||
duration,
|
duration_ms,
|
||||||
event,
|
|
||||||
)
|
)
|
||||||
|
return result
|
||||||
next_state = self._TRANSITIONS.get((ctx.state, event))
|
|
||||||
if next_state is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"[turn {ctx.turn_id}] No transition from {ctx.state} "
|
|
||||||
f"on event {event!r}"
|
|
||||||
)
|
|
||||||
ctx.state = next_state
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"[turn {}] Turn completed after {} states",
|
|
||||||
ctx.turn_id,
|
|
||||||
len(ctx.trace),
|
|
||||||
)
|
|
||||||
return ctx.outbound
|
|
||||||
|
|
||||||
def _assemble_outbound(
|
def _assemble_outbound(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
final_content: str,
|
final_content: str,
|
||||||
all_msgs: list[dict[str, Any]],
|
|
||||||
stop_reason: str,
|
stop_reason: str,
|
||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
streamed_content: bool,
|
streamed_content: bool,
|
||||||
@@ -1538,7 +1480,7 @@ class AgentLoop:
|
|||||||
metadata=meta,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _state_restore(self, ctx: TurnContext) -> TurnState:
|
async def _restore_turn(self, ctx: TurnContext) -> None:
|
||||||
"""Restore checkpoint / pending user turn; extract documents."""
|
"""Restore checkpoint / pending user turn; extract documents."""
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
@@ -1571,8 +1513,6 @@ class AgentLoop:
|
|||||||
if self._restore_pending_user_turn(ctx.session):
|
if self._restore_pending_user_turn(ctx.session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
||||||
if self._should_extract_document_text():
|
if self._should_extract_document_text():
|
||||||
return extract_documents(content, media)
|
return extract_documents(content, media)
|
||||||
@@ -1583,14 +1523,13 @@ class AgentLoop:
|
|||||||
return True
|
return True
|
||||||
return self.channels_config.extract_document_text
|
return self.channels_config.extract_document_text
|
||||||
|
|
||||||
async def _state_compact(self, ctx: TurnContext) -> str:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
||||||
ctx.pending_summary = pending
|
ctx.pending_summary = pending
|
||||||
return "ok"
|
|
||||||
|
|
||||||
async def _state_command(self, ctx: TurnContext) -> str:
|
async def _dispatch_command(self, ctx: TurnContext) -> bool:
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
return "dispatch"
|
return False
|
||||||
raw = ctx.msg.content.strip()
|
raw = ctx.msg.content.strip()
|
||||||
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||||
is_user_turn = (
|
is_user_turn = (
|
||||||
@@ -1626,10 +1565,10 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
return "shortcut"
|
return True
|
||||||
return "dispatch"
|
return False
|
||||||
|
|
||||||
async def _state_build(self, ctx: TurnContext) -> str:
|
async def _build_turn(self, ctx: TurnContext) -> None:
|
||||||
runtime = ctx.runtime
|
runtime = ctx.runtime
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
runtime = self.runtime_for_session(ctx.session)
|
runtime = self.runtime_for_session(ctx.session)
|
||||||
@@ -1685,9 +1624,7 @@ class AgentLoop:
|
|||||||
if ctx.on_retry_wait is None:
|
if ctx.on_retry_wait is None:
|
||||||
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
||||||
|
|
||||||
return "ok"
|
async def _run_turn(self, ctx: TurnContext) -> None:
|
||||||
|
|
||||||
async def _state_run(self, ctx: TurnContext) -> str:
|
|
||||||
if ctx.visible_run_started_at is None:
|
if ctx.visible_run_started_at is None:
|
||||||
ctx.visible_run_started_at = time.time()
|
ctx.visible_run_started_at = time.time()
|
||||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||||
@@ -1714,17 +1651,15 @@ class AgentLoop:
|
|||||||
tools=ctx.tools,
|
tools=ctx.tools,
|
||||||
request_context=ctx.request_context,
|
request_context=ctx.request_context,
|
||||||
)
|
)
|
||||||
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
final_content, _, all_msgs, stop_reason, had_injections = result
|
||||||
ctx.final_content = final_content
|
ctx.final_content = final_content
|
||||||
ctx.tools_used = tools_used
|
|
||||||
ctx.all_messages = all_msgs
|
ctx.all_messages = all_msgs
|
||||||
ctx.stop_reason = stop_reason
|
ctx.stop_reason = stop_reason
|
||||||
ctx.had_injections = had_injections
|
ctx.had_injections = had_injections
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
await turn_continuation.maybe_continue_turn(ctx)
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
return "ok"
|
|
||||||
|
|
||||||
async def _state_save(self, ctx: TurnContext) -> str:
|
async def _persist_turn(self, ctx: TurnContext) -> None:
|
||||||
turn_continuation.prepare_save_boundary(ctx)
|
turn_continuation.prepare_save_boundary(ctx)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1765,12 +1700,11 @@ class AgentLoop:
|
|||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
self._clear_runtime_checkpoint(ctx.session)
|
self._clear_runtime_checkpoint(ctx.session)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
return "ok"
|
|
||||||
|
|
||||||
async def _state_respond(self, ctx: TurnContext) -> str:
|
async def _prepare_outbound(self, ctx: TurnContext) -> None:
|
||||||
if ctx.suppress_response:
|
if ctx.suppress_response:
|
||||||
ctx.outbound = None
|
ctx.outbound = None
|
||||||
return "ok"
|
return
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
ctx.outbound = ctx.delivery.background_response(
|
ctx.outbound = ctx.delivery.background_response(
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
@@ -1778,11 +1712,10 @@ class AgentLoop:
|
|||||||
streamed=ctx.streamed_content,
|
streamed=ctx.streamed_content,
|
||||||
latency_ms=ctx.turn_latency_ms,
|
latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
return "ok"
|
return
|
||||||
ctx.outbound = self._assemble_outbound(
|
ctx.outbound = self._assemble_outbound(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
ctx.all_messages,
|
|
||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.streamed_content,
|
ctx.streamed_content,
|
||||||
@@ -1790,7 +1723,6 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if ctx.ephemeral and ctx.outbound is not None:
|
if ctx.ephemeral and ctx.outbound is not None:
|
||||||
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
|
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
|
||||||
return "ok"
|
|
||||||
|
|
||||||
def _sanitize_persisted_blocks(
|
def _sanitize_persisted_blocks(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -912,7 +912,7 @@ class Consolidator:
|
|||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||||
history = self._full_unconsolidated_history(session)
|
history = self._full_unconsolidated_history(session)
|
||||||
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None))
|
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
||||||
# Include archived summary in estimation so the budget accounts for it.
|
# Include archived summary in estimation so the budget accounts for it.
|
||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
|
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
|
||||||
@@ -920,10 +920,7 @@ class Consolidator:
|
|||||||
history=history,
|
history=history,
|
||||||
current_message="[token-probe]",
|
current_message="[token-probe]",
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
|
||||||
sender_id=None,
|
|
||||||
session_summary=summary,
|
session_summary=summary,
|
||||||
session_metadata=session.metadata,
|
|
||||||
session_key=session.key,
|
session_key=session.key,
|
||||||
unified_session=self.unified_session,
|
unified_session=self.unified_session,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1327,7 +1327,7 @@ class AgentRunner:
|
|||||||
return payload, event, exc
|
return payload, event, exc
|
||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if is_tool_error_result(tool_call.name, result):
|
if is_tool_error_result(result):
|
||||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
|
|||||||
@@ -39,12 +39,6 @@ def _validate_patch_path(path: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def _lines_to_text(lines: list[str]) -> str:
|
|
||||||
if not lines:
|
|
||||||
return ""
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _text_line_count(text: str) -> int:
|
def _text_line_count(text: str) -> int:
|
||||||
if not text:
|
if not text:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
|||||||
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
|
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
|
||||||
"Not used for action='list' or action='remove'."
|
"Not used for action='list' or action='remove'."
|
||||||
),
|
),
|
||||||
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
|
every_seconds=IntegerSchema(description="Interval in seconds (for recurring tasks)"),
|
||||||
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
|
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
|
||||||
tz=StringSchema(
|
tz=StringSchema(
|
||||||
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
|
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
|
||||||
@@ -138,8 +138,6 @@ class CronTool(Tool):
|
|||||||
tz: str | None = None,
|
tz: str | None = None,
|
||||||
at: str | None = None,
|
at: str | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
deliver: bool = True,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
if action == "add":
|
if action == "add":
|
||||||
if self._in_cron_context.get():
|
if self._in_cron_context.get():
|
||||||
|
|||||||
@@ -447,7 +447,6 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
default=False,
|
default=False,
|
||||||
),
|
),
|
||||||
yield_time_ms=IntegerSchema(
|
yield_time_ms=IntegerSchema(
|
||||||
DEFAULT_YIELD_MS,
|
|
||||||
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=MAX_YIELD_MS,
|
maximum=MAX_YIELD_MS,
|
||||||
@@ -458,20 +457,17 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
wait_timeout_ms=IntegerSchema(
|
wait_timeout_ms=IntegerSchema(
|
||||||
DEFAULT_WAIT_FOR_MS,
|
|
||||||
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=MAX_WAIT_FOR_MS,
|
maximum=MAX_WAIT_FOR_MS,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
max_output_chars=IntegerSchema(
|
max_output_chars=IntegerSchema(
|
||||||
DEFAULT_MAX_OUTPUT_CHARS,
|
|
||||||
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
||||||
minimum=1000,
|
minimum=1000,
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
),
|
),
|
||||||
max_output_tokens=IntegerSchema(
|
max_output_tokens=IntegerSchema(
|
||||||
DEFAULT_MAX_OUTPUT_CHARS,
|
|
||||||
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
||||||
minimum=1000,
|
minimum=1000,
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
|||||||
@@ -226,12 +226,10 @@ def _builtin_skill_read_path(path: str) -> Path | None:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to read"),
|
path=StringSchema("The file path to read"),
|
||||||
offset=IntegerSchema(
|
offset=IntegerSchema(
|
||||||
1,
|
|
||||||
description="Line number to start reading from (1-indexed, default 1)",
|
description="Line number to start reading from (1-indexed, default 1)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
limit=IntegerSchema(
|
limit=IntegerSchema(
|
||||||
2000,
|
|
||||||
description="Maximum number of lines to read (default 2000)",
|
description="Maximum number of lines to read (default 2000)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
@@ -790,13 +788,11 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
new_text=StringSchema("The text to replace with"),
|
new_text=StringSchema("The text to replace with"),
|
||||||
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
||||||
occurrence=IntegerSchema(
|
occurrence=IntegerSchema(
|
||||||
1,
|
|
||||||
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
line_hint=IntegerSchema(
|
line_hint=IntegerSchema(
|
||||||
1,
|
|
||||||
description=(
|
description=(
|
||||||
"Optional exact 1-based target line copied from read_file. "
|
"Optional exact 1-based target line copied from read_file. "
|
||||||
"The selected old_text match must cover this line."
|
"The selected old_text match must cover this line."
|
||||||
@@ -805,7 +801,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
expected_replacements=IntegerSchema(
|
expected_replacements=IntegerSchema(
|
||||||
1,
|
|
||||||
description="Optional guard for the number of replacements that must be made.",
|
description="Optional guard for the number of replacements that must be made.",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -1036,7 +1031,6 @@ class EditFileTool(_FsTool):
|
|||||||
path=StringSchema("The directory path to list"),
|
path=StringSchema("The directory path to list"),
|
||||||
recursive=BooleanSchema(description="Recursively list all files (default false)"),
|
recursive=BooleanSchema(description="Recursively list all files (default false)"),
|
||||||
max_entries=IntegerSchema(
|
max_entries=IntegerSchema(
|
||||||
200,
|
|
||||||
description="Maximum entries to return (default 200)",
|
description="Maximum entries to return (default 200)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1293,7 +1293,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
|
|
||||||
tools_removed = 0
|
tools_removed = 0
|
||||||
for name in [*removed, *changed]:
|
for name in [*removed, *changed]:
|
||||||
tools_removed += _unregister_server_tools(state, registry, name)
|
tools_removed += _unregister_server_tools(registry, name)
|
||||||
await _close_server(state, name)
|
await _close_server(state, name)
|
||||||
|
|
||||||
state._mcp_servers = next_servers
|
state._mcp_servers = next_servers
|
||||||
@@ -1467,7 +1467,7 @@ async def _refresh_terminated_server(
|
|||||||
return current_tool
|
return current_tool
|
||||||
|
|
||||||
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
||||||
_unregister_server_tools(state, registry, server_name)
|
_unregister_server_tools(registry, server_name)
|
||||||
await _close_server(state, server_name)
|
await _close_server(state, server_name)
|
||||||
|
|
||||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||||
@@ -1499,7 +1499,7 @@ def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str)
|
|||||||
return tool_name.startswith(_tool_prefix(server_name))
|
return tool_name.startswith(_tool_prefix(server_name))
|
||||||
|
|
||||||
|
|
||||||
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
|
||||||
removed = 0
|
removed = 0
|
||||||
for tool_name in list(registry.tool_names):
|
for tool_name in list(registry.tool_names):
|
||||||
tool = registry.get(tool_name)
|
tool = registry.get(tool_name)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.runtime_context import RuntimeContextProvider
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
|
|
||||||
def is_tool_error_result(name: str, result: Any) -> bool:
|
def is_tool_error_result(result: Any) -> bool:
|
||||||
return isinstance(result, ToolResult) and result.is_error
|
return isinstance(result, ToolResult) and result.is_error
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +215,7 @@ class ToolRegistry:
|
|||||||
try:
|
try:
|
||||||
assert tool is not None # guarded by prepare_call()
|
assert tool is not None # guarded by prepare_call()
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
if is_tool_error_result(name, result):
|
if is_tool_error_result(result):
|
||||||
return ToolResult.error(str(result) + hint)
|
return ToolResult.error(str(result) + hint)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -52,11 +52,10 @@ class StringSchema(Schema):
|
|||||||
|
|
||||||
|
|
||||||
class IntegerSchema(Schema):
|
class IntegerSchema(Schema):
|
||||||
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds."""
|
"""Integer parameter with a description and optional bounds."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
value: int = 0,
|
|
||||||
*,
|
*,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
minimum: int | None = None,
|
minimum: int | None = None,
|
||||||
@@ -64,7 +63,6 @@ class IntegerSchema(Schema):
|
|||||||
enum: tuple[int, ...] | list[int] | None = None,
|
enum: tuple[int, ...] | list[int] | None = None,
|
||||||
nullable: bool = False,
|
nullable: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._value = value
|
|
||||||
self._description = description
|
self._description = description
|
||||||
self._minimum = minimum
|
self._minimum = minimum
|
||||||
self._maximum = maximum
|
self._maximum = maximum
|
||||||
@@ -92,7 +90,6 @@ class NumberSchema(Schema):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
value: float = 0.0,
|
|
||||||
*,
|
*,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
minimum: float | None = None,
|
minimum: float | None = None,
|
||||||
@@ -100,7 +97,6 @@ class NumberSchema(Schema):
|
|||||||
enum: tuple[float, ...] | list[float] | None = None,
|
enum: tuple[float, ...] | list[float] | None = None,
|
||||||
nullable: bool = False,
|
nullable: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._value = value
|
|
||||||
self._description = description
|
self._description = description
|
||||||
self._minimum = minimum
|
self._minimum = minimum
|
||||||
self._maximum = maximum
|
self._maximum = maximum
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ class _PreparedCommand:
|
|||||||
working_dir=StringSchema("Optional working directory for the command"),
|
working_dir=StringSchema("Optional working directory for the command"),
|
||||||
workdir=StringSchema("Compatibility alias for working_dir"),
|
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||||
timeout=IntegerSchema(
|
timeout=IntegerSchema(
|
||||||
60,
|
|
||||||
description=(
|
description=(
|
||||||
"Timeout in seconds. Increase for long-running commands "
|
"Timeout in seconds. Increase for long-running commands "
|
||||||
"like compilation or installation (default 60, max 600)."
|
"like compilation or installation (default 60, max 600)."
|
||||||
|
|||||||
@@ -271,13 +271,12 @@ def _normalize_volcengine_auth_level(value: Any) -> int | None:
|
|||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
query=StringSchema("Search query"),
|
query=StringSchema("Search query"),
|
||||||
count=IntegerSchema(1, description="Results (1-10)", minimum=1, maximum=10),
|
count=IntegerSchema(description="Results (1-10)", minimum=1, maximum=10),
|
||||||
timeRange=StringSchema(
|
timeRange=StringSchema(
|
||||||
"Optional time filter for providers that support it: "
|
"Optional time filter for providers that support it: "
|
||||||
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
|
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
|
||||||
),
|
),
|
||||||
authLevel=IntegerSchema(
|
authLevel=IntegerSchema(
|
||||||
0,
|
|
||||||
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
|
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=1,
|
maximum=1,
|
||||||
@@ -939,7 +938,7 @@ class WebSearchTool(Tool):
|
|||||||
"enum": ["markdown", "text"],
|
"enum": ["markdown", "text"],
|
||||||
"default": "markdown",
|
"default": "markdown",
|
||||||
},
|
},
|
||||||
maxChars=IntegerSchema(0, minimum=100),
|
maxChars=IntegerSchema(minimum=100),
|
||||||
required=["url"],
|
required=["url"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
from functools import cache
|
|
||||||
from importlib.metadata import entry_points
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -19,22 +17,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def _warn_legacy_channel_entry_points() -> None:
|
|
||||||
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
|
|
||||||
# migration window for installed legacy channel entry points.
|
|
||||||
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
|
|
||||||
if not names:
|
|
||||||
return
|
|
||||||
logger.warning(
|
|
||||||
"Legacy channel entry points were detected but will not be loaded: {}. "
|
|
||||||
"The '{}' entry-point group is no longer supported; use a built-in channel or "
|
|
||||||
"migrate it into nanobot/channels/<channel>/.",
|
|
||||||
", ".join(names),
|
|
||||||
"nanobot.channels",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _channel_package_names() -> list[str]:
|
def _channel_package_names() -> list[str]:
|
||||||
import nanobot.channels as package
|
import nanobot.channels as package
|
||||||
|
|
||||||
@@ -49,7 +31,6 @@ def discover_plugins(
|
|||||||
enabled_names: set[str] | None = None,
|
enabled_names: set[str] | None = None,
|
||||||
) -> dict[str, ChannelPlugin]:
|
) -> dict[str, ChannelPlugin]:
|
||||||
"""Load dependency-free descriptors from self-contained channel packages."""
|
"""Load dependency-free descriptors from self-contained channel packages."""
|
||||||
_warn_legacy_channel_entry_points()
|
|
||||||
plugins: dict[str, ChannelPlugin] = {}
|
plugins: dict[str, ChannelPlugin] = {}
|
||||||
for name in _channel_package_names():
|
for name in _channel_package_names():
|
||||||
if enabled_names is not None and name not in enabled_names:
|
if enabled_names is not None and name not in enabled_names:
|
||||||
|
|||||||
@@ -816,7 +816,6 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
_warn_deprecated_config_keys(config_path)
|
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return loaded
|
return loaded
|
||||||
@@ -837,24 +836,6 @@ def _read_trigger_cli_message(message: str | None) -> str:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
|
||||||
"""Hint users to remove obsolete keys from their config file."""
|
|
||||||
import json
|
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path
|
|
||||||
|
|
||||||
path = config_path or get_config_path()
|
|
||||||
try:
|
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
|
|
||||||
console.print(
|
|
||||||
"[dim]Hint: `memoryWindow` in your config is no longer used "
|
|
||||||
"and can be safely removed.[/dim]"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_inspection_config(
|
def _load_inspection_config(
|
||||||
config: str | None = None,
|
config: str | None = None,
|
||||||
workspace: str | None = None,
|
workspace: str | None = None,
|
||||||
@@ -874,7 +855,6 @@ def _load_inspection_config(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
_warn_deprecated_config_keys(display_path)
|
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return display_path, loaded
|
return display_path, loaded
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
from loguru import logger
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
@@ -200,23 +199,6 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
def _migrate_config(data: dict) -> dict:
|
||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
agents = data.get("agents", {})
|
|
||||||
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
|
||||||
if isinstance(defaults, dict):
|
|
||||||
had_legacy_max_messages = (
|
|
||||||
"maxMessages" in defaults or "max_messages" in defaults
|
|
||||||
)
|
|
||||||
defaults.pop("maxMessages", None)
|
|
||||||
defaults.pop("max_messages", None)
|
|
||||||
if had_legacy_max_messages:
|
|
||||||
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
|
|
||||||
# final release that warns before the schema silently ignores the field.
|
|
||||||
logger.warning(
|
|
||||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
|
||||||
"replay max messages is now an internal safety cap. Remove it from "
|
|
||||||
"config. This compatibility warning will be removed in the next version."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
|
|||||||
@@ -64,9 +64,6 @@ class DreamConfig(Base):
|
|||||||
default=None,
|
default=None,
|
||||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||||
) # Override model for Dream sessions (pending implementation)
|
) # Override model for Dream sessions (pending implementation)
|
||||||
max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used
|
|
||||||
max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used
|
|
||||||
annotate_line_ages: bool = True # Deprecated: no longer used
|
|
||||||
|
|
||||||
def build_schedule(self, timezone: str) -> CronSchedule:
|
def build_schedule(self, timezone: str) -> CronSchedule:
|
||||||
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
||||||
|
|||||||
+19
-66
@@ -5,7 +5,6 @@ import errno
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -477,6 +476,14 @@ class SessionManager:
|
|||||||
except _SESSION_DATA_ERRORS:
|
except _SESSION_DATA_ERRORS:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _session_key_from_path(cls, path: Path) -> str | None:
|
||||||
|
"""Decode a session key only from a canonical collision-resistant filename."""
|
||||||
|
key = cls._decode_storage_key(path.stem)
|
||||||
|
if key is None or cls._storage_key(key) != path.stem:
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the collision-resistant workspace path for a session."""
|
||||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
||||||
@@ -489,61 +496,6 @@ class SessionManager:
|
|||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _stored_key_for_path(path: Path) -> str | None:
|
|
||||||
"""Read the stored session key from a JSONL metadata row, if present."""
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
data = json.loads(line)
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("session records must be JSON objects")
|
|
||||||
if data.get("_type") == "metadata":
|
|
||||||
stored_key = data.get("key")
|
|
||||||
return stored_key if isinstance(stored_key, str) else None
|
|
||||||
return None
|
|
||||||
except _SESSION_DATA_ERRORS:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None:
|
|
||||||
"""Resolve a session path, falling back to legacy storage locations."""
|
|
||||||
path = self._get_session_path(key)
|
|
||||||
if path.exists():
|
|
||||||
return path
|
|
||||||
|
|
||||||
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
|
|
||||||
# compatibility window for reading and lazily migrating legacy session files.
|
|
||||||
fallback_paths = [
|
|
||||||
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
|
||||||
(self._get_legacy_session_path(key), "legacy path"),
|
|
||||||
]
|
|
||||||
for fallback_path, description in fallback_paths:
|
|
||||||
if not fallback_path.exists():
|
|
||||||
continue
|
|
||||||
stored_key = self._stored_key_for_path(fallback_path)
|
|
||||||
if stored_key and stored_key != key:
|
|
||||||
logger.info(
|
|
||||||
"Skipping session {} from {} because it belongs to {}",
|
|
||||||
key,
|
|
||||||
description,
|
|
||||||
stored_key,
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not migrate:
|
|
||||||
return fallback_path
|
|
||||||
try:
|
|
||||||
shutil.move(str(fallback_path), str(path))
|
|
||||||
logger.info("Migrated session {} from {}", key, description)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to migrate session {}", key)
|
|
||||||
return None
|
|
||||||
return path
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
Get an existing session or create a new one.
|
Get an existing session or create a new one.
|
||||||
@@ -567,8 +519,8 @@ class SessionManager:
|
|||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
"""Load a session from disk."""
|
"""Load a session from disk."""
|
||||||
path = self._resolve_session_path(key, migrate=True)
|
path = self._get_session_path(key)
|
||||||
if path is None:
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -847,8 +799,8 @@ class SessionManager:
|
|||||||
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
||||||
``None`` when the session file does not exist or fails to parse.
|
``None`` when the session file does not exist or fails to parse.
|
||||||
"""
|
"""
|
||||||
path = self._resolve_session_path(key)
|
path = self._get_session_path(key)
|
||||||
if path is None:
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
messages: list[dict[str, Any]] = []
|
messages: list[dict[str, Any]] = []
|
||||||
@@ -890,8 +842,8 @@ class SessionManager:
|
|||||||
This is used by WebUI routes that need session-level metadata but not the
|
This is used by WebUI routes that need session-level metadata but not the
|
||||||
full conversation transcript.
|
full conversation transcript.
|
||||||
"""
|
"""
|
||||||
path = self._resolve_session_path(key)
|
path = self._get_session_path(key)
|
||||||
if path is None:
|
if not path.exists():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -935,8 +887,9 @@ class SessionManager:
|
|||||||
sessions = []
|
sessions = []
|
||||||
|
|
||||||
for path in self.sessions_dir.glob("*.jsonl"):
|
for path in self.sessions_dir.glob("*.jsonl"):
|
||||||
decoded = self._decode_storage_key(path.stem)
|
storage_key = self._session_key_from_path(path)
|
||||||
fallback_key = decoded or path.stem.replace("_", ":", 1)
|
if storage_key is None:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
# Read the metadata line and a small preview for session lists.
|
# Read the metadata line and a small preview for session lists.
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -946,7 +899,7 @@ class SessionManager:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("session records must be JSON objects")
|
raise ValueError("session records must be JSON objects")
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or fallback_key
|
key = data.get("key") or storage_key
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
title = _metadata_title(metadata)
|
title = _metadata_title(metadata)
|
||||||
preview = ""
|
preview = ""
|
||||||
@@ -991,7 +944,7 @@ class SessionManager:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
except _SESSION_DATA_ERRORS:
|
except _SESSION_DATA_ERRORS:
|
||||||
repaired = self._repair(fallback_key, path=path)
|
repaired = self._repair(storage_key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
sessions.append(
|
sessions.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
|
|||||||
for row in existing_rows or []
|
for row in existing_rows or []
|
||||||
if isinstance(row.get("file"), str)
|
if isinstance(row.get("file"), str)
|
||||||
}
|
}
|
||||||
paths = sorted(session_manager.sessions_dir.glob("*.jsonl"))
|
paths = sorted(
|
||||||
|
path
|
||||||
|
for path in session_manager.sessions_dir.glob("*.jsonl")
|
||||||
|
if SessionManager._session_key_from_path(path) is not None
|
||||||
|
)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
changed = existing_rows is None
|
changed = existing_rows is None
|
||||||
|
|
||||||
@@ -268,8 +272,9 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||||
storage_key = SessionManager._decode_storage_key(path.stem)
|
storage_key = SessionManager._session_key_from_path(path)
|
||||||
fallback_key = storage_key or path.stem.replace("_", ":", 1)
|
if storage_key is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
@@ -320,7 +325,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
|
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
|
||||||
created_at_s = created_at_s or fallback_time
|
created_at_s = created_at_s or fallback_time
|
||||||
updated_at_s = updated_at_s or fallback_time
|
updated_at_s = updated_at_s or fallback_time
|
||||||
key = data.get("key") or fallback_key
|
key = data.get("key") or storage_key
|
||||||
activity_signature = _webui_activity_signature(key)
|
activity_signature = _webui_activity_signature(key)
|
||||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||||
return {
|
return {
|
||||||
@@ -340,7 +345,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
**activity_signature,
|
**activity_signature,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
repaired = session_manager._repair(fallback_key)
|
repaired = session_manager._repair(storage_key)
|
||||||
if repaired is None:
|
if repaired is None:
|
||||||
return None
|
return None
|
||||||
return _indexed_row_for_session(repaired, path)
|
return _indexed_row_for_session(repaired, path)
|
||||||
|
|||||||
@@ -353,6 +353,14 @@ class TestBuildSystemPrompt:
|
|||||||
|
|
||||||
|
|
||||||
class TestBuildMessages:
|
class TestBuildMessages:
|
||||||
|
def test_optional_arguments_are_keyword_only(self, tmp_path):
|
||||||
|
builder = _builder(tmp_path)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
builder.build_system_prompt(["legacy-skill"])
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
builder.build_messages([], "hello", ["legacy-skill"])
|
||||||
|
|
||||||
def test_basic_empty_history(self, tmp_path):
|
def test_basic_empty_history(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages([], "hello")
|
messages = builder.build_messages([], "hello")
|
||||||
@@ -363,7 +371,7 @@ class TestBuildMessages:
|
|||||||
|
|
||||||
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", chat_id="direct")
|
messages = builder.build_messages([], "hello", channel="cli")
|
||||||
user_msg = str(messages[-1]["content"])
|
user_msg = str(messages[-1]["content"])
|
||||||
assert user_msg == "hello"
|
assert user_msg == "hello"
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
|
|||||||
history=[],
|
history=[],
|
||||||
current_message="hello world",
|
current_message="hello world",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
|
||||||
runtime_context_blocks=[
|
runtime_context_blocks=[
|
||||||
RuntimeContextBlock(source="test", content="provider context"),
|
RuntimeContextBlock(source="test", content="provider context"),
|
||||||
],
|
],
|
||||||
@@ -322,7 +321,7 @@ def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
|
|||||||
|
|
||||||
messages = builder.build_messages(
|
messages = builder.build_messages(
|
||||||
history=[], current_message="hi",
|
history=[], current_message="hi",
|
||||||
channel="telegram", chat_id="123",
|
channel="telegram",
|
||||||
)
|
)
|
||||||
system = messages[0]["content"]
|
system = messages[0]["content"]
|
||||||
assert "Format Hint" in system
|
assert "Format Hint" in system
|
||||||
@@ -349,7 +348,6 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
|
|||||||
history=[{"role": "assistant", "content": "previous result"}],
|
history=[{"role": "assistant", "content": "previous result"}],
|
||||||
current_message="subagent result",
|
current_message="subagent result",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
|
||||||
current_role="assistant",
|
current_role="assistant",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind, TurnState
|
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ChannelsConfig
|
from nanobot.config.schema import ChannelsConfig
|
||||||
@@ -27,7 +27,7 @@ def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) ->
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_state_restore_extracts_documents_by_default(
|
async def test_restore_turn_extracts_documents_by_default(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -52,14 +52,13 @@ async def test_state_restore_extracts_documents_by_default(
|
|||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
session_key="cli:c",
|
session_key="cli:c",
|
||||||
state=TurnState.RESTORE,
|
|
||||||
turn_id="turn-1",
|
turn_id="turn-1",
|
||||||
runtime=loop.llm_runtime(),
|
runtime=loop.llm_runtime(),
|
||||||
kind=TurnKind.USER,
|
kind=TurnKind.USER,
|
||||||
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await loop._state_restore(ctx) == "ok"
|
await loop._restore_turn(ctx)
|
||||||
|
|
||||||
assert calls == [("summarize", [str(doc_path)])]
|
assert calls == [("summarize", [str(doc_path)])]
|
||||||
assert "Quarterly revenue" in ctx.msg.content
|
assert "Quarterly revenue" in ctx.msg.content
|
||||||
@@ -67,7 +66,7 @@ async def test_state_restore_extracts_documents_by_default(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_state_restore_references_documents_when_extraction_disabled(
|
async def test_restore_turn_references_documents_when_extraction_disabled(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -90,14 +89,13 @@ async def test_state_restore_references_documents_when_extraction_disabled(
|
|||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
session_key="cli:c",
|
session_key="cli:c",
|
||||||
state=TurnState.RESTORE,
|
|
||||||
turn_id="turn-1",
|
turn_id="turn-1",
|
||||||
runtime=loop.llm_runtime(),
|
runtime=loop.llm_runtime(),
|
||||||
kind=TurnKind.USER,
|
kind=TurnKind.USER,
|
||||||
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await loop._state_restore(ctx) == "ok"
|
await loop._restore_turn(ctx)
|
||||||
|
|
||||||
assert "Quarterly revenue" not in ctx.msg.content
|
assert "Quarterly revenue" not in ctx.msg.content
|
||||||
assert f"[Attachment: {doc_path}]" in ctx.msg.content
|
assert f"[Attachment: {doc_path}]" in ctx.msg.content
|
||||||
|
|||||||
@@ -414,13 +414,13 @@ class TestEphemeralDirect:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
original_save = loop._state_save
|
original_save = loop._persist_turn
|
||||||
|
|
||||||
async def patched_save(ctx):
|
async def patched_save(ctx):
|
||||||
captured["ephemeral"] = ctx.ephemeral
|
captured["ephemeral"] = ctx.ephemeral
|
||||||
return await original_save(ctx)
|
return await original_save(ctx)
|
||||||
|
|
||||||
with patch.object(loop, "_state_save", side_effect=patched_save):
|
with patch.object(loop, "_persist_turn", side_effect=patched_save):
|
||||||
await loop.process_direct(
|
await loop.process_direct(
|
||||||
"test", session_key="dream:check", ephemeral=True,
|
"test", session_key="dream:check", ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -435,13 +435,13 @@ class TestEphemeralDirect:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
original_save = loop._state_save
|
original_save = loop._persist_turn
|
||||||
|
|
||||||
async def patched_save(ctx):
|
async def patched_save(ctx):
|
||||||
captured["ephemeral"] = ctx.ephemeral
|
captured["ephemeral"] = ctx.ephemeral
|
||||||
return await original_save(ctx)
|
return await original_save(ctx)
|
||||||
|
|
||||||
with patch.object(loop, "_state_save", side_effect=patched_save):
|
with patch.object(loop, "_persist_turn", side_effect=patched_save):
|
||||||
await loop.process_direct("test", session_key="cli:normal")
|
await loop.process_direct("test", session_key="cli:normal")
|
||||||
|
|
||||||
assert captured.get("ephemeral") is False
|
assert captured.get("ephemeral") is False
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.loop import AgentLoop, TurnState
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@@ -451,7 +451,6 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
|
|||||||
[],
|
[],
|
||||||
user_text,
|
user_text,
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
|
||||||
)
|
)
|
||||||
assert "_meta" not in messages[-1]
|
assert "_meta" not in messages[-1]
|
||||||
|
|
||||||
@@ -476,7 +475,6 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
|
|||||||
user_text,
|
user_text,
|
||||||
media=[str(image)],
|
media=[str(image)],
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="direct",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loop._save_turn(session, messages, skip=1)
|
loop._save_turn(session, messages, skip=1)
|
||||||
@@ -1101,7 +1099,7 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
|
async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
||||||
@@ -1135,12 +1133,11 @@ async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path:
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.chat_id == "thread-777"
|
assert result.chat_id == "thread-777"
|
||||||
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
|
||||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
async def test_process_message_uses_explicit_session_for_goal_context(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
@@ -1185,10 +1182,10 @@ async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.content == "ok"
|
assert result.content == "ok"
|
||||||
kwargs = loop.context.build_messages.call_args.kwargs
|
kwargs = loop._run_agent_loop.call_args.kwargs
|
||||||
assert kwargs["chat_id"] == "chat-with-goal"
|
assert kwargs["session"] is system_session
|
||||||
assert kwargs["session_metadata"] is system_session.metadata
|
assert kwargs["session_key"] == "system"
|
||||||
assert GOAL_STATE_KEY not in kwargs["session_metadata"]
|
assert GOAL_STATE_KEY not in kwargs["session"].metadata
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1570,27 +1567,26 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path: Path) -> None:
|
async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
||||||
return_value=False
|
return_value=False
|
||||||
)
|
)
|
||||||
visited: list[TurnState] = []
|
visited: list[str] = []
|
||||||
|
|
||||||
for state in (
|
for name in (
|
||||||
TurnState.RESTORE,
|
"_restore_turn",
|
||||||
TurnState.COMPACT,
|
"_compact_session",
|
||||||
TurnState.COMMAND,
|
"_dispatch_command",
|
||||||
TurnState.BUILD,
|
"_build_turn",
|
||||||
TurnState.RUN,
|
"_run_turn",
|
||||||
TurnState.SAVE,
|
"_persist_turn",
|
||||||
TurnState.RESPOND,
|
"_prepare_outbound",
|
||||||
):
|
):
|
||||||
name = f"_state_{state.name.lower()}"
|
|
||||||
original = getattr(loop, name)
|
original = getattr(loop, name)
|
||||||
|
|
||||||
async def record(ctx, *, _original=original, _state=state):
|
async def record(ctx, *, _original=original, _name=name):
|
||||||
visited.append(_state)
|
visited.append(_name)
|
||||||
return await _original(ctx)
|
return await _original(ctx)
|
||||||
|
|
||||||
setattr(loop, name, record)
|
setattr(loop, name, record)
|
||||||
@@ -1606,6 +1602,9 @@ async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path:
|
|||||||
|
|
||||||
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||||
|
|
||||||
|
logs: list[str] = []
|
||||||
|
sink_id = logger.add(logs.append, level="DEBUG", format="{message}")
|
||||||
|
try:
|
||||||
await loop._process_message(
|
await loop._process_message(
|
||||||
InboundMessage(
|
InboundMessage(
|
||||||
channel="system",
|
channel="system",
|
||||||
@@ -1615,16 +1614,21 @@ async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path:
|
|||||||
metadata={"subagent_task_id": "sub-1"},
|
metadata={"subagent_task_id": "sub-1"},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
logger.remove(sink_id)
|
||||||
|
|
||||||
assert visited == [
|
assert visited == [
|
||||||
TurnState.RESTORE,
|
"_restore_turn",
|
||||||
TurnState.COMPACT,
|
"_compact_session",
|
||||||
TurnState.COMMAND,
|
"_dispatch_command",
|
||||||
TurnState.BUILD,
|
"_build_turn",
|
||||||
TurnState.RUN,
|
"_run_turn",
|
||||||
TurnState.SAVE,
|
"_persist_turn",
|
||||||
TurnState.RESPOND,
|
"_prepare_outbound",
|
||||||
]
|
]
|
||||||
|
logged = "".join(logs)
|
||||||
|
for stage in ("restore", "compact", "command", "build", "run", "save", "respond"):
|
||||||
|
assert f"Stage {stage} completed in" in logged
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1689,7 +1693,6 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
|
|||||||
current_message="subagent result",
|
current_message="subagent result",
|
||||||
current_role="user",
|
current_role="user",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
chat_id="merge",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
non_system = [m for m in projected if m.get("role") != "system"]
|
non_system = [m for m in projected if m.get("role") != "system"]
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ async def test_process_message_captures_original_text_before_restore(
|
|||||||
seen.append((ctx.original_user_text, ctx.runtime))
|
seen.append((ctx.original_user_text, ctx.runtime))
|
||||||
raise RuntimeError("captured before restore")
|
raise RuntimeError("captured before restore")
|
||||||
|
|
||||||
loop._state_restore = stop_after_capture # type: ignore[method-assign]
|
loop._restore_turn = stop_after_capture # type: ignore[method-assign]
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="captured before restore"):
|
with pytest.raises(RuntimeError, match="captured before restore"):
|
||||||
await loop._process_message(
|
await loop._process_message(
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async def test_tool_fails_after_retry_exhausted():
|
|||||||
|
|
||||||
assert "failed after retry" in output
|
assert "failed after retry" in output
|
||||||
assert "ClosedResourceError" in output
|
assert "ClosedResourceError" in output
|
||||||
assert is_tool_error_result(wrapper.name, output)
|
assert is_tool_error_result(output)
|
||||||
assert session.call_tool.call_count == 2
|
assert session.call_tool.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
|
|||||||
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
|
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
|
||||||
|
|
||||||
|
|
||||||
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
||||||
sm = _manager(tmp_path, monkeypatch)
|
sm = _manager(tmp_path, monkeypatch)
|
||||||
key = "telegram:legacy:lossy"
|
key = "telegram:legacy:lossy"
|
||||||
lossy_path = sm._get_legacy_lossy_path(key)
|
lossy_path = sm._get_legacy_lossy_path(key)
|
||||||
@@ -73,49 +73,23 @@ def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
|||||||
|
|
||||||
session = sm._load(key)
|
session = sm._load(key)
|
||||||
|
|
||||||
assert session is not None
|
assert session is None
|
||||||
assert session.metadata == {"source": "test"}
|
assert lossy_path.exists()
|
||||||
assert session.messages[0]["content"] == "loaded from lossy"
|
assert not sm._get_session_path(key).exists()
|
||||||
|
|
||||||
|
|
||||||
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
|
def test_load_ignores_legacy_global_path(tmp_path: Path, monkeypatch) -> None:
|
||||||
sm = _manager(tmp_path, monkeypatch)
|
sm = _manager(tmp_path, monkeypatch)
|
||||||
key = "telegram:migrate:lossy"
|
key = "telegram:legacy:global"
|
||||||
new_path = sm._get_session_path(key)
|
new_path = sm._get_session_path(key)
|
||||||
lossy_path = sm._get_legacy_lossy_path(key)
|
legacy_path = sm._get_legacy_session_path(key)
|
||||||
_write_session_file(lossy_path, key, "migrate me")
|
_write_session_file(legacy_path, key, "loaded from global")
|
||||||
|
|
||||||
session = sm._load(key)
|
session = sm._load(key)
|
||||||
|
|
||||||
assert session is not None
|
assert session is None
|
||||||
assert session.messages[0]["content"] == "migrate me"
|
assert legacy_path.exists()
|
||||||
assert new_path.exists()
|
assert not new_path.exists()
|
||||||
assert not lossy_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
sm = _manager(tmp_path, monkeypatch)
|
|
||||||
first_key = "telegram:a_b"
|
|
||||||
second_key = "telegram:a:b"
|
|
||||||
lossy_path = sm._get_legacy_lossy_path(first_key)
|
|
||||||
assert lossy_path == sm._get_legacy_lossy_path(second_key)
|
|
||||||
_write_session_file(lossy_path, first_key, "belongs to first")
|
|
||||||
|
|
||||||
loaded_second = sm._load(second_key)
|
|
||||||
|
|
||||||
assert loaded_second is None
|
|
||||||
assert lossy_path.exists()
|
|
||||||
assert not sm._get_session_path(second_key).exists()
|
|
||||||
|
|
||||||
loaded_first = sm._load(first_key)
|
|
||||||
|
|
||||||
assert loaded_first is not None
|
|
||||||
assert loaded_first.messages[0]["content"] == "belongs to first"
|
|
||||||
assert sm._get_session_path(first_key).exists()
|
|
||||||
assert not lossy_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_safe_key_is_lossy() -> None:
|
def test_safe_key_is_lossy() -> None:
|
||||||
|
|||||||
@@ -140,5 +140,5 @@ async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
|
|||||||
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
|
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
|
||||||
|
|
||||||
result = await tool.execute(value="1")
|
result = await tool.execute(value="1")
|
||||||
assert is_tool_error_result("api_plugin", result) is True
|
assert is_tool_error_result(result) is True
|
||||||
assert str(result) == "Error: plugin failed"
|
assert str(result) == "Error: plugin failed"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async def test_run_inline_returns_structured_error(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == "subagent failed"
|
assert result == "subagent failed"
|
||||||
assert is_tool_error_result("spawn", result)
|
assert is_tool_error_result(result)
|
||||||
assert manager._running_tasks == {}
|
assert manager._running_tasks == {}
|
||||||
assert manager._session_tasks == {}
|
assert manager._session_tasks == {}
|
||||||
|
|
||||||
|
|||||||
@@ -788,35 +788,6 @@ def test_discover_plugins_skips_names_outside_enabled_set():
|
|||||||
assert loaded == []
|
assert loaded == []
|
||||||
|
|
||||||
|
|
||||||
def test_discover_plugins_warns_once_for_legacy_entry_points():
|
|
||||||
from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins
|
|
||||||
|
|
||||||
legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")]
|
|
||||||
_warn_legacy_channel_entry_points.cache_clear()
|
|
||||||
try:
|
|
||||||
with (
|
|
||||||
patch(
|
|
||||||
"nanobot.channels.registry.entry_points",
|
|
||||||
return_value=legacy_entry_points,
|
|
||||||
) as metadata_entry_points,
|
|
||||||
patch("nanobot.channels.registry._channel_package_names", return_value=[]),
|
|
||||||
patch("nanobot.channels.registry.logger.warning") as warning,
|
|
||||||
):
|
|
||||||
discover_plugins()
|
|
||||||
discover_plugins()
|
|
||||||
finally:
|
|
||||||
_warn_legacy_channel_entry_points.cache_clear()
|
|
||||||
|
|
||||||
metadata_entry_points.assert_called_once_with(group="nanobot.channels")
|
|
||||||
warning.assert_called_once_with(
|
|
||||||
"Legacy channel entry points were detected but will not be loaded: {}. "
|
|
||||||
"The '{}' entry-point group is no longer supported; use a built-in channel or "
|
|
||||||
"migrate it into nanobot/channels/<channel>/.",
|
|
||||||
"a-old, z-old",
|
|
||||||
"nanobot.channels",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_channel_manifest_rejects_invalid_dependency_metadata():
|
def test_channel_manifest_rejects_invalid_dependency_metadata():
|
||||||
with pytest.raises(TypeError, match="tuple of requirements"):
|
with pytest.raises(TypeError, match="tuple of requirements"):
|
||||||
ChannelPlugin(
|
ChannelPlugin(
|
||||||
|
|||||||
@@ -1744,17 +1744,6 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
|||||||
assert passed_config.workspace_path == workspace_path
|
assert passed_config.workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
|
||||||
config_file = tmp_path / "config.json"
|
|
||||||
config_file.write_text(json.dumps({"agents": {"defaults": {"memoryWindow": 42}}}))
|
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "memoryWindow" in result.stdout
|
|
||||||
assert "no longer used" in result.stdout
|
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_retains_recent_messages_by_default():
|
def test_heartbeat_retains_recent_messages_by_default():
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
|
|||||||
@@ -96,22 +96,17 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
|
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
|
||||||
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
|
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.config.loader.logger.warning") as warning:
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.agents.defaults.max_tokens == 1234
|
||||||
assert not hasattr(config.agents.defaults, "max_messages")
|
assert not hasattr(config.agents.defaults, "max_messages")
|
||||||
warning.assert_called_once()
|
|
||||||
message = warning.call_args.args[0]
|
|
||||||
assert "legacy and ignored" in message
|
|
||||||
assert "next version" in message
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
||||||
@@ -121,7 +116,6 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.config.loader.logger.warning"):
|
|
||||||
config = load_config(config_path)
|
config = load_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -52,3 +52,22 @@ def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> Non
|
|||||||
assert cfg.model_override == "openrouter/sonnet"
|
assert cfg.model_override == "openrouter/sonnet"
|
||||||
assert dumped["modelOverride"] == "openrouter/sonnet"
|
assert dumped["modelOverride"] == "openrouter/sonnet"
|
||||||
assert "model" not in dumped
|
assert "model" not in dumped
|
||||||
|
|
||||||
|
|
||||||
|
def test_dream_config_ignores_retired_noop_fields() -> None:
|
||||||
|
cfg = DreamConfig.model_validate(
|
||||||
|
{
|
||||||
|
"maxBatchSize": 99,
|
||||||
|
"maxIterations": 99,
|
||||||
|
"annotateLineAges": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = cfg.model_dump(by_alias=True)
|
||||||
|
|
||||||
|
assert not hasattr(cfg, "max_batch_size")
|
||||||
|
assert not hasattr(cfg, "max_iterations")
|
||||||
|
assert not hasattr(cfg, "annotate_line_ages")
|
||||||
|
assert "maxBatchSize" not in dumped
|
||||||
|
assert "maxIterations" not in dumped
|
||||||
|
assert "annotateLineAges" not in dumped
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Regression tests for legacy-stem session handling."""
|
"""Tests for retired legacy session storage paths."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -6,15 +7,11 @@ from pathlib import Path
|
|||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) -> None:
|
def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.session.manager.get_legacy_sessions_dir",
|
|
||||||
lambda: tmp_path / "legacy_sessions",
|
|
||||||
)
|
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
|
||||||
# Simulate a legacy lossy-path filename (telegram_12345.jsonl) with a corrupt
|
# A legacy lossy-path filename must not be treated as current session storage,
|
||||||
# first line that triggers the repair branch in list_sessions.
|
# even when the file contains otherwise recoverable records.
|
||||||
legacy_stem = "telegram_12345"
|
legacy_stem = "telegram_12345"
|
||||||
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
|
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
|
||||||
corrupt_path.parent.mkdir(parents=True, exist_ok=True)
|
corrupt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -24,7 +21,6 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
|
|||||||
"created_at": datetime(2025, 1, 1).isoformat(),
|
"created_at": datetime(2025, 1, 1).isoformat(),
|
||||||
"updated_at": datetime(2025, 1, 1).isoformat(),
|
"updated_at": datetime(2025, 1, 1).isoformat(),
|
||||||
})
|
})
|
||||||
# Corrupt line followed by valid message
|
|
||||||
corrupt_path.write_text(
|
corrupt_path.write_text(
|
||||||
metadata + "\n{INVALID JSON LINE\n"
|
metadata + "\n{INVALID JSON LINE\n"
|
||||||
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
|
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
|
||||||
@@ -33,14 +29,11 @@ def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch)
|
|||||||
|
|
||||||
sessions = manager.list_sessions()
|
sessions = manager.list_sessions()
|
||||||
|
|
||||||
# BUG: repair fails because _repair re-encodes the fallback_key via
|
assert sessions == []
|
||||||
# _get_session_path, producing a base64 stem that doesn't match the
|
assert corrupt_path.exists()
|
||||||
# actual legacy filename. The session is silently dropped.
|
|
||||||
assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}"
|
|
||||||
assert sessions[0]["key"] == "telegram:12345"
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_session_methods_fall_back_to_legacy_lossy_stem(
|
def test_read_session_methods_ignore_legacy_lossy_stem(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -69,8 +62,5 @@ def test_read_session_methods_fall_back_to_legacy_lossy_stem(
|
|||||||
metadata_result = manager.read_session_metadata(key)
|
metadata_result = manager.read_session_metadata(key)
|
||||||
file_result = manager.read_session_file(key)
|
file_result = manager.read_session_file(key)
|
||||||
|
|
||||||
assert metadata_result is not None
|
assert metadata_result is None
|
||||||
assert metadata_result["metadata"] == metadata["metadata"]
|
assert file_result is None
|
||||||
assert file_result is not None
|
|
||||||
assert file_result["metadata"] == metadata["metadata"]
|
|
||||||
assert file_result["messages"] == []
|
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ def test_write_stdin_reports_missing_session(tmp_path):
|
|||||||
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
|
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
|
||||||
|
|
||||||
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
|
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
|
||||||
assert is_tool_error_result("write_stdin", result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "Error: server-side MCP failure"
|
assert result == "Error: server-side MCP failure"
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -462,7 +462,7 @@ async def test_execute_contains_malformed_success_result() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool returned malformed content: TypeError)"
|
assert result == "(MCP tool returned malformed content: TypeError)"
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -476,7 +476,7 @@ async def test_registry_adds_retry_hint_to_malformed_mcp_result() -> None:
|
|||||||
|
|
||||||
result = await registry.execute(wrapper.name, {})
|
result = await registry.execute(wrapper.name, {})
|
||||||
|
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
assert "MCP tool returned malformed content" in result
|
assert "MCP tool returned malformed content" in result
|
||||||
assert "Analyze the error above and try a different approach" in result
|
assert "Analyze the error above and try a different approach" in result
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "Error: generated report successfully"
|
assert result == "Error: generated report successfully"
|
||||||
assert not is_tool_error_result(wrapper.name, result)
|
assert not is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
# Smallest valid 1x1 PNG, base64 without the data: prefix.
|
# Smallest valid 1x1 PNG, base64 without the data: prefix.
|
||||||
@@ -562,7 +562,7 @@ async def test_execute_returns_timeout_message() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call timed out after 0.01s)"
|
assert result == "(MCP tool call timed out after 0.01s)"
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -575,7 +575,7 @@ async def test_execute_handles_server_cancelled_error() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call was cancelled)"
|
assert result == "(MCP tool call was cancelled)"
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -607,7 +607,7 @@ async def test_execute_handles_generic_exception() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call failed: RuntimeError)"
|
assert result == "(MCP tool call failed: RuntimeError)"
|
||||||
assert is_tool_error_result(wrapper.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
def _make_tool_def(name: str) -> SimpleNamespace:
|
def _make_tool_def(name: str) -> SimpleNamespace:
|
||||||
@@ -1631,7 +1631,7 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
|
|||||||
assert wrapper._reconnect is not None
|
assert wrapper._reconnect is not None
|
||||||
assert other_wrapper._reconnect is None
|
assert other_wrapper._reconnect is None
|
||||||
|
|
||||||
removed = mcp_mod._unregister_server_tools(SimpleNamespace(), registry, server_name)
|
removed = mcp_mod._unregister_server_tools(registry, server_name)
|
||||||
|
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert wrapper.name not in registry.tool_names
|
assert wrapper.name not in registry.tool_names
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class SampleTool(Tool):
|
|||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
count=IntegerSchema(minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -81,12 +81,12 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
|
|||||||
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
|
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
|
||||||
root = tool_parameters_schema(
|
root = tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
count=IntegerSchema(minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
obj = ObjectSchema(
|
obj = ObjectSchema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
count=IntegerSchema(minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
params = {"query": "h", "count": 2}
|
params = {"query": "h", "count": 2}
|
||||||
@@ -110,14 +110,14 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
|
|||||||
expected = _Mini().validate_params(params)
|
expected = _Mini().validate_params(params)
|
||||||
assert Schema.validate_json_schema_value(params, root, "") == expected
|
assert Schema.validate_json_schema_value(params, root, "") == expected
|
||||||
assert obj.validate_value(params, "") == expected
|
assert obj.validate_value(params, "") == expected
|
||||||
assert IntegerSchema(0, minimum=1).validate_value(0, "n") == ["n must be >= 1"]
|
assert IntegerSchema(minimum=1).validate_value(0, "n") == ["n must be >= 1"]
|
||||||
|
|
||||||
|
|
||||||
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
|
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
|
||||||
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
|
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
|
||||||
built = tool_parameters_schema(
|
built = tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(2, minimum=1, maximum=10),
|
count=IntegerSchema(minimum=1, maximum=10),
|
||||||
mode=StringSchema("", enum=["fast", "full"]),
|
mode=StringSchema("", enum=["fast", "full"]),
|
||||||
meta=ObjectSchema(
|
meta=ObjectSchema(
|
||||||
tag=StringSchema(""),
|
tag=StringSchema(""),
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ async def test_serper_search_http_error(monkeypatch):
|
|||||||
tool = _tool(provider="serper", api_key="bad-serper-key")
|
tool = _tool(provider="serper", api_key="bad-serper-key")
|
||||||
result = await tool.execute(query="serper")
|
result = await tool.execute(query="serper")
|
||||||
assert "Error: Serper search failed (403)" in result
|
assert "Error: Serper search failed (403)" in result
|
||||||
assert is_tool_error_result(tool.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -284,7 +284,7 @@ async def test_serper_search_rate_limited(monkeypatch):
|
|||||||
tool = _tool(provider="serper", api_key="serper-key")
|
tool = _tool(provider="serper", api_key="serper-key")
|
||||||
result = await tool.execute(query="serper")
|
result = await tool.execute(query="serper")
|
||||||
assert "Serper search rate limited" in result
|
assert "Serper search rate limited" in result
|
||||||
assert is_tool_error_result(tool.name, result)
|
assert is_tool_error_result(result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
|||||||
assert list_webui_sessions(manager) == []
|
assert list_webui_sessions(manager) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
||||||
|
manager = SessionManager(tmp_path)
|
||||||
|
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
||||||
|
legacy_path.write_text(
|
||||||
|
'{"_type":"metadata","key":"websocket:legacy",'
|
||||||
|
'"created_at":"2025-01-01T00:00:00",'
|
||||||
|
'"updated_at":"2025-01-01T00:00:00","metadata":{}}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_webui_sessions(manager) == []
|
||||||
|
assert legacy_path.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
|
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:cron-preview")
|
session = manager.get_or_create("websocket:cron-preview")
|
||||||
|
|||||||
@@ -115,9 +115,6 @@ function baseSettingsPayload() {
|
|||||||
},
|
},
|
||||||
dream: {
|
dream: {
|
||||||
schedule: "every 2h",
|
schedule: "every 2h",
|
||||||
max_batch_size: 20,
|
|
||||||
max_iterations: 15,
|
|
||||||
annotate_line_ages: true,
|
|
||||||
},
|
},
|
||||||
unified_session: false,
|
unified_session: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -259,9 +259,6 @@ function modelSettings(model: string, provider: string): SettingsPayload {
|
|||||||
},
|
},
|
||||||
dream: {
|
dream: {
|
||||||
schedule: "every 2h",
|
schedule: "every 2h",
|
||||||
max_batch_size: 20,
|
|
||||||
max_iterations: 15,
|
|
||||||
annotate_line_ages: true,
|
|
||||||
},
|
},
|
||||||
unified_session: false,
|
unified_session: false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user