mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2349a0bfc |
@@ -348,19 +348,6 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<a id="responses-state-and-compaction"></a>
|
|
||||||
|
|
||||||
### Responses conversation state and compaction
|
|
||||||
|
|
||||||
Providers that use the Responses API can keep reasoning context across a
|
|
||||||
conversation, which helps with multi-step tasks. Supported providers can also
|
|
||||||
compact long conversations automatically.
|
|
||||||
|
|
||||||
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
|
|
||||||
Native compaction is also automatic when the provider supports it. The
|
|
||||||
threshold is derived from the active model's context window and reserved output
|
|
||||||
headroom; no provider configuration is required.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Azure OpenAI</b></summary>
|
<summary><b>Azure OpenAI</b></summary>
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -229,9 +229,7 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it.
|
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account.
|
||||||
|
|
||||||
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions.
|
|
||||||
|
|
||||||
### Custom OpenAI-Compatible Endpoint
|
### Custom OpenAI-Compatible Endpoint
|
||||||
|
|
||||||
@@ -460,7 +458,7 @@ For GitHub Copilot:
|
|||||||
nanobot provider login github-copilot --set-main
|
nanobot provider login github-copilot --set-main
|
||||||
```
|
```
|
||||||
|
|
||||||
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
Each command authenticates the selected provider and makes its current default model active. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
|
||||||
|
|
||||||
## Provider Resolution
|
## Provider Resolution
|
||||||
|
|
||||||
|
|||||||
@@ -31,19 +31,9 @@ class AutoCompact:
|
|||||||
now: datetime | None = None) -> bool:
|
now: datetime | None = None) -> bool:
|
||||||
if self._ttl <= 0 or not ts:
|
if self._ttl <= 0 or not ts:
|
||||||
return False
|
return False
|
||||||
try:
|
if isinstance(ts, str):
|
||||||
if isinstance(ts, str):
|
ts = datetime.fromisoformat(ts)
|
||||||
ts = datetime.fromisoformat(ts)
|
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60
|
||||||
current = now or datetime.now()
|
|
||||||
if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None:
|
|
||||||
idle_seconds = current.timestamp() - ts.timestamp()
|
|
||||||
else:
|
|
||||||
idle_seconds = (current - ts).total_seconds()
|
|
||||||
except (OSError, OverflowError, TypeError, ValueError):
|
|
||||||
# list_sessions() forwards raw persisted metadata; an unusable value
|
|
||||||
# must not escape the idle scan and stop the agent loop.
|
|
||||||
return False
|
|
||||||
return idle_seconds >= self._ttl * 60
|
|
||||||
|
|
||||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
@@ -134,21 +124,10 @@ class AutoCompact:
|
|||||||
if entry:
|
if entry:
|
||||||
return session, self._format_summary(entry[0], entry[1])
|
return session, self._format_summary(entry[0], entry[1])
|
||||||
# Cold path: summary persisted in session metadata (process restarted).
|
# Cold path: summary persisted in session metadata (process restarted).
|
||||||
# Persisted metadata may outlive schema changes; a malformed summary must
|
|
||||||
# not abort turn preparation.
|
|
||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
if isinstance(meta, dict):
|
if isinstance(meta, dict):
|
||||||
summary_meta = cast(dict[str, object], meta)
|
return session, self._format_summary(
|
||||||
text = summary_meta.get("text")
|
cast(str, meta["text"]),
|
||||||
if isinstance(text, str) and text:
|
datetime.fromisoformat(cast(str, meta["last_active"])),
|
||||||
raw_last_active = summary_meta.get("last_active")
|
)
|
||||||
try:
|
|
||||||
last_active = (
|
|
||||||
datetime.fromisoformat(raw_last_active)
|
|
||||||
if isinstance(raw_last_active, str)
|
|
||||||
else session.updated_at
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
last_active = session.updated_at
|
|
||||||
return session, self._format_summary(text, last_active)
|
|
||||||
return session, None
|
return session, None
|
||||||
|
|||||||
+21
-44
@@ -217,18 +217,19 @@ class ContextBuilder:
|
|||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
conversation_only: bool = False,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""Build the complete message list for an LLM call."""
|
||||||
messages = list(history)
|
root = workspace or self.workspace
|
||||||
if not conversation_only:
|
active_skill_names = (
|
||||||
root = workspace or self.workspace
|
self.skills.get_explicitly_invoked_skills(current_message)
|
||||||
active_skill_names = (
|
if current_role == "user"
|
||||||
self.skills.get_explicitly_invoked_skills(current_message)
|
else []
|
||||||
if current_role == "user"
|
)
|
||||||
else []
|
user_content = self.build_user_content(current_message, image_paths=media)
|
||||||
)
|
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
||||||
messages.insert(0, {
|
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
|
||||||
|
messages: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": self.build_system_prompt(
|
"content": self.build_system_prompt(
|
||||||
active_skill_names=active_skill_names,
|
active_skill_names=active_skill_names,
|
||||||
@@ -239,47 +240,23 @@ class ContextBuilder:
|
|||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
),
|
),
|
||||||
})
|
},
|
||||||
current = self.build_current_message(
|
*history,
|
||||||
current_message,
|
]
|
||||||
media=media,
|
if messages[-1].get("role") == current_role:
|
||||||
current_role=current_role,
|
|
||||||
runtime_context_blocks=runtime_context_blocks,
|
|
||||||
)
|
|
||||||
if messages and messages[-1].get("role") == current_role:
|
|
||||||
last = dict(messages[-1])
|
last = dict(messages[-1])
|
||||||
last["content"] = self._merge_message_content(
|
last["content"] = self._merge_message_content(last.get("content"), merged)
|
||||||
last.get("content"),
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
current.get("content"),
|
|
||||||
)
|
|
||||||
current_meta = current.get("_meta")
|
|
||||||
if current_role == "user" and isinstance(current_meta, dict):
|
|
||||||
internal_meta = dict(last.get("_meta") or {})
|
internal_meta = dict(last.get("_meta") or {})
|
||||||
internal_meta.update(cast(dict[str, Any], current_meta))
|
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = runtime_context_meta
|
||||||
last["_meta"] = internal_meta
|
last["_meta"] = internal_meta
|
||||||
messages[-1] = last
|
messages[-1] = last
|
||||||
return messages
|
return messages
|
||||||
messages.append(current)
|
|
||||||
return messages
|
|
||||||
|
|
||||||
def build_current_message(
|
|
||||||
self,
|
|
||||||
current_message: str,
|
|
||||||
*,
|
|
||||||
media: list[str] | None = None,
|
|
||||||
current_role: str = "user",
|
|
||||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Build only the fresh turn message without merging it into history."""
|
|
||||||
content = self.build_user_content(current_message, image_paths=media)
|
|
||||||
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
|
|
||||||
merged, runtime_context_meta = append_runtime_context(content, blocks)
|
|
||||||
current: dict[str, Any] = {"role": current_role, "content": merged}
|
current: dict[str, Any] = {"role": current_role, "content": merged}
|
||||||
if current_role == "user" and runtime_context_meta is not None:
|
if current_role == "user" and runtime_context_meta is not None:
|
||||||
current["_meta"] = {
|
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
|
||||||
RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta,
|
messages.append(current)
|
||||||
}
|
return messages
|
||||||
return current
|
|
||||||
|
|
||||||
def build_user_content(
|
def build_user_content(
|
||||||
self,
|
self,
|
||||||
|
|||||||
+15
-162
@@ -9,7 +9,6 @@ import dataclasses
|
|||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import weakref
|
|
||||||
from collections.abc import Coroutine, Iterable, Mapping
|
from collections.abc import Coroutine, Iterable, Mapping
|
||||||
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -49,7 +48,7 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||||
from nanobot.providers.base import LLMProvider, ProviderConversationState
|
from nanobot.providers.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
@@ -106,7 +105,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
_T = TypeVar("_T")
|
||||||
_SUBAGENT_PROVIDER_TASK_META = "subagent_provider_task_id"
|
|
||||||
|
|
||||||
|
|
||||||
class TurnKind(Enum):
|
class TurnKind(Enum):
|
||||||
@@ -127,7 +125,6 @@ class TurnContext:
|
|||||||
|
|
||||||
history: list[dict[str, Any]] = field(default_factory=list)
|
history: list[dict[str, Any]] = field(default_factory=list)
|
||||||
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
initial_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
|
||||||
request_context: RequestContext | None = None
|
request_context: RequestContext | None = None
|
||||||
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||||
attributes: dict[str, Any] = field(default_factory=dict)
|
attributes: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -245,8 +242,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"
|
||||||
_PROVIDER_STATE_CHECKPOINT_VERSION_KEY = "provider_state_checkpoint_version"
|
|
||||||
_PROVIDER_STATE_CHECKPOINT_VERSION = "v1"
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -399,9 +394,7 @@ class AgentLoop:
|
|||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._session_locks: dict[str, asyncio.Lock] = {}
|
||||||
weakref.WeakValueDictionary()
|
|
||||||
)
|
|
||||||
# Per-session pending queues for mid-turn message injection.
|
# Per-session pending queues for mid-turn message injection.
|
||||||
# When a session has an active task, new messages for that session
|
# When a session has an active task, new messages for that session
|
||||||
# are routed here instead of creating a new task.
|
# are routed here instead of creating a new task.
|
||||||
@@ -723,7 +716,6 @@ class AgentLoop:
|
|||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=ctx.session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
conversation_only=ctx.session.transient is True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
|
||||||
@@ -751,12 +743,10 @@ class AgentLoop:
|
|||||||
self,
|
self,
|
||||||
ctx: TurnContext,
|
ctx: TurnContext,
|
||||||
) -> list[RuntimeContextBlock]:
|
) -> list[RuntimeContextBlock]:
|
||||||
if ctx.require_session().transient is True:
|
|
||||||
return []
|
|
||||||
assert ctx.request_context is not None
|
assert ctx.request_context is not None
|
||||||
return await self._resolve_runtime_context_for_request(
|
return await self._resolve_runtime_context_for_request(
|
||||||
ctx.request_context,
|
ctx.request_context,
|
||||||
ctx.tools if ctx.tools is not None else self.tools,
|
ctx.tools or self.tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _resolve_runtime_context_for_request(
|
async def _resolve_runtime_context_for_request(
|
||||||
@@ -787,24 +777,18 @@ class AgentLoop:
|
|||||||
else:
|
else:
|
||||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||||
|
|
||||||
async def cancel_active_turn(self, key: str) -> int:
|
async def _cancel_active_tasks(self, key: str) -> int:
|
||||||
"""Cancel active work and discard queued follow-ups for *key*.
|
"""Cancel and await all active tasks and subagents for *key*.
|
||||||
|
|
||||||
Returns the total number of cancelled tasks + subagents.
|
Returns the total number of cancelled tasks + subagents.
|
||||||
"""
|
"""
|
||||||
pending = self._pending_queues.pop(key, None)
|
|
||||||
queued = 0
|
|
||||||
if pending is not None:
|
|
||||||
while not pending.empty():
|
|
||||||
pending.get_nowait()
|
|
||||||
queued += 1
|
|
||||||
tasks = tuple(self._active_tasks.pop(key, set()))
|
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
with suppress(asyncio.CancelledError, Exception):
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
await t
|
await t
|
||||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||||
return queued + cancelled + sub_cancelled
|
return cancelled + sub_cancelled
|
||||||
|
|
||||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||||
"""Return the session key used for task routing and mid-turn injections."""
|
"""Return the session key used for task routing and mid-turn injections."""
|
||||||
@@ -870,7 +854,6 @@ class AgentLoop:
|
|||||||
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
turn_scopes: list[AbstractContextManager[Any]] | None = None,
|
||||||
tools: ToolRegistry | None = None,
|
tools: ToolRegistry | None = None,
|
||||||
request_context: RequestContext | None = None,
|
request_context: RequestContext | None = None,
|
||||||
provider_state: ProviderConversationState | None = None,
|
|
||||||
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
|
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
|
||||||
"""Run the agent iteration loop.
|
"""Run the agent iteration loop.
|
||||||
|
|
||||||
@@ -886,18 +869,7 @@ class AgentLoop:
|
|||||||
async def _checkpoint(payload: dict[str, Any]) -> None:
|
async def _checkpoint(payload: dict[str, Any]) -> None:
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
public_payload = dict(payload)
|
self._set_runtime_checkpoint(session, payload)
|
||||||
private_state = public_payload.pop("provider_state", None)
|
|
||||||
public_payload.pop(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY, None)
|
|
||||||
if "provider_state" in payload and (
|
|
||||||
private_state is None
|
|
||||||
or isinstance(private_state, ProviderConversationState)
|
|
||||||
):
|
|
||||||
session.provider_state = private_state
|
|
||||||
public_payload[self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY] = (
|
|
||||||
self._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
)
|
|
||||||
self._set_runtime_checkpoint(session, public_payload)
|
|
||||||
|
|
||||||
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
|
||||||
"""Drain follow-up messages from the pending queue.
|
"""Drain follow-up messages from the pending queue.
|
||||||
@@ -931,10 +903,7 @@ class AgentLoop:
|
|||||||
if isinstance(metadata_value, dict)
|
if isinstance(metadata_value, dict)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
if (
|
if pending_msg.channel != "system":
|
||||||
pending_msg.channel != "system"
|
|
||||||
and not (session is not None and session.transient is True)
|
|
||||||
):
|
|
||||||
scope = self.workspace_scopes.for_turn(
|
scope = self.workspace_scopes.for_turn(
|
||||||
channel=pending_msg.channel,
|
channel=pending_msg.channel,
|
||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
@@ -1014,7 +983,7 @@ class AgentLoop:
|
|||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
session_metadata=session.metadata if session is not None else None,
|
session_metadata=session.metadata if session is not None else None,
|
||||||
)
|
)
|
||||||
effective_tools = tools if tools is not None else self.tools
|
effective_tools = tools or self.tools
|
||||||
request_ctx = request_context or RequestContext(
|
request_ctx = request_context or RequestContext(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
@@ -1098,7 +1067,6 @@ class AgentLoop:
|
|||||||
session_metadata=session_metadata,
|
session_metadata=session_metadata,
|
||||||
message_metadata=metadata,
|
message_metadata=metadata,
|
||||||
),
|
),
|
||||||
provider_state=provider_state,
|
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
turn_scope_stack.close()
|
turn_scope_stack.close()
|
||||||
@@ -1106,8 +1074,6 @@ class AgentLoop:
|
|||||||
reset_request_context(request_token)
|
reset_request_context(request_token)
|
||||||
reset_file_states(file_state_token)
|
reset_file_states(file_state_token)
|
||||||
self._last_usage = result.usage
|
self._last_usage = result.usage
|
||||||
if session is not None and not ephemeral:
|
|
||||||
session.provider_state = result.provider_state
|
|
||||||
if result.stop_reason == "max_iterations":
|
if result.stop_reason == "max_iterations":
|
||||||
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
||||||
should_stream = turn_continuation.should_stream_budget_response(
|
should_stream = turn_continuation.should_stream_budget_response(
|
||||||
@@ -1172,11 +1138,6 @@ class AgentLoop:
|
|||||||
effective_key = self._effective_session_key(msg)
|
effective_key = self._effective_session_key(msg)
|
||||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||||
continue
|
continue
|
||||||
if (
|
|
||||||
msg.transient_session
|
|
||||||
and not self.sessions.is_transient_active(effective_key)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
msg, effective_key, raw,
|
msg, effective_key, raw,
|
||||||
@@ -1245,7 +1206,7 @@ class AgentLoop:
|
|||||||
session_key = self._effective_session_key(msg)
|
session_key = self._effective_session_key(msg)
|
||||||
if session_key != msg.session_key:
|
if session_key != msg.session_key:
|
||||||
msg = dataclasses.replace(msg, session_key_override=session_key)
|
msg = dataclasses.replace(msg, session_key_override=session_key)
|
||||||
lock = self._get_session_lock(session_key)
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
gate = self._concurrency_gate or nullcontext()
|
gate = self._concurrency_gate or nullcontext()
|
||||||
|
|
||||||
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
|
||||||
@@ -1288,8 +1249,6 @@ class AgentLoop:
|
|||||||
session_key,
|
session_key,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
if msg.transient_session:
|
|
||||||
raise
|
|
||||||
# Preserve partial context from the interrupted turn so
|
# Preserve partial context from the interrupted turn so
|
||||||
# the user does not lose tool results and assistant
|
# the user does not lose tool results and assistant
|
||||||
# messages accumulated before /stop. The checkpoint was
|
# messages accumulated before /stop. The checkpoint was
|
||||||
@@ -1592,16 +1551,13 @@ class AgentLoop:
|
|||||||
if ctx.session is None:
|
if ctx.session is None:
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
session = ctx.session
|
session = ctx.session
|
||||||
if session.transient is True:
|
|
||||||
ctx.ephemeral = True
|
|
||||||
ctx.tools = ToolRegistry()
|
|
||||||
self._remember_unified_session_route(
|
self._remember_unified_session_route(
|
||||||
session,
|
session,
|
||||||
msg,
|
msg,
|
||||||
is_user_turn=ctx.original_user_text is not None,
|
is_user_turn=ctx.original_user_text is not None,
|
||||||
)
|
)
|
||||||
await ctx.delivery.started()
|
await ctx.delivery.started()
|
||||||
if ctx.kind is TurnKind.USER and not session.transient:
|
if ctx.kind is TurnKind.USER:
|
||||||
self.workspace_scopes.persist_message_scope(session, msg)
|
self.workspace_scopes.persist_message_scope(session, msg)
|
||||||
|
|
||||||
if self._restore_runtime_checkpoint(session):
|
if self._restore_runtime_checkpoint(session):
|
||||||
@@ -1611,8 +1567,6 @@ class AgentLoop:
|
|||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _compact_session(self, ctx: TurnContext) -> None:
|
||||||
session = ctx.require_session()
|
session = ctx.require_session()
|
||||||
if session.transient is True:
|
|
||||||
return
|
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(
|
ctx.session, pending = self.auto_compact.prepare_session(
|
||||||
session,
|
session,
|
||||||
ctx.session_key,
|
ctx.session_key,
|
||||||
@@ -1703,24 +1657,14 @@ class AgentLoop:
|
|||||||
"extend_to_user": is_subagent,
|
"extend_to_user": is_subagent,
|
||||||
}
|
}
|
||||||
ctx.history = session.get_history(**_hist_kwargs)
|
ctx.history = session.get_history(**_hist_kwargs)
|
||||||
stored_state = session.provider_state
|
|
||||||
subagent_followup_persisted = False
|
|
||||||
if is_subagent:
|
if is_subagent:
|
||||||
# Keep the durable internal delivery as an assistant record, but
|
# Keep the durable internal delivery as an assistant record, but
|
||||||
# present this completion to the model as fresh follow-up input.
|
# present this completion to the model as fresh follow-up input.
|
||||||
# Providers without assistant-prefill support drop trailing
|
# Providers without assistant-prefill support drop trailing
|
||||||
# assistant messages, so using the persisted record as the current
|
# assistant messages, so using the persisted record as the current
|
||||||
# prompt would hide an independently dispatched subagent result.
|
# prompt would hide an independently dispatched subagent result.
|
||||||
subagent_followup_persisted = self._persist_subagent_followup(
|
if self._persist_subagent_followup(session, ctx.msg):
|
||||||
session,
|
|
||||||
ctx.msg,
|
|
||||||
)
|
|
||||||
if subagent_followup_persisted:
|
|
||||||
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
logger.debug("Subagent result persisted for session {}", ctx.session_key)
|
||||||
# Establish a durable, replay-safe baseline before any fallible
|
|
||||||
# provider compatibility or prompt assembly work. A compatible
|
|
||||||
# staged state replaces this in a second atomic save below.
|
|
||||||
session.provider_state = None
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
ctx.input_persisted_early = True
|
ctx.input_persisted_early = True
|
||||||
ctx.delivery.record_runtime(runtime)
|
ctx.delivery.record_runtime(runtime)
|
||||||
@@ -1728,65 +1672,13 @@ class AgentLoop:
|
|||||||
ctx.request_context = self._request_context_for_turn(ctx)
|
ctx.request_context = self._request_context_for_turn(ctx)
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
ctx.runtime_context_blocks = await self._resolve_runtime_context_for_turn(ctx)
|
||||||
staged_provider_state = False
|
ctx.initial_messages = self._build_initial_messages(ctx)
|
||||||
if stored_state is not None and runtime.provider.can_resume_conversation_state(
|
|
||||||
stored_state,
|
|
||||||
runtime.model,
|
|
||||||
):
|
|
||||||
current_provider_message = self.context.build_current_message(
|
|
||||||
ctx.msg.content,
|
|
||||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
|
||||||
)
|
|
||||||
task_id = ctx.msg.metadata.get("subagent_task_id") if is_subagent else None
|
|
||||||
already_staged = False
|
|
||||||
if isinstance(task_id, str) and task_id:
|
|
||||||
internal_meta = current_provider_message.get("_meta")
|
|
||||||
current_provider_message["_meta"] = {
|
|
||||||
**(
|
|
||||||
cast(dict[str, Any], internal_meta)
|
|
||||||
if isinstance(internal_meta, dict)
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
_SUBAGENT_PROVIDER_TASK_META: task_id,
|
|
||||||
}
|
|
||||||
already_staged = any(
|
|
||||||
isinstance(message.get("_meta"), dict)
|
|
||||||
and cast(dict[str, Any], message["_meta"]).get(
|
|
||||||
_SUBAGENT_PROVIDER_TASK_META
|
|
||||||
)
|
|
||||||
== task_id
|
|
||||||
for message in stored_state.pending_messages
|
|
||||||
)
|
|
||||||
ctx.provider_state = (
|
|
||||||
stored_state
|
|
||||||
if already_staged
|
|
||||||
else stored_state.with_pending_messages([
|
|
||||||
*stored_state.pending_messages,
|
|
||||||
current_provider_message,
|
|
||||||
])
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
not ctx.ephemeral
|
|
||||||
and (ctx.kind is TurnKind.USER or subagent_followup_persisted)
|
|
||||||
):
|
|
||||||
session.provider_state = ctx.provider_state
|
|
||||||
staged_provider_state = True
|
|
||||||
elif stored_state is not None:
|
|
||||||
session.provider_state = None
|
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
ctx.input_persisted_early = self._persist_user_message_early(
|
ctx.input_persisted_early = self._persist_user_message_early(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
session,
|
session,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
)
|
)
|
||||||
if staged_provider_state and not ctx.input_persisted_early:
|
|
||||||
session.provider_state = stored_state
|
|
||||||
elif subagent_followup_persisted and staged_provider_state:
|
|
||||||
# Upgrade the replay-safe baseline to the resumable state before
|
|
||||||
# prompt assembly and the first model checkpoint.
|
|
||||||
self.sessions.save(session)
|
|
||||||
ctx.initial_messages = self._build_initial_messages(ctx)
|
|
||||||
|
|
||||||
if ctx.on_progress is None:
|
if ctx.on_progress is None:
|
||||||
ctx.on_progress = ctx.delivery.progress_callback()
|
ctx.on_progress = ctx.delivery.progress_callback()
|
||||||
@@ -1820,7 +1712,6 @@ class AgentLoop:
|
|||||||
turn_scopes=ctx.turn_scopes,
|
turn_scopes=ctx.turn_scopes,
|
||||||
tools=ctx.tools,
|
tools=ctx.tools,
|
||||||
request_context=ctx.request_context,
|
request_context=ctx.request_context,
|
||||||
provider_state=ctx.provider_state,
|
|
||||||
)
|
)
|
||||||
final_content, _, 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
|
||||||
@@ -2158,36 +2049,7 @@ class AgentLoop:
|
|||||||
):
|
):
|
||||||
overlap = size
|
overlap = size
|
||||||
break
|
break
|
||||||
appended_messages = restored_messages[overlap:]
|
session.messages.extend(restored_messages[overlap:])
|
||||||
session.messages.extend(appended_messages)
|
|
||||||
assistant_message_data = (
|
|
||||||
cast(dict[str, Any], assistant_message)
|
|
||||||
if isinstance(assistant_message, dict)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
provider_state_is_synchronized = (
|
|
||||||
checkpoint_data.get(self._PROVIDER_STATE_CHECKPOINT_VERSION_KEY)
|
|
||||||
== self._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
)
|
|
||||||
phase = checkpoint_data.get("phase")
|
|
||||||
exact_final_response = (
|
|
||||||
phase == "final_response"
|
|
||||||
and assistant_message_data is not None
|
|
||||||
and assistant_message_data.get("role") == "assistant"
|
|
||||||
and not bool(checkpoint_data.get("completed_tool_results"))
|
|
||||||
and not bool(checkpoint_data.get("pending_tool_calls"))
|
|
||||||
)
|
|
||||||
exact_completed_tools = (
|
|
||||||
phase == "tools_completed"
|
|
||||||
and assistant_message_data is not None
|
|
||||||
and assistant_message_data.get("role") == "assistant"
|
|
||||||
and not bool(checkpoint_data.get("pending_tool_calls"))
|
|
||||||
)
|
|
||||||
if not (
|
|
||||||
provider_state_is_synchronized
|
|
||||||
and (exact_final_response or exact_completed_tools)
|
|
||||||
):
|
|
||||||
session.provider_state = None
|
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
self._clear_pending_user_turn(session)
|
||||||
self._clear_runtime_checkpoint(session)
|
self._clear_runtime_checkpoint(session)
|
||||||
@@ -2208,7 +2070,6 @@ class AgentLoop:
|
|||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
session.provider_state = None
|
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
|
|
||||||
self._clear_pending_user_turn(session)
|
self._clear_pending_user_turn(session)
|
||||||
@@ -2247,7 +2108,7 @@ class AgentLoop:
|
|||||||
content=content, media=media or [], metadata=metadata,
|
content=content, media=media or [], metadata=metadata,
|
||||||
)
|
)
|
||||||
# Share the dispatch lock so direct calls serialize with bus turns.
|
# Share the dispatch lock so direct calls serialize with bus turns.
|
||||||
lock = self._get_session_lock(session_key)
|
lock = self._session_locks.setdefault(session_key, asyncio.Lock())
|
||||||
try:
|
try:
|
||||||
async with lock:
|
async with lock:
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
@@ -2278,11 +2139,3 @@ class AgentLoop:
|
|||||||
finally:
|
finally:
|
||||||
await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle")
|
await self.runtime_event_publisher.run_status_changed(msg, session_key, "idle")
|
||||||
self.runtime_event_publisher.clear_turn(session_key)
|
self.runtime_event_publisher.clear_turn(session_key)
|
||||||
|
|
||||||
def _get_session_lock(self, session_key: str) -> asyncio.Lock:
|
|
||||||
"""Return the shared lock while allowing idle session entries to expire."""
|
|
||||||
lock = self._session_locks.get(session_key)
|
|
||||||
if lock is None:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
self._session_locks[session_key] = lock
|
|
||||||
return lock
|
|
||||||
|
|||||||
@@ -713,10 +713,11 @@ class MemoryStore:
|
|||||||
if tools_used
|
if tools_used
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
raw_timestamp = message.get("timestamp")
|
timestamp = cast(str, message.get("timestamp", "?"))
|
||||||
timestamp = str(raw_timestamp) if raw_timestamp is not None else "?"
|
role = cast(str, message["role"])
|
||||||
role = str(message.get("role") or "unknown")
|
lines.append(
|
||||||
lines.append(f"[{timestamp[:16]}] {role.upper()}{tools}: {content}")
|
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
|
||||||
|
)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def raw_archive(
|
def raw_archive(
|
||||||
@@ -930,7 +931,6 @@ class Consolidator:
|
|||||||
session_key=session.key,
|
session_key=session.key,
|
||||||
)
|
)
|
||||||
session.last_consolidated = end_idx
|
session.last_consolidated = end_idx
|
||||||
session.provider_state = None
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
@@ -1136,7 +1136,6 @@ class Consolidator:
|
|||||||
if summary:
|
if summary:
|
||||||
last_summary = summary
|
last_summary = summary
|
||||||
session.last_consolidated = end_idx
|
session.last_consolidated = end_idx
|
||||||
session.provider_state = None
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
if not summary:
|
if not summary:
|
||||||
# LLM is degraded — stop hammering it this call;
|
# LLM is degraded — stop hammering it this call;
|
||||||
@@ -1206,7 +1205,6 @@ class Consolidator:
|
|||||||
|
|
||||||
# Preserve history and advance only the replay boundary.
|
# Preserve history and advance only the replay boundary.
|
||||||
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||||
session.provider_state = None
|
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
+29
-167
@@ -19,17 +19,7 @@ from nanobot.agent.context_governance import (
|
|||||||
)
|
)
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
ToolCallRequest,
|
|
||||||
)
|
|
||||||
from nanobot.providers.conversation_state import (
|
|
||||||
ProviderConversationStateController,
|
|
||||||
allows_conversation_message_merge,
|
|
||||||
)
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
detach_runtime_context,
|
detach_runtime_context,
|
||||||
@@ -114,7 +104,6 @@ class AgentRunSpec:
|
|||||||
goal_active_predicate: Callable[[], bool] | None = None
|
goal_active_predicate: Callable[[], bool] | None = None
|
||||||
goal_continue_message: GoalContinueMessage | None = None
|
goal_continue_message: GoalContinueMessage | None = None
|
||||||
finalize_on_max_iterations: bool = True
|
finalize_on_max_iterations: bool = True
|
||||||
provider_state: ProviderConversationState | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -131,7 +120,6 @@ class AgentRunResult:
|
|||||||
had_injections: bool = False
|
had_injections: bool = False
|
||||||
# Terminal tail to emit when the preceding final-content prefix was already streamed.
|
# Terminal tail to emit when the preceding final-content prefix was already streamed.
|
||||||
pending_stream_content: str | None = None
|
pending_stream_content: str | None = None
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRunner:
|
class AgentRunner:
|
||||||
@@ -173,7 +161,6 @@ class AgentRunner:
|
|||||||
and messages[-1].get("role") == "user"
|
and messages[-1].get("role") == "user"
|
||||||
and not is_hidden_history_message(injection)
|
and not is_hidden_history_message(injection)
|
||||||
and not is_hidden_history_message(messages[-1])
|
and not is_hidden_history_message(messages[-1])
|
||||||
and allows_conversation_message_merge(messages[-1])
|
|
||||||
):
|
):
|
||||||
merged = dict(messages[-1])
|
merged = dict(messages[-1])
|
||||||
left_meta = merged.get("_meta")
|
left_meta = merged.get("_meta")
|
||||||
@@ -244,7 +231,6 @@ class AgentRunner:
|
|||||||
assistant_message: dict[str, Any] | None,
|
assistant_message: dict[str, Any] | None,
|
||||||
injection_cycles: int,
|
injection_cycles: int,
|
||||||
*,
|
*,
|
||||||
conversation_state: ProviderConversationStateController | None = None,
|
|
||||||
phase: str = "after error",
|
phase: str = "after error",
|
||||||
iteration: int | None = None,
|
iteration: int | None = None,
|
||||||
allow_goal_continue: bool = False,
|
allow_goal_continue: bool = False,
|
||||||
@@ -272,21 +258,16 @@ class AgentRunner:
|
|||||||
if assistant_message is not None:
|
if assistant_message is not None:
|
||||||
messages.append(assistant_message)
|
messages.append(assistant_message)
|
||||||
if iteration is not None:
|
if iteration is not None:
|
||||||
checkpoint: dict[str, Any] = {
|
|
||||||
"phase": "final_response",
|
|
||||||
"iteration": iteration,
|
|
||||||
"model": spec.runtime.model,
|
|
||||||
"assistant_message": assistant_message,
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
if conversation_state is not None:
|
|
||||||
checkpoint["provider_state"] = conversation_state.checkpoint(
|
|
||||||
messages
|
|
||||||
)
|
|
||||||
await self._emit_checkpoint(
|
await self._emit_checkpoint(
|
||||||
spec,
|
spec,
|
||||||
checkpoint,
|
{
|
||||||
|
"phase": "final_response",
|
||||||
|
"iteration": iteration,
|
||||||
|
"model": spec.runtime.model,
|
||||||
|
"assistant_message": assistant_message,
|
||||||
|
"completed_tool_results": [],
|
||||||
|
"pending_tool_calls": [],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self._append_injected_messages(messages, injections)
|
self._append_injected_messages(messages, injections)
|
||||||
if real_injection:
|
if real_injection:
|
||||||
@@ -439,12 +420,6 @@ class AgentRunner:
|
|||||||
injection_cycles = 0
|
injection_cycles = 0
|
||||||
compacted_tool_call_ids: set[str] = set()
|
compacted_tool_call_ids: set[str] = set()
|
||||||
pending_stream_content: str | None = None
|
pending_stream_content: str | None = None
|
||||||
conversation_state = ProviderConversationStateController(
|
|
||||||
provider=spec.runtime.provider,
|
|
||||||
model=spec.runtime.model,
|
|
||||||
messages=messages,
|
|
||||||
state=spec.provider_state,
|
|
||||||
)
|
|
||||||
governance_config = ContextGovernanceConfig(
|
governance_config = ContextGovernanceConfig(
|
||||||
provider=spec.runtime.provider,
|
provider=spec.runtime.provider,
|
||||||
model=spec.runtime.model,
|
model=spec.runtime.model,
|
||||||
@@ -475,20 +450,7 @@ class AgentRunner:
|
|||||||
session_key=spec.session_key,
|
session_key=spec.session_key,
|
||||||
)
|
)
|
||||||
await hook.before_iteration(context)
|
await hook.before_iteration(context)
|
||||||
provider_context = conversation_state.prepare_request(
|
response = await self._request_model(spec, messages_for_model, hook, context)
|
||||||
messages,
|
|
||||||
context_window_tokens=spec.runtime.context_window_tokens,
|
|
||||||
model_messages=messages_for_model,
|
|
||||||
)
|
|
||||||
response = await self._request_model(
|
|
||||||
spec,
|
|
||||||
messages_for_model,
|
|
||||||
hook,
|
|
||||||
context,
|
|
||||||
conversation_state=conversation_state,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
conversation_state.observe_response(response, messages)
|
|
||||||
context.response = response
|
context.response = response
|
||||||
context.tool_calls = list(response.tool_calls)
|
context.tool_calls = list(response.tool_calls)
|
||||||
|
|
||||||
@@ -518,10 +480,6 @@ class AgentRunner:
|
|||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
thinking_blocks=response.thinking_blocks,
|
thinking_blocks=response.thinking_blocks,
|
||||||
)
|
)
|
||||||
assistant_message = conversation_state.project_response_message(
|
|
||||||
assistant_message,
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
messages.append(assistant_message)
|
messages.append(assistant_message)
|
||||||
await self._emit_checkpoint(
|
await self._emit_checkpoint(
|
||||||
spec,
|
spec,
|
||||||
@@ -586,15 +544,6 @@ class AgentRunner:
|
|||||||
length_recovery_parts.clear()
|
length_recovery_parts.clear()
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
checkpoint_model_messages = (
|
|
||||||
self.context_governor.prepare_for_model(
|
|
||||||
governance_config,
|
|
||||||
messages,
|
|
||||||
compacted_tool_call_ids,
|
|
||||||
)
|
|
||||||
if response.provider_state is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
await self._emit_checkpoint(
|
await self._emit_checkpoint(
|
||||||
spec,
|
spec,
|
||||||
{
|
{
|
||||||
@@ -604,10 +553,6 @@ class AgentRunner:
|
|||||||
"assistant_message": assistant_message,
|
"assistant_message": assistant_message,
|
||||||
"completed_tool_results": completed_tool_results,
|
"completed_tool_results": completed_tool_results,
|
||||||
"pending_tool_calls": [],
|
"pending_tool_calls": [],
|
||||||
"provider_state": conversation_state.checkpoint(
|
|
||||||
messages,
|
|
||||||
model_messages=checkpoint_model_messages,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
empty_content_retries = 0
|
empty_content_retries = 0
|
||||||
@@ -630,11 +575,7 @@ class AgentRunner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
clean = hook.finalize_content(context, response.content)
|
clean = hook.finalize_content(context, response.content)
|
||||||
if (
|
if response.finish_reason != "error" and is_blank_text(clean):
|
||||||
response.finish_reason
|
|
||||||
not in {"error", "length", "refusal", "content_filter"}
|
|
||||||
and is_blank_text(clean)
|
|
||||||
):
|
|
||||||
empty_content_retries += 1
|
empty_content_retries += 1
|
||||||
if empty_content_retries < _MAX_EMPTY_RETRIES:
|
if empty_content_retries < _MAX_EMPTY_RETRIES:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -657,12 +598,7 @@ class AgentRunner:
|
|||||||
if hook.wants_streaming():
|
if hook.wants_streaming():
|
||||||
await hook.on_stream_end(context, resuming=False)
|
await hook.on_stream_end(context, resuming=False)
|
||||||
retry_messages = self._finalization_retry_messages(messages_for_model)
|
retry_messages = self._finalization_retry_messages(messages_for_model)
|
||||||
response = await self._request_finalization_retry(
|
response = await self._request_finalization_retry(spec, messages_for_model)
|
||||||
spec,
|
|
||||||
messages_for_model,
|
|
||||||
transcript=messages,
|
|
||||||
conversation_state=conversation_state,
|
|
||||||
)
|
|
||||||
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
|
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
|
||||||
self._accumulate_usage(usage, retry_usage)
|
self._accumulate_usage(usage, retry_usage)
|
||||||
raw_usage = self._merge_usage(raw_usage, retry_usage)
|
raw_usage = self._merge_usage(raw_usage, retry_usage)
|
||||||
@@ -672,7 +608,7 @@ class AgentRunner:
|
|||||||
original_content = response.content
|
original_content = response.content
|
||||||
clean = hook.finalize_content(context, response.content)
|
clean = hook.finalize_content(context, response.content)
|
||||||
|
|
||||||
if response.finish_reason == "length":
|
if response.finish_reason == "length" and not is_blank_text(clean):
|
||||||
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
|
||||||
length_recovery_parts.append(
|
length_recovery_parts.append(
|
||||||
_restore_outer_whitespace(clean or "", original_content)
|
_restore_outer_whitespace(clean or "", original_content)
|
||||||
@@ -687,13 +623,10 @@ class AgentRunner:
|
|||||||
if hook.wants_streaming():
|
if hook.wants_streaming():
|
||||||
context.stream_continues_current_message = True
|
context.stream_continues_current_message = True
|
||||||
await hook.on_stream_end(context, resuming=True)
|
await hook.on_stream_end(context, resuming=True)
|
||||||
messages.append(conversation_state.project_response_message(
|
messages.append(build_assistant_message(
|
||||||
build_assistant_message(
|
clean,
|
||||||
clean,
|
reasoning_content=response.reasoning_content,
|
||||||
reasoning_content=response.reasoning_content,
|
thinking_blocks=response.thinking_blocks,
|
||||||
thinking_blocks=response.thinking_blocks,
|
|
||||||
),
|
|
||||||
response,
|
|
||||||
))
|
))
|
||||||
messages.append(build_length_recovery_message(clean or ""))
|
messages.append(build_length_recovery_message(clean or ""))
|
||||||
await hook.after_iteration(context)
|
await hook.after_iteration(context)
|
||||||
@@ -723,22 +656,15 @@ class AgentRunner:
|
|||||||
reasoning_content=response.reasoning_content,
|
reasoning_content=response.reasoning_content,
|
||||||
thinking_blocks=response.thinking_blocks,
|
thinking_blocks=response.thinking_blocks,
|
||||||
)
|
)
|
||||||
assistant_message = conversation_state.project_response_message(
|
|
||||||
assistant_message,
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for mid-turn injections BEFORE signaling stream end.
|
# Check for mid-turn injections BEFORE signaling stream end.
|
||||||
# If injections are found we keep the stream alive (resuming=True)
|
# If injections are found we keep the stream alive (resuming=True)
|
||||||
# so streaming channels don't prematurely finalize the card.
|
# so streaming channels don't prematurely finalize the card.
|
||||||
should_continue, injection_cycles = await self._try_drain_injections(
|
should_continue, injection_cycles = await self._try_drain_injections(
|
||||||
spec, messages, assistant_message, injection_cycles,
|
spec, messages, assistant_message, injection_cycles,
|
||||||
conversation_state=conversation_state,
|
|
||||||
phase="after final response",
|
phase="after final response",
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
allow_goal_continue=(
|
allow_goal_continue=True,
|
||||||
response.finish_reason not in {"refusal", "content_filter"}
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if should_continue:
|
if should_continue:
|
||||||
had_injections = True
|
had_injections = True
|
||||||
@@ -791,17 +717,11 @@ class AgentRunner:
|
|||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
|
||||||
messages.append(
|
messages.append(assistant_message or build_assistant_message(
|
||||||
assistant_message
|
clean,
|
||||||
or conversation_state.project_response_message(
|
reasoning_content=response.reasoning_content,
|
||||||
build_assistant_message(
|
thinking_blocks=response.thinking_blocks,
|
||||||
clean,
|
))
|
||||||
reasoning_content=response.reasoning_content,
|
|
||||||
thinking_blocks=response.thinking_blocks,
|
|
||||||
),
|
|
||||||
response,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await self._emit_checkpoint(
|
await self._emit_checkpoint(
|
||||||
spec,
|
spec,
|
||||||
{
|
{
|
||||||
@@ -811,7 +731,6 @@ class AgentRunner:
|
|||||||
"assistant_message": messages[-1],
|
"assistant_message": messages[-1],
|
||||||
"completed_tool_results": [],
|
"completed_tool_results": [],
|
||||||
"pending_tool_calls": [],
|
"pending_tool_calls": [],
|
||||||
"provider_state": conversation_state.checkpoint(messages),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if length_recovery_parts:
|
if length_recovery_parts:
|
||||||
@@ -845,7 +764,6 @@ class AgentRunner:
|
|||||||
hook,
|
hook,
|
||||||
messages,
|
messages,
|
||||||
usage,
|
usage,
|
||||||
conversation_state,
|
|
||||||
)
|
)
|
||||||
if terminal_content is None:
|
if terminal_content is None:
|
||||||
terminal_content = self._max_iterations_fallback(spec)
|
terminal_content = self._max_iterations_fallback(spec)
|
||||||
@@ -869,7 +787,6 @@ class AgentRunner:
|
|||||||
tool_events=tool_events,
|
tool_events=tool_events,
|
||||||
had_injections=had_injections,
|
had_injections=had_injections,
|
||||||
pending_stream_content=pending_stream_content,
|
pending_stream_content=pending_stream_content,
|
||||||
provider_state=conversation_state.finish(messages),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _build_request_kwargs(
|
def _build_request_kwargs(
|
||||||
@@ -900,8 +817,6 @@ class AgentRunner:
|
|||||||
context: AgentHookContext,
|
context: AgentHookContext,
|
||||||
*,
|
*,
|
||||||
malformed_retry: bool = False,
|
malformed_retry: bool = False,
|
||||||
conversation_state: ProviderConversationStateController,
|
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
timeout_s: float | None = spec.llm_timeout_s
|
timeout_s: float | None = spec.llm_timeout_s
|
||||||
if timeout_s is None:
|
if timeout_s is None:
|
||||||
@@ -971,7 +886,6 @@ class AgentRunner:
|
|||||||
|
|
||||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
provider_context=provider_context,
|
|
||||||
on_content_delta=_stream,
|
on_content_delta=_stream,
|
||||||
on_thinking_delta=_thinking,
|
on_thinking_delta=_thinking,
|
||||||
on_tool_call_delta=_provider_tool_event,
|
on_tool_call_delta=_provider_tool_event,
|
||||||
@@ -1006,15 +920,11 @@ class AgentRunner:
|
|||||||
|
|
||||||
coro = spec.runtime.provider.chat_stream_with_retry(
|
coro = spec.runtime.provider.chat_stream_with_retry(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
provider_context=provider_context,
|
|
||||||
on_content_delta=_stream_progress,
|
on_content_delta=_stream_progress,
|
||||||
on_tool_call_delta=_provider_tool_event,
|
on_tool_call_delta=_provider_tool_event,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
coro = spec.runtime.provider.chat_with_retry(
|
coro = spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Streaming requests also have provider-level idle timeouts
|
# Streaming requests also have provider-level idle timeouts
|
||||||
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
# (NANOBOT_STREAM_IDLE_TIMEOUT_S), but a stream that keeps producing
|
||||||
@@ -1076,10 +986,6 @@ class AgentRunner:
|
|||||||
return await self._request_model(
|
return await self._request_model(
|
||||||
spec, retry_messages, hook, context,
|
spec, retry_messages, hook, context,
|
||||||
malformed_retry=True,
|
malformed_retry=True,
|
||||||
conversation_state=conversation_state,
|
|
||||||
provider_context=conversation_state.independent_request_context(
|
|
||||||
context_window_tokens=spec.runtime.context_window_tokens,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
all_dropped
|
all_dropped
|
||||||
@@ -1092,13 +998,7 @@ class AgentRunner:
|
|||||||
fallback_messages = self._malformed_tool_call_retry_messages(
|
fallback_messages = self._malformed_tool_call_retry_messages(
|
||||||
messages, response.content,
|
messages, response.content,
|
||||||
)
|
)
|
||||||
return await self._request_no_tools(
|
return await self._request_no_tools(spec, fallback_messages)
|
||||||
spec,
|
|
||||||
fallback_messages,
|
|
||||||
provider_context=conversation_state.independent_request_context(
|
|
||||||
context_window_tokens=spec.runtime.context_window_tokens,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1131,10 +1031,6 @@ class AgentRunner:
|
|||||||
original_finish_reason,
|
original_finish_reason,
|
||||||
)
|
)
|
||||||
response.tool_calls = valid
|
response.tool_calls = valid
|
||||||
# The opaque candidate still contains every raw function_call item.
|
|
||||||
# Advancing it after dropping even one call would replay an unmatched
|
|
||||||
# call without a corresponding tool output on the next request.
|
|
||||||
response.provider_state = None
|
|
||||||
if not valid:
|
if not valid:
|
||||||
response.finish_reason = "stop"
|
response.finish_reason = "stop"
|
||||||
return (dropped, not valid, original_finish_reason)
|
return (dropped, not valid, original_finish_reason)
|
||||||
@@ -1164,27 +1060,9 @@ class AgentRunner:
|
|||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
|
||||||
transcript: list[dict[str, Any]],
|
|
||||||
conversation_state: ProviderConversationStateController,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
retry_messages = self._finalization_retry_messages(messages)
|
retry_messages = self._finalization_retry_messages(messages)
|
||||||
provider_context = conversation_state.prepare_request(
|
return await self._request_no_tools(spec, retry_messages)
|
||||||
transcript,
|
|
||||||
context_window_tokens=spec.runtime.context_window_tokens,
|
|
||||||
supplemental_messages=[retry_messages[-1]],
|
|
||||||
)
|
|
||||||
response = await self._request_no_tools(
|
|
||||||
spec,
|
|
||||||
retry_messages,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
conversation_state.observe_response(
|
|
||||||
response,
|
|
||||||
transcript,
|
|
||||||
adopt_candidate_state=False,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _finalization_retry_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
@@ -1198,17 +1076,10 @@ class AgentRunner:
|
|||||||
hook: AgentHook,
|
hook: AgentHook,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
usage: dict[str, int],
|
usage: dict[str, int],
|
||||||
conversation_state: ProviderConversationStateController,
|
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
retry_messages = self._budget_exhausted_finalization_messages(messages)
|
retry_messages = self._budget_exhausted_finalization_messages(messages)
|
||||||
try:
|
try:
|
||||||
response = await self._request_no_tools(
|
response = await self._request_no_tools(spec, retry_messages)
|
||||||
spec,
|
|
||||||
retry_messages,
|
|
||||||
provider_context=conversation_state.independent_request_context(
|
|
||||||
context_window_tokens=spec.runtime.context_window_tokens,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Budget-exhausted finalization failed for {}; using fallback",
|
"Budget-exhausted finalization failed for {}; using fallback",
|
||||||
@@ -1244,18 +1115,9 @@ class AgentRunner:
|
|||||||
self,
|
self,
|
||||||
spec: AgentRunSpec,
|
spec: AgentRunSpec,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
kwargs = self._build_request_kwargs(
|
kwargs = self._build_request_kwargs(spec, messages, tools=None)
|
||||||
spec,
|
return await spec.runtime.provider.chat_with_retry(**kwargs)
|
||||||
messages,
|
|
||||||
tools=None,
|
|
||||||
)
|
|
||||||
return await spec.runtime.provider.chat_with_retry(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _budget_exhausted_finalization_messages(
|
def _budget_exhausted_finalization_messages(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -52,66 +51,6 @@ class ExecSessionInfo:
|
|||||||
owner_session_key: str | None = None
|
owner_session_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class _BoundedOutputBuffer:
|
|
||||||
"""Keep the first and most recent characters within a fixed budget."""
|
|
||||||
|
|
||||||
def __init__(self, max_chars: int) -> None:
|
|
||||||
self.max_chars = max_chars
|
|
||||||
self._content = ""
|
|
||||||
self._tail: deque[str] = deque()
|
|
||||||
self._tail_chars = 0
|
|
||||||
self._total_chars = 0
|
|
||||||
self._truncated = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_output(self) -> bool:
|
|
||||||
return self._total_chars > 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def retained_chars(self) -> int:
|
|
||||||
return len(self._content) + self._tail_chars
|
|
||||||
|
|
||||||
def append(self, text: str) -> None:
|
|
||||||
if not text:
|
|
||||||
return
|
|
||||||
self._total_chars += len(text)
|
|
||||||
if not self._truncated:
|
|
||||||
combined = self._content + text
|
|
||||||
if len(combined) <= self.max_chars:
|
|
||||||
self._content = combined
|
|
||||||
return
|
|
||||||
head_chars = self.max_chars // 2
|
|
||||||
tail_chars = self.max_chars - head_chars
|
|
||||||
self._content = combined[:head_chars]
|
|
||||||
self._tail.append(combined[-tail_chars:])
|
|
||||||
self._tail_chars = tail_chars
|
|
||||||
self._truncated = True
|
|
||||||
return
|
|
||||||
|
|
||||||
tail_chars = self.max_chars - len(self._content)
|
|
||||||
self._tail.append(text)
|
|
||||||
self._tail_chars += len(text)
|
|
||||||
while self._tail_chars > tail_chars:
|
|
||||||
excess = self._tail_chars - tail_chars
|
|
||||||
first = self._tail[0]
|
|
||||||
if len(first) <= excess:
|
|
||||||
self._tail.popleft()
|
|
||||||
self._tail_chars -= len(first)
|
|
||||||
else:
|
|
||||||
self._tail[0] = first[excess:]
|
|
||||||
self._tail_chars -= excess
|
|
||||||
|
|
||||||
def drain(self) -> tuple[str, int]:
|
|
||||||
output = self._content + "".join(self._tail)
|
|
||||||
truncated_chars = self._total_chars - len(output)
|
|
||||||
self._content = ""
|
|
||||||
self._tail.clear()
|
|
||||||
self._tail_chars = 0
|
|
||||||
self._total_chars = 0
|
|
||||||
self._truncated = False
|
|
||||||
return output, truncated_chars
|
|
||||||
|
|
||||||
|
|
||||||
class _ExecSession:
|
class _ExecSession:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -134,27 +73,30 @@ class _ExecSession:
|
|||||||
# timeout None/0 means no limit; an infinite deadline is never reached.
|
# timeout None/0 means no limit; an infinite deadline is never reached.
|
||||||
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
self.deadline = time.monotonic() + timeout if timeout else float("inf")
|
||||||
self.last_access = time.monotonic()
|
self.last_access = time.monotonic()
|
||||||
self._stdout = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
|
self._chunks: list[str] = []
|
||||||
self._stderr = _BoundedOutputBuffer(MAX_OUTPUT_CHARS)
|
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._timed_out = False
|
self._timed_out = False
|
||||||
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, self._stdout))
|
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
|
||||||
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, self._stderr))
|
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
|
||||||
|
|
||||||
async def _read_stream(
|
async def _read_stream(
|
||||||
self,
|
self,
|
||||||
stream: asyncio.StreamReader | None,
|
stream: asyncio.StreamReader | None,
|
||||||
buffer: _BoundedOutputBuffer,
|
prefix: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if stream is None:
|
if stream is None:
|
||||||
return
|
return
|
||||||
|
first = True
|
||||||
while True:
|
while True:
|
||||||
chunk = await stream.read(4096)
|
chunk = await stream.read(4096)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
text = chunk.decode("utf-8", errors="replace")
|
text = chunk.decode("utf-8", errors="replace")
|
||||||
|
if prefix and first:
|
||||||
|
text = prefix + text
|
||||||
|
first = False
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
buffer.append(text)
|
self._chunks.append(text)
|
||||||
|
|
||||||
async def write(self, chars: str) -> str | None:
|
async def write(self, chars: str) -> str | None:
|
||||||
if self.process.returncode is not None:
|
if self.process.returncode is not None:
|
||||||
@@ -215,14 +157,10 @@ class _ExecSession:
|
|||||||
await self._wait_for_buffered_output()
|
await self._wait_for_buffered_output()
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
stdout, stdout_truncated = self._stdout.drain()
|
output = "".join(self._chunks)
|
||||||
stderr, stderr_truncated = self._stderr.drain()
|
self._chunks.clear()
|
||||||
|
|
||||||
output_parts = [stdout] if stdout else []
|
output, truncated = _truncate_output(output, max_output_chars)
|
||||||
if stderr:
|
|
||||||
output_parts.append(f"STDERR:\n{stderr}")
|
|
||||||
output = "\n".join(output_parts)
|
|
||||||
output, response_truncated = _truncate_output(output, max_output_chars)
|
|
||||||
return _SessionPoll(
|
return _SessionPoll(
|
||||||
output=output,
|
output=output,
|
||||||
done=self.process.returncode is not None,
|
done=self.process.returncode is not None,
|
||||||
@@ -231,7 +169,7 @@ class _ExecSession:
|
|||||||
timed_out=self._timed_out,
|
timed_out=self._timed_out,
|
||||||
terminated=terminated,
|
terminated=terminated,
|
||||||
stdin_closed=stdin_closed,
|
stdin_closed=stdin_closed,
|
||||||
truncated_chars=stdout_truncated + stderr_truncated + response_truncated,
|
truncated_chars=truncated,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
@@ -257,7 +195,7 @@ class _ExecSession:
|
|||||||
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
deadline = time.monotonic() + OUTPUT_DRAIN_GRACE_S
|
||||||
while time.monotonic() < deadline:
|
while time.monotonic() < deadline:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if self._stdout.has_output or self._stderr.has_output:
|
if self._chunks:
|
||||||
return
|
return
|
||||||
await asyncio.sleep(0.01)
|
await asyncio.sleep(0.01)
|
||||||
|
|
||||||
@@ -465,16 +403,20 @@ def clamp_session_int(value: int | None, default: int, minimum: int, maximum: in
|
|||||||
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
|
||||||
if len(output) <= max_output_chars:
|
if len(output) <= max_output_chars:
|
||||||
return output, 0
|
return output, 0
|
||||||
head_chars = max_output_chars // 2
|
half = max_output_chars // 2
|
||||||
tail_chars = max_output_chars - head_chars
|
|
||||||
omitted = len(output) - max_output_chars
|
omitted = len(output) - max_output_chars
|
||||||
return output[:head_chars] + output[-tail_chars:], omitted
|
return (
|
||||||
|
output[:half]
|
||||||
|
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
|
||||||
|
+ output[-half:],
|
||||||
|
omitted,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
||||||
parts = [poll.output] if poll.output else []
|
parts = [poll.output] if poll.output else []
|
||||||
if poll.truncated_chars:
|
if poll.truncated_chars:
|
||||||
parts.append(f"({poll.truncated_chars:,} chars truncated from output)")
|
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
|
||||||
if poll.timed_out:
|
if poll.timed_out:
|
||||||
parts.append("Error: Command timed out; session was terminated.")
|
parts.append("Error: Command timed out; session was terminated.")
|
||||||
if poll.terminated and not poll.timed_out:
|
if poll.terminated and not poll.timed_out:
|
||||||
@@ -645,9 +587,7 @@ class WriteStdinTool(Tool):
|
|||||||
max_output_chars: int,
|
max_output_chars: int,
|
||||||
) -> str:
|
) -> str:
|
||||||
deadline = time.monotonic() + (wait_timeout_ms / 1000)
|
deadline = time.monotonic() + (wait_timeout_ms / 1000)
|
||||||
aggregate = _BoundedOutputBuffer(max_output_chars)
|
aggregate: list[str] = []
|
||||||
upstream_truncated = 0
|
|
||||||
search_overlap = ""
|
|
||||||
first = True
|
first = True
|
||||||
poll: _SessionPoll | None = None
|
poll: _SessionPoll | None = None
|
||||||
|
|
||||||
@@ -660,24 +600,19 @@ class WriteStdinTool(Tool):
|
|||||||
close_stdin=close_stdin if first else False,
|
close_stdin=close_stdin if first else False,
|
||||||
terminate=terminate if first else False,
|
terminate=terminate if first else False,
|
||||||
yield_time_ms=step_ms,
|
yield_time_ms=step_ms,
|
||||||
max_output_chars=MAX_OUTPUT_CHARS,
|
max_output_chars=max_output_chars,
|
||||||
owner_session_key=current_request_session_key(),
|
owner_session_key=current_request_session_key(),
|
||||||
)
|
)
|
||||||
first = False
|
first = False
|
||||||
upstream_truncated += poll.truncated_chars
|
|
||||||
if poll.output:
|
if poll.output:
|
||||||
aggregate.append(poll.output)
|
aggregate.append(poll.output)
|
||||||
searchable = search_overlap + poll.output
|
joined = "".join(aggregate)
|
||||||
if wait_for in searchable:
|
if wait_for in joined:
|
||||||
poll.output, aggregate_truncated = aggregate.drain()
|
poll.output = joined
|
||||||
poll.truncated_chars = upstream_truncated + aggregate_truncated
|
|
||||||
result = format_session_poll(session_id, poll)
|
result = format_session_poll(session_id, poll)
|
||||||
return ToolResult.error(result) if poll.timed_out else result
|
return ToolResult.error(result) if poll.timed_out else result
|
||||||
overlap_chars = max(0, len(wait_for) - 1)
|
|
||||||
search_overlap = searchable[-overlap_chars:] if overlap_chars else ""
|
|
||||||
if poll.done or remaining_ms <= 0:
|
if poll.done or remaining_ms <= 0:
|
||||||
poll.output, aggregate_truncated = aggregate.drain()
|
poll.output = "".join(aggregate)
|
||||||
poll.truncated_chars = upstream_truncated + aggregate_truncated
|
|
||||||
result = format_session_poll(session_id, poll)
|
result = format_session_poll(session_id, poll)
|
||||||
if wait_for not in poll.output:
|
if wait_for not in poll.output:
|
||||||
result += f"\nWait target not observed: {wait_for!r}"
|
result += f"\nWait target not observed: {wait_for!r}"
|
||||||
|
|||||||
+13
-14
@@ -12,7 +12,7 @@ from contextlib import AsyncExitStack, suppress
|
|||||||
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
|
||||||
from weakref import WeakKeyDictionary
|
from weakref import WeakKeyDictionary
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
@@ -25,9 +25,9 @@ from nanobot.bus.events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
PinnedDNSAsyncTransport,
|
Httpx2PinnedDNSAsyncTransport,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
httpx_env_proxy_mounts,
|
httpx2_env_proxy_mounts,
|
||||||
resolve_url_target,
|
resolve_url_target,
|
||||||
validate_url_target,
|
validate_url_target,
|
||||||
)
|
)
|
||||||
@@ -194,7 +194,7 @@ def _is_session_terminated(exc: BaseException) -> bool:
|
|||||||
messages.append(str(getattr(error, "message", "")))
|
messages.append(str(getattr(error, "message", "")))
|
||||||
return any(
|
return any(
|
||||||
marker in message.lower()
|
marker in message.lower()
|
||||||
for marker in ("session terminated", "connection closed")
|
for marker in ("session terminated", "session not found", "connection closed")
|
||||||
for message in messages
|
for message in messages
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -252,8 +252,8 @@ def _redact_url(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _pinned_transport_kwargs() -> dict[str, Any]:
|
def _pinned_transport_kwargs() -> dict[str, Any]:
|
||||||
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
|
kwargs: dict[str, Any] = {"transport": Httpx2PinnedDNSAsyncTransport()}
|
||||||
mounts = httpx_env_proxy_mounts()
|
mounts = httpx2_env_proxy_mounts()
|
||||||
if mounts:
|
if mounts:
|
||||||
kwargs["mounts"] = mounts
|
kwargs["mounts"] = mounts
|
||||||
return kwargs
|
return kwargs
|
||||||
@@ -518,7 +518,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|||||||
"""
|
"""
|
||||||
image_cls = getattr(types, "ImageContent", None)
|
image_cls = getattr(types, "ImageContent", None)
|
||||||
if image_cls is not None and isinstance(block, image_cls):
|
if image_cls is not None and isinstance(block, image_cls):
|
||||||
mime = getattr(block, "mimeType", None) or "image/png"
|
mime = getattr(block, "mime_type", None) or "image/png"
|
||||||
return f"data:{mime};base64,{block.data}"
|
return f"data:{mime};base64,{block.data}"
|
||||||
|
|
||||||
embedded_cls = getattr(types, "EmbeddedResource", None)
|
embedded_cls = getattr(types, "EmbeddedResource", None)
|
||||||
@@ -527,7 +527,7 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
|
|||||||
resource = getattr(block, "resource", None)
|
resource = getattr(block, "resource", None)
|
||||||
if blob_cls is not None and isinstance(resource, blob_cls):
|
if blob_cls is not None and isinstance(resource, blob_cls):
|
||||||
blob_resource = cast(Any, resource)
|
blob_resource = cast(Any, resource)
|
||||||
mime = getattr(blob_resource, "mimeType", None) or ""
|
mime = getattr(blob_resource, "mime_type", None) or ""
|
||||||
if isinstance(mime, str) and mime.startswith("image/"):
|
if isinstance(mime, str) and mime.startswith("image/"):
|
||||||
return f"data:{mime};base64,{blob_resource.blob}"
|
return f"data:{mime};base64,{blob_resource.blob}"
|
||||||
return None
|
return None
|
||||||
@@ -571,7 +571,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
self._original_name = tool_def.name
|
self._original_name = tool_def.name
|
||||||
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
|
||||||
self._description = tool_def.description or tool_def.name
|
self._description = tool_def.description or tool_def.name
|
||||||
raw_schema = tool_def.inputSchema or {"type": "object", "properties": {}}
|
raw_schema = tool_def.input_schema or {"type": "object", "properties": {}}
|
||||||
self._parameters = _normalize_schema_for_openai(raw_schema)
|
self._parameters = _normalize_schema_for_openai(raw_schema)
|
||||||
self._tool_timeout = tool_timeout
|
self._tool_timeout = tool_timeout
|
||||||
|
|
||||||
@@ -650,7 +650,7 @@ class MCPToolWrapper(_MCPWrapperBase):
|
|||||||
# Success — extract text and persist any image content as artifacts.
|
# Success — extract text and persist any image content as artifacts.
|
||||||
try:
|
try:
|
||||||
rendered = self._render_call_result(result.content, kwargs)
|
rendered = self._render_call_result(result.content, kwargs)
|
||||||
if getattr(result, "isError", False):
|
if getattr(result, "is_error", False):
|
||||||
return ToolResult.error(rendered)
|
return ToolResult.error(rendered)
|
||||||
return rendered
|
return rendered
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -876,8 +876,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
async def execute(self, **kwargs: Any) -> str:
|
||||||
from mcp import types
|
from mcp import MCPError, types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
|
|
||||||
retried_transient = False
|
retried_transient = False
|
||||||
refreshed_session = False
|
refreshed_session = False
|
||||||
@@ -897,7 +896,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
raise
|
raise
|
||||||
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
logger.warning("MCP prompt '{}' was cancelled by server/SDK", self._name)
|
||||||
return "(MCP prompt call was cancelled)"
|
return "(MCP prompt call was cancelled)"
|
||||||
except McpError as exc:
|
except MCPError as exc:
|
||||||
if await self._refresh_session_after_termination(
|
if await self._refresh_session_after_termination(
|
||||||
exc,
|
exc,
|
||||||
refreshed_session,
|
refreshed_session,
|
||||||
@@ -1062,7 +1061,7 @@ async def connect_mcp_servers(
|
|||||||
**_pinned_transport_kwargs(),
|
**_pinned_transport_kwargs(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
read, write, _ = await server_stack.enter_async_context(
|
read, write = await server_stack.enter_async_context(
|
||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
|||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
INBOUND_META_TRANSIENT_SESSION = "_transient_session"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,7 +32,6 @@ class InboundMessage:
|
|||||||
media: list[str] = field(default_factory=list) # Media URLs
|
media: list[str] = field(default_factory=list) # Media URLs
|
||||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||||
transient_session: bool = False # In-memory session whose lifetime is owned by the channel
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_key(self) -> str:
|
def session_key(self) -> str:
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||||
INBOUND_META_TRANSIENT_SESSION,
|
|
||||||
InboundMessage,
|
|
||||||
OutboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.pairing import (
|
from nanobot.pairing import (
|
||||||
PAIRING_CODE_META_KEY,
|
PAIRING_CODE_META_KEY,
|
||||||
@@ -252,15 +248,7 @@ class BaseChannel(ABC):
|
|||||||
permission_id = authorization_id if authorization_id is not None else sender_id
|
permission_id = authorization_id if authorization_id is not None else sender_id
|
||||||
if not self.is_allowed(permission_id):
|
if not self.is_allowed(permission_id):
|
||||||
if is_dm:
|
if is_dm:
|
||||||
try:
|
code = generate_code(self.name, str(sender_id))
|
||||||
code = generate_code(self.name, str(sender_id))
|
|
||||||
except OSError:
|
|
||||||
# Transient pairing-store I/O failure: skip the pairing
|
|
||||||
# reply for this message rather than crash the handler.
|
|
||||||
self.logger.warning(
|
|
||||||
"Pairing store unavailable; dropping DM from {}", sender_id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self.send(
|
await self.send(
|
||||||
OutboundMessage(
|
OutboundMessage(
|
||||||
channel=self.name,
|
channel=self.name,
|
||||||
@@ -281,8 +269,7 @@ class BaseChannel(ABC):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
meta = dict(metadata or {})
|
meta = metadata or {}
|
||||||
transient_session = meta.pop(INBOUND_META_TRANSIENT_SESSION, False) is True
|
|
||||||
if self.supports_streaming:
|
if self.supports_streaming:
|
||||||
meta = {**meta, "_wants_stream": True}
|
meta = {**meta, "_wants_stream": True}
|
||||||
|
|
||||||
@@ -294,7 +281,6 @@ class BaseChannel(ABC):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
session_key_override=session_key,
|
||||||
transient_session=transient_session,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Awaitable, Callable, Iterable
|
from collections.abc import Callable, Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
@@ -97,7 +97,6 @@ class ChannelManager:
|
|||||||
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
webui_runtime_model_name: Callable[[], str | None] | None = None,
|
||||||
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
webui_cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
|
||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
@@ -111,7 +110,6 @@ class ChannelManager:
|
|||||||
self._webui_runtime_model_name = webui_runtime_model_name
|
self._webui_runtime_model_name = webui_runtime_model_name
|
||||||
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
|
||||||
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
|
||||||
self._webui_cancel_active_turn = webui_cancel_active_turn
|
|
||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
@@ -180,7 +178,6 @@ class ChannelManager:
|
|||||||
local_trigger_store=self._local_trigger_store,
|
local_trigger_store=self._local_trigger_store,
|
||||||
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
cron_pending_job_ids=self._webui_cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||||
cancel_active_turn=self._webui_cancel_active_turn,
|
|
||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
channel_runtime_status=self.get_status,
|
||||||
skill_state_action=self._webui_skill_state_action,
|
skill_state_action=self._webui_skill_state_action,
|
||||||
|
|||||||
@@ -493,11 +493,12 @@ class SlackChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("reactions_add failed: {}", e)
|
self.logger.debug("reactions_add failed: {}", e)
|
||||||
|
|
||||||
# Thread-scoped session key whenever the turn lives in a thread: either the
|
# Thread-scoped session key whenever the user is in a real thread
|
||||||
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new
|
# (raw_thread_ts is set). DM threads get their own session, separate
|
||||||
# thread for this channel message. DM roots have no thread_ts and keep the
|
# from the DM root, so context doesn't bleed across thread boundaries.
|
||||||
# default per-chat session, so context doesn't bleed across thread boundaries.
|
session_key = (
|
||||||
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None
|
f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
|
||||||
|
)
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
file_markers: list[str] = []
|
file_markers: list[str] = []
|
||||||
for file_info in _as_json_list(event.get("files")) or []:
|
for file_info in _as_json_list(event.get("files")) or []:
|
||||||
|
|||||||
@@ -555,113 +555,6 @@ async def test_dm_thread_message_keeps_thread_ts_and_threaded_session() -> None:
|
|||||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
||||||
|
|
||||||
|
|
||||||
def _channel_mention_request(envelope_id: str, ts: str) -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(
|
|
||||||
type="events_api",
|
|
||||||
envelope_id=envelope_id,
|
|
||||||
payload={
|
|
||||||
"event": {
|
|
||||||
"type": "app_mention",
|
|
||||||
"user": "U1",
|
|
||||||
"channel": "C123",
|
|
||||||
"text": "<@UBOT> hello",
|
|
||||||
"ts": ts,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_root_message_uses_thread_scoped_session() -> None:
|
|
||||||
"""A channel mention that opens a thread belongs to that thread's session."""
|
|
||||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
||||||
channel._bot_user_id = "UBOT"
|
|
||||||
channel._web_client = _FakeAsyncWebClient()
|
|
||||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
||||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
||||||
|
|
||||||
req = _channel_mention_request("env-c1", "1700000000.000100")
|
|
||||||
|
|
||||||
await channel._on_socket_request(client, req)
|
|
||||||
|
|
||||||
channel._handle_message.assert_awaited_once()
|
|
||||||
kwargs = channel._handle_message.await_args.kwargs
|
|
||||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
|
||||||
assert kwargs["metadata"]["slack"]["thread_ts"] == "1700000000.000100"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_root_messages_do_not_share_one_session() -> None:
|
|
||||||
"""Two threads opened in the same channel must not collapse into one session."""
|
|
||||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
||||||
channel._bot_user_id = "UBOT"
|
|
||||||
channel._web_client = _FakeAsyncWebClient()
|
|
||||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
||||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
||||||
|
|
||||||
first = _channel_mention_request("env-c1", "1700000000.000100")
|
|
||||||
second = _channel_mention_request("env-c2", "1700000000.000200")
|
|
||||||
|
|
||||||
await channel._on_socket_request(client, first)
|
|
||||||
await channel._on_socket_request(client, second)
|
|
||||||
|
|
||||||
session_keys = [call.kwargs["session_key"] for call in channel._handle_message.await_args_list]
|
|
||||||
assert session_keys == [
|
|
||||||
"slack:C123:1700000000.000100",
|
|
||||||
"slack:C123:1700000000.000200",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_root_message_without_reply_in_thread_uses_channel_session() -> None:
|
|
||||||
"""With reply_in_thread disabled no thread is opened, so the channel session is used."""
|
|
||||||
channel = SlackChannel(SlackConfig(enabled=True, reply_in_thread=False), MessageBus())
|
|
||||||
channel._bot_user_id = "UBOT"
|
|
||||||
channel._web_client = _FakeAsyncWebClient()
|
|
||||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
||||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
||||||
|
|
||||||
req = _channel_mention_request("env-c3", "1700000000.000300")
|
|
||||||
|
|
||||||
await channel._on_socket_request(client, req)
|
|
||||||
|
|
||||||
channel._handle_message.assert_awaited_once()
|
|
||||||
kwargs = channel._handle_message.await_args.kwargs
|
|
||||||
assert kwargs["session_key"] is None
|
|
||||||
assert kwargs["metadata"]["slack"]["thread_ts"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_thread_reply_keeps_thread_session() -> None:
|
|
||||||
"""A reply inside a channel thread stays in the session opened by the root message."""
|
|
||||||
channel = SlackChannel(SlackConfig(enabled=True), MessageBus())
|
|
||||||
channel._bot_user_id = "UBOT"
|
|
||||||
channel._web_client = _FakeAsyncWebClient()
|
|
||||||
channel._handle_message = AsyncMock() # type: ignore[method-assign]
|
|
||||||
channel._with_thread_context = AsyncMock(return_value="hello") # type: ignore[method-assign]
|
|
||||||
client = SimpleNamespace(send_socket_mode_response=AsyncMock())
|
|
||||||
req = SimpleNamespace(
|
|
||||||
type="events_api",
|
|
||||||
envelope_id="env-c4",
|
|
||||||
payload={
|
|
||||||
"event": {
|
|
||||||
"type": "app_mention",
|
|
||||||
"user": "U1",
|
|
||||||
"channel": "C123",
|
|
||||||
"text": "<@UBOT> follow up",
|
|
||||||
"ts": "1700000000.000400",
|
|
||||||
"thread_ts": "1700000000.000100",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel._on_socket_request(client, req)
|
|
||||||
|
|
||||||
channel._handle_message.assert_awaited_once()
|
|
||||||
kwargs = channel._handle_message.await_args.kwargs
|
|
||||||
assert kwargs["session_key"] == "slack:C123:1700000000.000100"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_slack_slash_command_skips_thread_context() -> None:
|
async def test_slack_slash_command_skips_thread_context() -> None:
|
||||||
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus())
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
|||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
INBOUND_META_TRANSIENT_SESSION,
|
|
||||||
OUTBOUND_META_AGENT_UI,
|
|
||||||
OutboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
@@ -36,10 +32,6 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.channels.websocket.temporary_chat import (
|
|
||||||
TemporaryChatLifecycle,
|
|
||||||
TemporaryChatLifecycleError,
|
|
||||||
)
|
|
||||||
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
from nanobot.command.builtin import builtin_command_starts_agent_turn
|
||||||
from nanobot.config.schema import Base
|
from nanobot.config.schema import Base
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
@@ -84,8 +76,6 @@ from nanobot.webui.websocket_logging import websockets_server_logger
|
|||||||
|
|
||||||
# Plain HTTP WebUI routes also run through websockets.process_request.
|
# Plain HTTP WebUI routes also run through websockets.process_request.
|
||||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
||||||
_TEMPORARY_CHAT_ID_PREFIX = "temporary-"
|
|
||||||
_TEMPORARY_COMMANDS = frozenset({"/model", "/stop"})
|
|
||||||
|
|
||||||
|
|
||||||
class WebSocketConfig(Base):
|
class WebSocketConfig(Base):
|
||||||
@@ -225,10 +215,6 @@ def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
|
|||||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||||
|
|
||||||
|
|
||||||
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
|
|
||||||
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_ID_PREFIX)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||||
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
||||||
|
|
||||||
@@ -300,13 +286,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._workspaces = gateway.workspaces
|
self._workspaces = gateway.workspaces
|
||||||
|
|
||||||
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
|
||||||
self._temporary_chats = TemporaryChatLifecycle(
|
|
||||||
sessions=gateway.session_manager,
|
|
||||||
cancel_active_turn=gateway.cancel_active_turn,
|
|
||||||
attach=self._attach,
|
|
||||||
detach=self._detach,
|
|
||||||
clear_stream_buffers=self._clear_stream_buffers,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- Subscription bookkeeping -------------------------------------------
|
# -- Subscription bookkeeping -------------------------------------------
|
||||||
|
|
||||||
@@ -318,23 +297,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
||||||
chats = self._conn_chats.get(connection)
|
|
||||||
if chats is not None:
|
|
||||||
chats.discard(chat_id)
|
|
||||||
if not chats:
|
|
||||||
self._conn_chats.pop(connection, None)
|
|
||||||
subscribers = self._subs.get(chat_id)
|
|
||||||
if subscribers is not None:
|
|
||||||
subscribers.discard(connection)
|
|
||||||
if not subscribers:
|
|
||||||
self._subs.pop(chat_id, None)
|
|
||||||
|
|
||||||
def _clear_stream_buffers(self, chat_id: str) -> None:
|
|
||||||
for key in tuple(self._stream_text_buffers):
|
|
||||||
if key[0] == chat_id:
|
|
||||||
self._stream_text_buffers.pop(key, None)
|
|
||||||
|
|
||||||
async def send_webui_protocol_error(
|
async def send_webui_protocol_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -363,15 +325,18 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(fork_id)
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
|
|
||||||
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
try:
|
chat_ids = self._conn_chats.pop(connection, set())
|
||||||
await self._temporary_chats.discard_owner(connection)
|
for cid in chat_ids:
|
||||||
finally:
|
subs = self._subs.get(cid)
|
||||||
for chat_id in tuple(self._conn_chats.get(connection, ())):
|
if subs is None:
|
||||||
self._detach(connection, chat_id)
|
continue
|
||||||
self._conn_default.pop(connection, None)
|
subs.discard(connection)
|
||||||
self._webui_connections.discard(connection)
|
if not subs:
|
||||||
|
self._subs.pop(cid, None)
|
||||||
|
self._conn_default.pop(connection, None)
|
||||||
|
self._webui_connections.discard(connection)
|
||||||
|
|
||||||
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
|
||||||
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
|
||||||
@@ -422,7 +387,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
@@ -644,7 +609,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("connection ended: {}", e)
|
self.logger.debug("connection ended: {}", e)
|
||||||
finally:
|
finally:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
|
|
||||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||||
|
|
||||||
@@ -682,36 +647,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if t == "fork_chat":
|
if t == "fork_chat":
|
||||||
await handle_webui_fork_chat(self, connection, envelope)
|
await handle_webui_fork_chat(self, connection, envelope)
|
||||||
return
|
return
|
||||||
if t == "discard_temporary_chat":
|
|
||||||
cid = envelope.get("chat_id")
|
|
||||||
if not _is_temporary_chat_id(cid):
|
|
||||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self._temporary_chats.discard(connection, cid)
|
|
||||||
except TemporaryChatLifecycleError as exc:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail=exc.detail,
|
|
||||||
chat_id=cid,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self._send_event(connection, "temporary_chat_discarded", chat_id=cid)
|
|
||||||
return
|
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
if _is_temporary_chat_id(cid):
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_cannot_attach",
|
|
||||||
chat_id=cid,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
@@ -721,14 +661,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
if _is_temporary_chat_id(cid):
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_has_no_workspace",
|
|
||||||
chat_id=cid,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_set_request(
|
lambda: self._workspaces.scope_for_set_request(
|
||||||
@@ -760,15 +692,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
temporary = envelope.get("temporary") is True
|
|
||||||
if _is_temporary_chat_id(cid) != temporary:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_mismatch",
|
|
||||||
chat_id=cid,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
raw_turn_id = envelope.get("turn_id")
|
raw_turn_id = envelope.get("turn_id")
|
||||||
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
|
||||||
rejection_fields = {
|
rejection_fields = {
|
||||||
@@ -805,17 +728,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if temporary:
|
|
||||||
await self._dispatch_temporary_message(
|
|
||||||
connection,
|
|
||||||
client_id=client_id,
|
|
||||||
chat_id=cid,
|
|
||||||
content=content,
|
|
||||||
turn_id=turn_id,
|
|
||||||
envelope=envelope,
|
|
||||||
rejection_fields=rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
raw_media = envelope.get("media")
|
raw_media = envelope.get("media")
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
@@ -937,103 +849,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
|
|
||||||
async def _dispatch_temporary_message(
|
|
||||||
self,
|
|
||||||
connection: ServerConnection,
|
|
||||||
*,
|
|
||||||
client_id: str,
|
|
||||||
chat_id: str,
|
|
||||||
content: str,
|
|
||||||
turn_id: str | None,
|
|
||||||
envelope: dict[str, Any],
|
|
||||||
rejection_fields: dict[str, str],
|
|
||||||
) -> None:
|
|
||||||
"""Admit a WebUI-only message without durable or local-agent capabilities."""
|
|
||||||
if connection not in self._webui_connections:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_unavailable",
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
forbidden = (
|
|
||||||
"media",
|
|
||||||
"cli_apps",
|
|
||||||
"mcp_presets",
|
|
||||||
"quoted_context",
|
|
||||||
"workspace_scope",
|
|
||||||
)
|
|
||||||
if any(field in envelope for field in forbidden):
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_capability_rejected",
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not content.strip():
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="missing content",
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
command = content.strip().partition(" ")[0].lower()
|
|
||||||
if command.startswith("/") and command not in _TEMPORARY_COMMANDS:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="temporary_chat_command_rejected",
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_key = self._temporary_chats.claim(connection, chat_id)
|
|
||||||
except TemporaryChatLifecycleError as exc:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail=exc.detail,
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
metadata: dict[str, Any] = {
|
|
||||||
"remote": getattr(connection, "remote_address", None),
|
|
||||||
"webui": True,
|
|
||||||
INBOUND_META_TRANSIENT_SESSION: True,
|
|
||||||
**self._transcripts.client_turn_metadata(turn_id),
|
|
||||||
}
|
|
||||||
queued_owner = None
|
|
||||||
if builtin_command_starts_agent_turn(content):
|
|
||||||
queued_owner = register_queued_websocket_turn_if_idle(chat_id, turn_id)
|
|
||||||
if queued_owner is not None:
|
|
||||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
|
||||||
accepted = False
|
|
||||||
try:
|
|
||||||
await self._handle_message(
|
|
||||||
sender_id=client_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
content=content,
|
|
||||||
metadata=metadata,
|
|
||||||
session_key=session_key,
|
|
||||||
is_dm=False,
|
|
||||||
)
|
|
||||||
accepted = True
|
|
||||||
finally:
|
|
||||||
if not accepted and queued_owner is not None:
|
|
||||||
clear_websocket_turn_if_current(chat_id, queued_owner)
|
|
||||||
if turn_id:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"message_accepted",
|
|
||||||
chat_id=chat_id,
|
|
||||||
turn_id=turn_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _workspace_scope_or_error(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -1074,8 +889,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
self._server_task = None
|
||||||
for connection in tuple(self._conn_chats):
|
|
||||||
await self._temporary_chats.discard_owner(connection)
|
|
||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
@@ -1093,7 +906,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
self.logger.warning("connection gone{}", label)
|
self.logger.warning("connection gone{}", label)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("send failed{}", label)
|
self.logger.exception("send failed{}", label)
|
||||||
@@ -1110,8 +923,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||||
if _is_temporary_chat_id(chat_id):
|
|
||||||
return True
|
|
||||||
persisted = self._transcripts.prepare_and_append(
|
persisted = self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
event,
|
event,
|
||||||
@@ -1405,7 +1216,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body,
|
body,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
phase="answer",
|
phase="answer",
|
||||||
include_source=True,
|
|
||||||
)
|
)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
if not conns:
|
if not conns:
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"""Connection-owned lifecycle for WebUI Temporary Chat sessions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
|
|
||||||
from websockets.asyncio.server import ServerConnection
|
|
||||||
|
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
from nanobot.session.webui_turns import clear_websocket_turns
|
|
||||||
|
|
||||||
|
|
||||||
class TemporaryChatLifecycleError(RuntimeError):
|
|
||||||
"""A stable WebSocket protocol error raised by the temporary-chat lifecycle."""
|
|
||||||
|
|
||||||
def __init__(self, detail: str) -> None:
|
|
||||||
self.detail = detail
|
|
||||||
super().__init__(detail)
|
|
||||||
|
|
||||||
|
|
||||||
class TemporaryChatLifecycle:
|
|
||||||
"""Own temporary session identity, cancellation, and cleanup ordering."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
sessions: SessionManager | None,
|
|
||||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None,
|
|
||||||
attach: Callable[[ServerConnection, str], None],
|
|
||||||
detach: Callable[[ServerConnection, str], None],
|
|
||||||
clear_stream_buffers: Callable[[str], None],
|
|
||||||
) -> None:
|
|
||||||
self._sessions = sessions
|
|
||||||
self._cancel_active_turn = cancel_active_turn
|
|
||||||
self._attach = attach
|
|
||||||
self._detach = detach
|
|
||||||
self._clear_stream_buffers = clear_stream_buffers
|
|
||||||
self._owners: dict[str, ServerConnection] = {}
|
|
||||||
|
|
||||||
def claim(self, owner: ServerConnection, chat_id: str) -> str:
|
|
||||||
"""Claim *chat_id* for *owner* and return its in-memory session key."""
|
|
||||||
if self._sessions is None or self._cancel_active_turn is None:
|
|
||||||
raise TemporaryChatLifecycleError("temporary_chat_unavailable")
|
|
||||||
current = self._owners.get(chat_id)
|
|
||||||
if current is not None and current is not owner:
|
|
||||||
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
|
||||||
|
|
||||||
session_key = f"websocket:{chat_id}"
|
|
||||||
self._sessions.get_or_create_transient(session_key)
|
|
||||||
self._owners[chat_id] = owner
|
|
||||||
self._attach(owner, chat_id)
|
|
||||||
return session_key
|
|
||||||
|
|
||||||
async def discard(self, owner: ServerConnection, chat_id: str) -> None:
|
|
||||||
"""Discard an owned chat; an unused chat is already discarded."""
|
|
||||||
current = self._owners.get(chat_id)
|
|
||||||
if current is None:
|
|
||||||
return
|
|
||||||
if current is not owner:
|
|
||||||
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
|
|
||||||
await self._discard_owned(owner, chat_id)
|
|
||||||
|
|
||||||
async def discard_owner(self, owner: ServerConnection) -> None:
|
|
||||||
"""Discard every temporary chat held by a disconnected owner."""
|
|
||||||
chat_ids = (
|
|
||||||
chat_id
|
|
||||||
for chat_id, current in self._owners.items()
|
|
||||||
if current is owner
|
|
||||||
)
|
|
||||||
for chat_id in tuple(chat_ids):
|
|
||||||
await self._discard_owned(owner, chat_id)
|
|
||||||
|
|
||||||
async def _discard_owned(self, owner: ServerConnection, chat_id: str) -> None:
|
|
||||||
self._owners.pop(chat_id, None)
|
|
||||||
self._detach(owner, chat_id)
|
|
||||||
|
|
||||||
session_key = f"websocket:{chat_id}"
|
|
||||||
assert self._sessions is not None
|
|
||||||
assert self._cancel_active_turn is not None
|
|
||||||
self._sessions.discard_transient(session_key)
|
|
||||||
try:
|
|
||||||
await self._cancel_active_turn(session_key)
|
|
||||||
finally:
|
|
||||||
clear_websocket_turns(chat_id)
|
|
||||||
self._clear_stream_buffers(chat_id)
|
|
||||||
@@ -51,7 +51,6 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
|
||||||
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
)
|
)
|
||||||
@@ -111,7 +110,6 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
runtime_model_name=None,
|
runtime_model_name=None,
|
||||||
runtime_surface=kw.get("runtime_surface", "browser"),
|
runtime_surface=kw.get("runtime_surface", "browser"),
|
||||||
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
|
||||||
cancel_active_turn=kw.get("cancel_active_turn"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -191,182 +189,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
|||||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_message_registers_in_memory_session(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
cancel = AsyncMock(return_value=0)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=sessions,
|
|
||||||
cancel_active_turn=cancel,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
connection.remote_address = None
|
|
||||||
channel._webui_connections.add(connection)
|
|
||||||
chat_id = "temporary-test"
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"client",
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"content": "hello",
|
|
||||||
"turn_id": "turn-1",
|
|
||||||
"temporary": True,
|
|
||||||
"webui": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
inbound = bus.publish_inbound.await_args.args[0]
|
|
||||||
assert inbound.session_key == f"websocket:{chat_id}"
|
|
||||||
assert inbound.transient_session is True
|
|
||||||
assert sessions.is_transient_active(inbound.session_key) is True
|
|
||||||
assert sessions.get_cached(inbound.session_key).transient is True
|
|
||||||
assert read_transcript_lines(inbound.session_key) == []
|
|
||||||
assert json.loads(connection.send.await_args.args[0])["event"] == "message_accepted"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"envelope",
|
|
||||||
[
|
|
||||||
{"type": "attach", "chat_id": "temporary-test"},
|
|
||||||
{
|
|
||||||
"type": "set_workspace_scope",
|
|
||||||
"chat_id": "temporary-test",
|
|
||||||
"workspace_scope": {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": "temporary-test",
|
|
||||||
"content": "hello",
|
|
||||||
"temporary": True,
|
|
||||||
"media": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": "temporary-test",
|
|
||||||
"content": "/history",
|
|
||||||
"temporary": True,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_temporary_chat_rejects_persistent_capabilities(
|
|
||||||
bus,
|
|
||||||
tmp_path,
|
|
||||||
envelope,
|
|
||||||
) -> None:
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=SessionManager(tmp_path),
|
|
||||||
cancel_active_turn=AsyncMock(return_value=0),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
connection.remote_address = None
|
|
||||||
channel._webui_connections.add(connection)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(connection, "client", envelope)
|
|
||||||
|
|
||||||
payload = json.loads(connection.send.await_args.args[0])
|
|
||||||
assert payload["event"] == "error"
|
|
||||||
bus.publish_inbound.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discard_temporary_chat_cancels_then_forgets_session(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
cancel = AsyncMock(return_value=1)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=sessions,
|
|
||||||
cancel_active_turn=cancel,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
connection.remote_address = None
|
|
||||||
channel._webui_connections.add(connection)
|
|
||||||
chat_id = "temporary-test"
|
|
||||||
session_key = channel._temporary_chats.claim(connection, chat_id)
|
|
||||||
sessions.get_cached(session_key).add_message("user", "private")
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"client",
|
|
||||||
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
cancel.assert_awaited_once_with(session_key)
|
|
||||||
assert sessions.get_cached(session_key) is None
|
|
||||||
assert chat_id not in channel._subs
|
|
||||||
assert json.loads(connection.send.await_args.args[0]) == {
|
|
||||||
"event": "temporary_chat_discarded",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discard_unused_temporary_chat_is_idempotent(bus, tmp_path) -> None:
|
|
||||||
cancel = AsyncMock(return_value=0)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=SessionManager(tmp_path),
|
|
||||||
cancel_active_turn=cancel,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"client",
|
|
||||||
{"type": "discard_temporary_chat", "chat_id": "temporary-unused"},
|
|
||||||
)
|
|
||||||
|
|
||||||
cancel.assert_not_awaited()
|
|
||||||
assert json.loads(connection.send.await_args.args[0]) == {
|
|
||||||
"event": "temporary_chat_discarded",
|
|
||||||
"chat_id": "temporary-unused",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect_discards_owned_temporary_chat(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
cancel = AsyncMock(return_value=1)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=sessions,
|
|
||||||
cancel_active_turn=cancel,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
chat_id = "temporary-disconnect"
|
|
||||||
session_key = channel._temporary_chats.claim(connection, chat_id)
|
|
||||||
|
|
||||||
await channel._cleanup_connection(connection)
|
|
||||||
|
|
||||||
cancel.assert_awaited_once_with(session_key)
|
|
||||||
assert sessions.get_cached(session_key) is None
|
|
||||||
assert chat_id not in channel._subs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||||
class Conn:
|
class Conn:
|
||||||
@@ -1528,35 +1350,6 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert "text" not in second
|
assert "text" not in second
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_delta_preserves_webui_source_metadata() -> None:
|
|
||||||
bus = MagicMock()
|
|
||||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
|
||||||
mock_ws = AsyncMock()
|
|
||||||
channel._attach(mock_ws, "chat-source-stream")
|
|
||||||
source = {"kind": "cron", "label": "Repo check"}
|
|
||||||
metadata = {WEBUI_MESSAGE_SOURCE_METADATA_KEY: source}
|
|
||||||
|
|
||||||
await channel.send_delta("chat-source-stream", "done", metadata=metadata, stream_id="sid")
|
|
||||||
await channel.send_delta(
|
|
||||||
"chat-source-stream",
|
|
||||||
"",
|
|
||||||
metadata=metadata,
|
|
||||||
stream_id="sid",
|
|
||||||
stream_end=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
first = json.loads(mock_ws.send.call_args_list[0][0][0])
|
|
||||||
second = json.loads(mock_ws.send.call_args_list[1][0][0])
|
|
||||||
assert first["event"] == "delta"
|
|
||||||
assert first["source"] == source
|
|
||||||
assert second["event"] == "stream_end"
|
|
||||||
assert second["source"] == source
|
|
||||||
lines = read_transcript_lines("websocket:chat-source-stream")
|
|
||||||
assert lines[-2]["source"] == source
|
|
||||||
assert lines[-1]["source"] == source
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_marks_resuming_stream_end() -> None:
|
async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@@ -230,30 +230,9 @@ class WeixinChannel(BaseChannel):
|
|||||||
self.logger.error("Failed to load Weixin account state", exc_info=True)
|
self.logger.error("Failed to load Weixin account state", exc_info=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _save_state(self, *, force: bool = False) -> None:
|
def _save_state(self) -> None:
|
||||||
state_file = self._get_state_dir() / "account.json"
|
state_file = self._get_state_dir() / "account.json"
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
if not force and state_file.exists():
|
|
||||||
persisted: object = None
|
|
||||||
try:
|
|
||||||
persisted = json.loads(state_file.read_text())
|
|
||||||
except Exception:
|
|
||||||
persisted = None
|
|
||||||
persisted_token = ""
|
|
||||||
if isinstance(persisted, dict):
|
|
||||||
persisted_mapping = cast(dict[str, object], persisted)
|
|
||||||
persisted_token = str(persisted_mapping.get("token", "") or "")
|
|
||||||
configured_token_is_authoritative: bool = bool(self.config.token) and (
|
|
||||||
self._token == self.config.token
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
persisted_token
|
|
||||||
and persisted_token != self._token
|
|
||||||
and not configured_token_is_authoritative
|
|
||||||
):
|
|
||||||
# A concurrent QR login may have committed a newer token.
|
|
||||||
# Never let an older runtime snapshot overwrite it.
|
|
||||||
return
|
|
||||||
data = {
|
data = {
|
||||||
"token": self._token,
|
"token": self._token,
|
||||||
"get_updates_buf": self._get_updates_buf,
|
"get_updates_buf": self._get_updates_buf,
|
||||||
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
|
|||||||
self._token = token
|
self._token = token
|
||||||
if base_url:
|
if base_url:
|
||||||
self.config.base_url = base_url
|
self.config.base_url = base_url
|
||||||
self._save_state(force=True)
|
self._save_state()
|
||||||
|
|
||||||
async def connect_close_client(self) -> None:
|
async def connect_close_client(self) -> None:
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
|
|||||||
remaining = self._session_pause_remaining_s()
|
remaining = self._session_pause_remaining_s()
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
await asyncio.sleep(remaining)
|
await asyncio.sleep(remaining)
|
||||||
if not self.config.token:
|
|
||||||
self._load_state()
|
|
||||||
return
|
return
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
|
|||||||
@@ -98,80 +98,6 @@ def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
|||||||
assert restored._context_tokens == {"wx-user": "ctx-1"}
|
assert restored._context_tokens == {"wx-user": "ctx-1"}
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_preserves_token_committed_by_another_instance(tmp_path) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._token = "old-token"
|
|
||||||
channel._save_state()
|
|
||||||
|
|
||||||
replacement = {
|
|
||||||
"token": "new-token",
|
|
||||||
"base_url": "https://new.example",
|
|
||||||
"get_updates_buf": "",
|
|
||||||
"context_tokens": {},
|
|
||||||
"typing_tickets": {},
|
|
||||||
}
|
|
||||||
(tmp_path / "account.json").write_text(json.dumps(replacement), encoding="utf-8")
|
|
||||||
|
|
||||||
channel._get_updates_buf = "stale-cursor"
|
|
||||||
channel._save_state()
|
|
||||||
|
|
||||||
assert json.loads((tmp_path / "account.json").read_text()) == replacement
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_force_overwrites_replaced_token(tmp_path) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
(tmp_path / "account.json").write_text(json.dumps({"token": "old-token"}), encoding="utf-8")
|
|
||||||
|
|
||||||
channel.connect_commit_account(token="new-token", base_url="https://new.example")
|
|
||||||
|
|
||||||
saved = json.loads((tmp_path / "account.json").read_text())
|
|
||||||
assert saved["token"] == "new-token"
|
|
||||||
assert saved["base_url"] == "https://new.example"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._token = "configured-token"
|
|
||||||
channel._get_updates_buf = "current-cursor"
|
|
||||||
(tmp_path / "account.json").write_text(
|
|
||||||
json.dumps({"token": "stale-token", "get_updates_buf": "stale-cursor"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel._save_state()
|
|
||||||
|
|
||||||
saved = json.loads((tmp_path / "account.json").read_text())
|
|
||||||
assert saved["token"] == "configured-token"
|
|
||||||
assert saved["get_updates_buf"] == "current-cursor"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
persisted = {"token": "persisted-token", "get_updates_buf": "persisted-cursor"}
|
|
||||||
(tmp_path / "account.json").write_text(json.dumps(persisted), encoding="utf-8")
|
|
||||||
|
|
||||||
channel._save_state()
|
|
||||||
|
|
||||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
@@ -536,56 +462,6 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
|||||||
assert channel._session_pause_remaining_s() > 0
|
assert channel._session_pause_remaining_s() > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poll_once_reloads_refreshed_state_after_session_pause(
|
|
||||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._token = "old-token"
|
|
||||||
channel._save_state()
|
|
||||||
(tmp_path / "account.json").write_text(
|
|
||||||
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel._session_pause_until = time.time() + 10
|
|
||||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
|
||||||
|
|
||||||
await channel._poll_once()
|
|
||||||
|
|
||||||
assert channel._token == "new-token"
|
|
||||||
assert channel.config.base_url == "https://new.example"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poll_once_keeps_explicit_token_after_session_pause(
|
|
||||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
||||||
) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel._token = "configured-token"
|
|
||||||
(tmp_path / "account.json").write_text(
|
|
||||||
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel._session_pause_until = time.time() + 10
|
|
||||||
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
|
||||||
|
|
||||||
await channel._poll_once()
|
|
||||||
|
|
||||||
assert channel._token == "configured-token"
|
|
||||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||||
no_qr_poll_delay,
|
no_qr_poll_delay,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Typer commands for foreground and background gateway control."""
|
"""Typer commands for foreground and background gateway control."""
|
||||||
|
|
||||||
|
# pyright: reportUnusedFunction=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -133,9 +135,8 @@ def create_gateway_app(
|
|||||||
console.print()
|
console.print()
|
||||||
console.print(result.content)
|
console.print(result.content)
|
||||||
|
|
||||||
# Typer consumes these callbacks through decorator registration.
|
|
||||||
@gateway_app.callback(invoke_without_command=True)
|
@gateway_app.callback(invoke_without_command=True)
|
||||||
def gateway( # pyright: ignore[reportUnusedFunction]
|
def gateway(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
@@ -190,7 +191,7 @@ def create_gateway_app(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@gateway_app.command("status")
|
@gateway_app.command("status")
|
||||||
def gateway_status( # pyright: ignore[reportUnusedFunction]
|
def gateway_status(
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -198,7 +199,7 @@ def create_gateway_app(
|
|||||||
print_status(runtime_for_instance(workspace=workspace, config=config).status())
|
print_status(runtime_for_instance(workspace=workspace, config=config).status())
|
||||||
|
|
||||||
@gateway_app.command("logs")
|
@gateway_app.command("logs")
|
||||||
def gateway_logs( # pyright: ignore[reportUnusedFunction]
|
def gateway_logs(
|
||||||
tail: int = typer.Option(200, "--tail", help="Number of recent lines to show"),
|
tail: int = typer.Option(200, "--tail", help="Number of recent lines to show"),
|
||||||
follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
|
follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
@@ -216,7 +217,7 @@ def create_gateway_app(
|
|||||||
console.print(line)
|
console.print(line)
|
||||||
|
|
||||||
@gateway_app.command("stop")
|
@gateway_app.command("stop")
|
||||||
def gateway_stop( # pyright: ignore[reportUnusedFunction]
|
def gateway_stop(
|
||||||
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
|
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
@@ -232,7 +233,7 @@ def create_gateway_app(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@gateway_app.command("restart")
|
@gateway_app.command("restart")
|
||||||
def gateway_restart( # pyright: ignore[reportUnusedFunction]
|
def gateway_restart(
|
||||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
@@ -265,7 +266,7 @@ def create_gateway_app(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@gateway_app.command("install-service")
|
@gateway_app.command("install-service")
|
||||||
def gateway_install_service( # pyright: ignore[reportUnusedFunction]
|
def gateway_install_service(
|
||||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
@@ -301,7 +302,7 @@ def create_gateway_app(
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
@gateway_app.command("uninstall-service")
|
@gateway_app.command("uninstall-service")
|
||||||
def gateway_uninstall_service( # pyright: ignore[reportUnusedFunction]
|
def gateway_uninstall_service(
|
||||||
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
|
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
|
||||||
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
|
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
|
||||||
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
|
||||||
|
|||||||
@@ -581,7 +581,6 @@ def _run_gateway(
|
|||||||
webui_runtime_model_name=_webui_runtime_model_name,
|
webui_runtime_model_name=_webui_runtime_model_name,
|
||||||
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
|
||||||
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
|
||||||
webui_cancel_active_turn=getattr(agent, "cancel_active_turn", None),
|
|
||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
|
|||||||
+11
-16
@@ -1,5 +1,7 @@
|
|||||||
"""Interactive onboarding questionnaire for nanobot."""
|
"""Interactive onboarding questionnaire for nanobot."""
|
||||||
|
|
||||||
|
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import types
|
import types
|
||||||
@@ -204,36 +206,35 @@ def _select_with_back(
|
|||||||
# Key bindings
|
# Key bindings
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
|
|
||||||
# KeyBindings consumes these handlers through decorator registration.
|
|
||||||
@bindings.add(Keys.Up)
|
@bindings.add(Keys.Up)
|
||||||
def _up(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _up(event: KeyPressEvent) -> None:
|
||||||
nonlocal selected_index
|
nonlocal selected_index
|
||||||
selected_index = (selected_index - 1) % len(choices)
|
selected_index = (selected_index - 1) % len(choices)
|
||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
|
|
||||||
@bindings.add(Keys.Down)
|
@bindings.add(Keys.Down)
|
||||||
def _down(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _down(event: KeyPressEvent) -> None:
|
||||||
nonlocal selected_index
|
nonlocal selected_index
|
||||||
selected_index = (selected_index + 1) % len(choices)
|
selected_index = (selected_index + 1) % len(choices)
|
||||||
event.app.invalidate()
|
event.app.invalidate()
|
||||||
|
|
||||||
@bindings.add(Keys.Enter)
|
@bindings.add(Keys.Enter)
|
||||||
def _enter(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _enter(event: KeyPressEvent) -> None:
|
||||||
state["result"] = choices[selected_index]
|
state["result"] = choices[selected_index]
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add("escape")
|
@bindings.add("escape")
|
||||||
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _escape(event: KeyPressEvent) -> None:
|
||||||
state["result"] = _BACK_PRESSED
|
state["result"] = _BACK_PRESSED
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add(Keys.Left)
|
@bindings.add(Keys.Left)
|
||||||
def _left(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _left(event: KeyPressEvent) -> None:
|
||||||
state["result"] = _BACK_PRESSED
|
state["result"] = _BACK_PRESSED
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@bindings.add(Keys.ControlC)
|
@bindings.add(Keys.ControlC)
|
||||||
def _ctrl_c(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _ctrl_c(event: KeyPressEvent) -> None:
|
||||||
state["result"] = None
|
state["result"] = None
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@@ -531,9 +532,8 @@ def _input_back_key_bindings() -> KeyBindings:
|
|||||||
"""Return key bindings that make Escape behave like a local back action."""
|
"""Return key bindings that make Escape behave like a local back action."""
|
||||||
bindings = KeyBindings()
|
bindings = KeyBindings()
|
||||||
|
|
||||||
# KeyBindings consumes this handler through decorator registration.
|
|
||||||
@bindings.add("escape")
|
@bindings.add("escape")
|
||||||
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
|
def _escape(event: KeyPressEvent) -> None:
|
||||||
event.app.exit(result=_BACK_PRESSED)
|
event.app.exit(result=_BACK_PRESSED)
|
||||||
|
|
||||||
return bindings
|
return bindings
|
||||||
@@ -1668,11 +1668,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# oauth-cli-kit does not publish type information.
|
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||||
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
|
|
||||||
get_token,
|
|
||||||
login_oauth_interactive,
|
|
||||||
)
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
return False
|
return False
|
||||||
@@ -1713,8 +1709,7 @@ def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> b
|
|||||||
if provider_name != "openai_codex":
|
if provider_name != "openai_codex":
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
# oauth-cli-kit does not publish type information.
|
from oauth_cli_kit import get_token
|
||||||
from oauth_cli_kit import get_token # pyright: ignore[reportMissingTypeStubs]
|
|
||||||
|
|
||||||
proxy = _quick_start_codex_proxy(config)
|
proxy = _quick_start_codex_proxy(config)
|
||||||
token = get_token(proxy=proxy)
|
token = get_token(proxy=proxy)
|
||||||
|
|||||||
@@ -203,7 +203,16 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
|
|||||||
"""Cancel all active tasks and subagents for the session."""
|
"""Cancel all active tasks and subagents for the session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
total = await loop.cancel_active_turn(ctx.key)
|
total = await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||||
|
# Also drain pending queue to prevent mid-turn injection deadlock
|
||||||
|
pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
|
||||||
|
if pending is not None:
|
||||||
|
while not pending.empty():
|
||||||
|
try:
|
||||||
|
pending.get_nowait()
|
||||||
|
total += 1
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
content = f"Stopped {total} task(s)." if total else "No active task to stop."
|
||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
channel=msg.channel, chat_id=msg.chat_id, content=content,
|
||||||
@@ -292,7 +301,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
|||||||
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
|
||||||
"""Stop active task and start a fresh session."""
|
"""Stop active task and start a fresh session."""
|
||||||
loop = ctx.loop
|
loop = ctx.loop
|
||||||
await loop.cancel_active_turn(ctx.key)
|
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
|
||||||
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
session = ctx.session or loop.sessions.get_or_create(ctx.key)
|
||||||
snapshot = session.messages[session.last_consolidated:]
|
snapshot = session.messages[session.last_consolidated:]
|
||||||
runtime = None
|
runtime = None
|
||||||
|
|||||||
+10
-28
@@ -504,7 +504,6 @@ class Config(BaseSettings):
|
|||||||
model_normalized = model_lower.replace("-", "_")
|
model_normalized = model_lower.replace("-", "_")
|
||||||
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
||||||
normalized_prefix = model_prefix.replace("-", "_")
|
normalized_prefix = model_prefix.replace("-", "_")
|
||||||
prefixed_provider = find_by_name(model_prefix) if model_prefix else None
|
|
||||||
|
|
||||||
def _kw_matches(kw: str) -> bool:
|
def _kw_matches(kw: str) -> bool:
|
||||||
kw = kw.lower()
|
kw = kw.lower()
|
||||||
@@ -534,22 +533,6 @@ class Config(BaseSettings):
|
|||||||
continue
|
continue
|
||||||
p = getattr(self.providers, spec.name, None)
|
p = getattr(self.providers, spec.name, None)
|
||||||
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
if p and any(_kw_matches(kw) for kw in spec.keywords):
|
||||||
# Local providers (Ollama, vLLM, …) keep model-family keywords
|
|
||||||
# like "nemotron" or "llama" to enable bare-model auto-routing,
|
|
||||||
# but those keywords collide with cloud-hosted variants of the
|
|
||||||
# same family (e.g. `nvidia/nemotron-...` via OpenRouter). Only
|
|
||||||
# honor a local keyword match when the user has actually
|
|
||||||
# configured that local endpoint via `api_base` — mirrors the
|
|
||||||
# gate already used by the local-fallback loop below.
|
|
||||||
if spec.is_local:
|
|
||||||
# A qualified model belongs to its explicit provider or a
|
|
||||||
# gateway fallback, never to a different local provider
|
|
||||||
# whose model-family keyword happens to match.
|
|
||||||
foreign_prefix = bool(
|
|
||||||
prefixed_provider is not None and prefixed_provider.name != spec.name
|
|
||||||
)
|
|
||||||
if not p.api_base or foreign_prefix:
|
|
||||||
continue
|
|
||||||
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
|
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
|
||||||
return p, spec.name
|
return p, spec.name
|
||||||
|
|
||||||
@@ -558,17 +541,16 @@ class Config(BaseSettings):
|
|||||||
# Prefer providers whose detect_by_base_keyword matches the configured api_base
|
# Prefer providers whose detect_by_base_keyword matches the configured api_base
|
||||||
# (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
|
# (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
|
||||||
local_fallback: tuple[ProviderConfig, str] | None = None
|
local_fallback: tuple[ProviderConfig, str] | None = None
|
||||||
if prefixed_provider is None:
|
for spec in PROVIDERS:
|
||||||
for spec in PROVIDERS:
|
if not spec.is_local:
|
||||||
if not spec.is_local:
|
continue
|
||||||
continue
|
p = getattr(self.providers, spec.name, None)
|
||||||
p = getattr(self.providers, spec.name, None)
|
if not (p and p.api_base):
|
||||||
if not (p and p.api_base):
|
continue
|
||||||
continue
|
if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
|
||||||
if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
|
return p, spec.name
|
||||||
return p, spec.name
|
if local_fallback is None:
|
||||||
if local_fallback is None:
|
local_fallback = (p, spec.name)
|
||||||
local_fallback = (p, spec.name)
|
|
||||||
if local_fallback:
|
if local_fallback:
|
||||||
return local_fallback
|
return local_fallback
|
||||||
|
|
||||||
|
|||||||
+28
-34
@@ -163,13 +163,9 @@ class CronService:
|
|||||||
self._store: CronStore | None = None
|
self._store: CronStore | None = None
|
||||||
self._timer_task: asyncio.Task[None] | None = None
|
self._timer_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._active_executions = 0
|
self._timer_active = False
|
||||||
self.max_sleep_ms = max_sleep_ms
|
self.max_sleep_ms = max_sleep_ms
|
||||||
|
|
||||||
def _should_persist_store(self) -> bool:
|
|
||||||
"""Return whether this instance currently owns the live store."""
|
|
||||||
return self._running or self._active_executions > 0
|
|
||||||
|
|
||||||
def _is_unbound_agent_job(self, job: CronJob) -> bool:
|
def _is_unbound_agent_job(self, job: CronJob) -> bool:
|
||||||
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
|
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
|
||||||
|
|
||||||
@@ -282,24 +278,23 @@ class CronService:
|
|||||||
logger.exception("load action line error")
|
logger.exception("load action line error")
|
||||||
continue
|
continue
|
||||||
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess]
|
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess]
|
||||||
if self._should_persist_store() and changed:
|
if self._running and changed:
|
||||||
self._action_path.write_text("", encoding="utf-8")
|
self._action_path.write_text("", encoding="utf-8")
|
||||||
self._save_store()
|
self._save_store()
|
||||||
return
|
return
|
||||||
|
|
||||||
def _load_store(self, *, reload_during_execution: bool = False) -> CronStore | None:
|
def _load_store(self) -> CronStore | None:
|
||||||
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
"""Load jobs from disk. Reloads automatically if file was modified externally.
|
||||||
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
- Reload every time because it needs to merge operations on the jobs object from other instances.
|
||||||
- During job execution, return the existing store to prevent concurrent
|
- During _on_timer execution, return the existing store to prevent concurrent
|
||||||
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
|
||||||
The first execution explicitly reloads once when it takes ownership.
|
|
||||||
- When the on-disk store exists but is unreadable: keep using the
|
- When the on-disk store exists but is unreadable: keep using the
|
||||||
previous in-memory ``self._store`` if we already have one (so a
|
previous in-memory ``self._store`` if we already have one (so a
|
||||||
transient corruption does not drop live jobs); only the very first
|
transient corruption does not drop live jobs); only the very first
|
||||||
load (during ``start``) can return ``None`` to signal an unrecoverable
|
load (during ``start``) can return ``None`` to signal an unrecoverable
|
||||||
state to the caller.
|
state to the caller.
|
||||||
"""
|
"""
|
||||||
if self._active_executions > 0 and self._store and not reload_during_execution:
|
if self._timer_active and self._store:
|
||||||
return self._store
|
return self._store
|
||||||
loaded = self._load_jobs()
|
loaded = self._load_jobs()
|
||||||
if loaded is None:
|
if loaded is None:
|
||||||
@@ -312,12 +307,12 @@ class CronService:
|
|||||||
jobs, version = loaded
|
jobs, version = loaded
|
||||||
self._store = CronStore(version=version, jobs=jobs)
|
self._store = CronStore(version=version, jobs=jobs)
|
||||||
self._merge_action()
|
self._merge_action()
|
||||||
if self._enforce_store_agent_bindings() and self._should_persist_store():
|
if self._enforce_store_agent_bindings() and self._running:
|
||||||
self._save_store()
|
self._save_store()
|
||||||
|
|
||||||
return self._store
|
return self._store
|
||||||
|
|
||||||
def _require_store(self, *, reload_during_execution: bool = False) -> CronStore:
|
def _require_store(self) -> CronStore:
|
||||||
"""Return a usable store or raise a clear error.
|
"""Return a usable store or raise a clear error.
|
||||||
|
|
||||||
``_load_store`` deliberately returns ``None`` when the first load sees
|
``_load_store`` deliberately returns ``None`` when the first load sees
|
||||||
@@ -327,7 +322,7 @@ class CronService:
|
|||||||
``AttributeError`` and, more importantly, prevents follow-up saves from
|
``AttributeError`` and, more importantly, prevents follow-up saves from
|
||||||
treating a corrupt store as an empty one.
|
treating a corrupt store as an empty one.
|
||||||
"""
|
"""
|
||||||
store = self._load_store(reload_during_execution=reload_during_execution)
|
store = self._load_store()
|
||||||
if store is None:
|
if store is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"cron store at {self.store_path} could not be loaded and was preserved "
|
f"cron store at {self.store_path} could not be loaded and was preserved "
|
||||||
@@ -509,20 +504,19 @@ class CronService:
|
|||||||
|
|
||||||
async def _on_timer(self) -> None:
|
async def _on_timer(self) -> None:
|
||||||
"""Handle timer tick - run due jobs."""
|
"""Handle timer tick - run due jobs."""
|
||||||
reload_store = self._active_executions == 0
|
self._load_store()
|
||||||
self._active_executions += 1
|
# If a hot reload found a corrupt store on disk, ``self._store`` may
|
||||||
try:
|
# still hold the previous, known-good in-memory snapshot. Keep using
|
||||||
store = self._load_store(reload_during_execution=reload_store)
|
# it rather than crashing the timer or wiping live jobs.
|
||||||
# If a hot reload found a corrupt store on disk, ``self._store`` may
|
if not self._store:
|
||||||
# still hold the previous, known-good in-memory snapshot. Keep using
|
self._arm_timer()
|
||||||
# it rather than crashing the timer or wiping live jobs.
|
return
|
||||||
if store is None:
|
|
||||||
self._arm_timer()
|
|
||||||
return
|
|
||||||
|
|
||||||
|
self._timer_active = True
|
||||||
|
try:
|
||||||
now = _now_ms()
|
now = _now_ms()
|
||||||
due_jobs = [
|
due_jobs = [
|
||||||
j for j in store.jobs
|
j for j in self._store.jobs
|
||||||
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -531,7 +525,7 @@ class CronService:
|
|||||||
|
|
||||||
self._save_store()
|
self._save_store()
|
||||||
finally:
|
finally:
|
||||||
self._active_executions -= 1
|
self._timer_active = False
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
|
|
||||||
async def _execute_job(self, job: CronJob) -> None:
|
async def _execute_job(self, job: CronJob) -> None:
|
||||||
@@ -663,7 +657,7 @@ class CronService:
|
|||||||
)
|
)
|
||||||
_normalize_agent_turn_job(job)
|
_normalize_agent_turn_job(job)
|
||||||
self._enforce_agent_binding(job)
|
self._enforce_agent_binding(job)
|
||||||
if self._should_persist_store():
|
if self._running:
|
||||||
store = self._require_store()
|
store = self._require_store()
|
||||||
store.jobs.append(job)
|
store.jobs.append(job)
|
||||||
self._save_store()
|
self._save_store()
|
||||||
@@ -703,7 +697,7 @@ class CronService:
|
|||||||
removed = len(store.jobs) < before
|
removed = len(store.jobs) < before
|
||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
if self._should_persist_store():
|
if self._running:
|
||||||
self._save_store()
|
self._save_store()
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
else:
|
else:
|
||||||
@@ -725,7 +719,7 @@ class CronService:
|
|||||||
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
|
||||||
else:
|
else:
|
||||||
job.state.next_run_at_ms = None
|
job.state.next_run_at_ms = None
|
||||||
if self._should_persist_store():
|
if self._running:
|
||||||
self._save_store()
|
self._save_store()
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
else:
|
else:
|
||||||
@@ -781,7 +775,7 @@ class CronService:
|
|||||||
else:
|
else:
|
||||||
job.state.next_run_at_ms = None
|
job.state.next_run_at_ms = None
|
||||||
|
|
||||||
if self._should_persist_store():
|
if self._running:
|
||||||
self._save_store()
|
self._save_store()
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
else:
|
else:
|
||||||
@@ -792,10 +786,10 @@ class CronService:
|
|||||||
|
|
||||||
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
async def run_job(self, job_id: str, force: bool = False) -> bool:
|
||||||
"""Manually run a job without disturbing the service's running state."""
|
"""Manually run a job without disturbing the service's running state."""
|
||||||
reload_store = self._active_executions == 0
|
was_running = self._running
|
||||||
self._active_executions += 1
|
self._running = True
|
||||||
try:
|
try:
|
||||||
store = self._require_store(reload_during_execution=reload_store)
|
store = self._require_store()
|
||||||
for job in store.jobs:
|
for job in store.jobs:
|
||||||
if job.id == job_id:
|
if job.id == job_id:
|
||||||
if self._is_unbound_agent_job(job):
|
if self._is_unbound_agent_job(job):
|
||||||
@@ -809,8 +803,8 @@ class CronService:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
self._active_executions -= 1
|
self._running = was_running
|
||||||
if self._running and self._active_executions == 0:
|
if was_running:
|
||||||
self._arm_timer()
|
self._arm_timer()
|
||||||
|
|
||||||
def get_job(self, job_id: str) -> CronJob | None:
|
def get_job(self, job_id: str) -> CronJob | None:
|
||||||
|
|||||||
@@ -40,15 +40,9 @@ def _load() -> dict[str, Any]:
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {"approved": {}, "pending": {}}
|
return {"approved": {}, "pending": {}}
|
||||||
except json.JSONDecodeError:
|
except (json.JSONDecodeError, OSError):
|
||||||
logger.warning("Corrupted pairing store, resetting")
|
logger.warning("Corrupted pairing store, resetting")
|
||||||
return {"approved": {}, "pending": {}}
|
return {"approved": {}, "pending": {}}
|
||||||
except OSError:
|
|
||||||
# A transiently locked or busy file is not corruption. Propagate so
|
|
||||||
# mutating callers fail loudly instead of persisting an empty view
|
|
||||||
# that would erase every approved sender.
|
|
||||||
logger.warning("Pairing store temporarily unreadable: {}", path)
|
|
||||||
raise
|
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
logger.warning("Corrupted pairing store, resetting")
|
logger.warning("Corrupted pairing store, resetting")
|
||||||
return {"approved": {}, "pending": {}}
|
return {"approved": {}, "pending": {}}
|
||||||
@@ -177,11 +171,7 @@ def deny_code(code: str) -> bool:
|
|||||||
def is_approved(channel: str, sender_id: str) -> bool:
|
def is_approved(channel: str, sender_id: str) -> bool:
|
||||||
"""Check whether *sender_id* has been approved on *channel*."""
|
"""Check whether *sender_id* has been approved on *channel*."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
try:
|
data = _load()
|
||||||
data = _load()
|
|
||||||
except OSError:
|
|
||||||
# Fail closed for this check; the store itself stays untouched.
|
|
||||||
return False
|
|
||||||
approved: dict[str, set[str]] = data.get("approved", {})
|
approved: dict[str, set[str]] = data.get("approved", {})
|
||||||
return str(sender_id) in approved.get(channel, set())
|
return str(sender_id) in approved.get(channel, set())
|
||||||
|
|
||||||
@@ -189,10 +179,7 @@ def is_approved(channel: str, sender_id: str) -> bool:
|
|||||||
def list_pending() -> list[dict[str, Any]]:
|
def list_pending() -> list[dict[str, Any]]:
|
||||||
"""Return all non-expired pending pairing requests."""
|
"""Return all non-expired pending pairing requests."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
try:
|
data = _load()
|
||||||
data = _load()
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
_gc_pending(data)
|
_gc_pending(data)
|
||||||
return [
|
return [
|
||||||
{"code": code, **info}
|
{"code": code, **info}
|
||||||
@@ -270,10 +257,7 @@ def clear_channel(channel: str) -> dict[str, int]:
|
|||||||
def get_approved(channel: str) -> list[str]:
|
def get_approved(channel: str) -> list[str]:
|
||||||
"""Return all approved sender IDs for *channel*."""
|
"""Return all approved sender IDs for *channel*."""
|
||||||
with _LOCK:
|
with _LOCK:
|
||||||
try:
|
data = _load()
|
||||||
data = _load()
|
|
||||||
except OSError:
|
|
||||||
return []
|
|
||||||
return sorted(data.get("approved", {}).get(channel, set()))
|
return sorted(data.get("approved", {}).get(channel, set()))
|
||||||
|
|
||||||
|
|
||||||
@@ -299,15 +283,6 @@ def handle_pairing_command(channel: str, subcommand_text: str) -> str:
|
|||||||
This is a pure function (no side effects other than store mutations)
|
This is a pure function (no side effects other than store mutations)
|
||||||
so it can be used from both the CLI and the agent CommandRouter.
|
so it can be used from both the CLI and the agent CommandRouter.
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
return _handle_pairing_subcommand(channel, subcommand_text)
|
|
||||||
except OSError:
|
|
||||||
# Mutations fail loudly on a transient I/O error instead of lying
|
|
||||||
# ("invalid code") or silently rewriting the store from an empty view.
|
|
||||||
return "The pairing store is temporarily unavailable. Please try again."
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_pairing_subcommand(channel: str, subcommand_text: str) -> str:
|
|
||||||
parts = subcommand_text.split()
|
parts = subcommand_text.split()
|
||||||
sub = parts[0] if parts else "list"
|
sub = parts[0] if parts else "list"
|
||||||
arg = parts[1] if len(parts) > 1 else None
|
arg = parts[1] if len(parts) > 1 else None
|
||||||
|
|||||||
@@ -23,26 +23,14 @@ import uuid
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
ResponsesStreamCapture,
|
|
||||||
build_responses_state,
|
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
|
convert_messages,
|
||||||
convert_tools,
|
convert_tools,
|
||||||
is_compaction_compatibility_error,
|
|
||||||
is_replayable_finish_reason,
|
|
||||||
parse_response_output,
|
parse_response_output,
|
||||||
prepare_responses_input,
|
|
||||||
resolve_compact_threshold,
|
|
||||||
responses_state_matches,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
|
_AZURE_OPENAI_SCOPE = "https://cognitiveservices.azure.com/.default"
|
||||||
@@ -109,7 +97,6 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
):
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self._native_compaction_available = True
|
|
||||||
|
|
||||||
if not api_base:
|
if not api_base:
|
||||||
raise ValueError("Azure OpenAI api_base is required")
|
raise ValueError("Azure OpenAI api_base is required")
|
||||||
@@ -155,25 +142,6 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
name = deployment_name.lower()
|
name = deployment_name.lower()
|
||||||
return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
|
return not any(token in name for token in ("gpt-5", "o1", "o3", "o4"))
|
||||||
|
|
||||||
def _responses_state_provider(self) -> str:
|
|
||||||
return f"azure_openai:{str(self.api_base).rstrip('/')}"
|
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return responses_state_matches(
|
|
||||||
state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=model or self.default_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
"""Azure's native Responses endpoint accepts context management."""
|
|
||||||
_ = model
|
|
||||||
return self._native_compaction_available
|
|
||||||
|
|
||||||
def _build_body(
|
def _build_body(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -183,26 +151,10 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
tool_choice: str | dict[str, Any] | None,
|
tool_choice: str | dict[str, Any] | None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build the Responses API request body from Chat-Completions-style args."""
|
"""Build the Responses API request body from Chat-Completions-style args."""
|
||||||
deployment = model or self.default_model
|
deployment = model or self.default_model
|
||||||
sanitized_messages = self._sanitize_empty_content(messages)
|
instructions, input_items = convert_messages(self._sanitize_empty_content(messages))
|
||||||
sanitized_state = (
|
|
||||||
provider_context.conversation_state
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if sanitized_state is not None:
|
|
||||||
sanitized_state = sanitized_state.with_pending_messages(
|
|
||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
|
||||||
)
|
|
||||||
instructions, input_items, replayed = prepare_responses_input(
|
|
||||||
sanitized_messages,
|
|
||||||
state=sanitized_state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=deployment,
|
|
||||||
)
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"model": deployment,
|
"model": deployment,
|
||||||
@@ -212,29 +164,13 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
"store": False,
|
"store": False,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
}
|
}
|
||||||
compact_threshold = resolve_compact_threshold(
|
|
||||||
(
|
|
||||||
provider_context.context_window_tokens
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
max_tokens,
|
|
||||||
)
|
|
||||||
if self.supports_native_compaction(deployment) and compact_threshold is not None:
|
|
||||||
body["context_management"] = [{
|
|
||||||
"type": "compaction",
|
|
||||||
"compact_threshold": compact_threshold,
|
|
||||||
}]
|
|
||||||
|
|
||||||
if self._supports_temperature(deployment, reasoning_effort):
|
if self._supports_temperature(deployment, reasoning_effort):
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
|
||||||
if not self._supports_temperature(deployment, reasoning_effort):
|
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
if replayed and "gpt-5.6" in deployment.lower():
|
body["include"] = ["reasoning.encrypted_content"]
|
||||||
body.setdefault("reasoning", {})["context"] = "all_turns"
|
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
@@ -242,97 +178,21 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
async def _create_response_with_compaction_fallback(
|
|
||||||
self,
|
|
||||||
body: dict[str, Any],
|
|
||||||
) -> Any:
|
|
||||||
"""Retry once without server compaction when Azure rejects the option."""
|
|
||||||
try:
|
|
||||||
return cast(Any, await self._client.responses.create(**body))
|
|
||||||
except Exception as exc:
|
|
||||||
if (
|
|
||||||
"context_management" not in body
|
|
||||||
or not is_compaction_compatibility_error(exc)
|
|
||||||
):
|
|
||||||
raise
|
|
||||||
self._native_compaction_available = False
|
|
||||||
body.pop("context_management", None)
|
|
||||||
logger.warning(
|
|
||||||
"Azure Responses server compaction unsupported; disabled for this provider "
|
|
||||||
"instance (status={})",
|
|
||||||
getattr(exc, "status_code", None),
|
|
||||||
)
|
|
||||||
return cast(Any, await self._client.responses.create(**body))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_error(e: Exception) -> LLMResponse:
|
def _handle_error(e: Exception) -> LLMResponse:
|
||||||
response = getattr(e, "response", None)
|
response = getattr(e, "response", None)
|
||||||
body = getattr(e, "body", None) or getattr(response, "text", None)
|
body = getattr(e, "body", None) or getattr(response, "text", None)
|
||||||
body_text = str(body).strip() if body is not None else ""
|
body_text = str(body).strip() if body is not None else ""
|
||||||
msg = f"Error: {body_text[:500]}" if body_text else f"Error calling Azure OpenAI: {e}"
|
msg = f"Error: {body_text[:500]}" if body_text else f"Error calling Azure OpenAI: {e}"
|
||||||
headers = getattr(response, "headers", None)
|
retry_after = LLMProvider._extract_retry_after_from_headers(getattr(response, "headers", None))
|
||||||
retry_after = LLMProvider._extract_retry_after_from_headers(headers)
|
|
||||||
if retry_after is None:
|
if retry_after is None:
|
||||||
retry_after = LLMProvider._extract_retry_after(msg)
|
retry_after = LLMProvider._extract_retry_after(msg)
|
||||||
status_code = getattr(e, "status_code", None)
|
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
|
||||||
if status_code is None and response is not None:
|
|
||||||
status_code = getattr(response, "status_code", None)
|
|
||||||
error_type, error_code = LLMProvider._extract_error_type_code(body)
|
|
||||||
should_retry: bool | None = None
|
|
||||||
if headers is not None:
|
|
||||||
raw_should_retry = headers.get("x-should-retry")
|
|
||||||
if isinstance(raw_should_retry, str):
|
|
||||||
lowered = raw_should_retry.strip().lower()
|
|
||||||
if lowered == "true":
|
|
||||||
should_retry = True
|
|
||||||
elif lowered == "false":
|
|
||||||
should_retry = False
|
|
||||||
error_name = type(e).__name__.lower()
|
|
||||||
error_kind = (
|
|
||||||
"timeout"
|
|
||||||
if "timeout" in error_name
|
|
||||||
else "connection"
|
|
||||||
if "connection" in error_name
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return LLMResponse(
|
|
||||||
content=msg,
|
|
||||||
finish_reason="error",
|
|
||||||
retry_after=retry_after,
|
|
||||||
error_status_code=int(status_code) if status_code is not None else None,
|
|
||||||
error_kind=error_kind,
|
|
||||||
error_type=error_type,
|
|
||||||
error_code=error_code,
|
|
||||||
error_retry_after_s=retry_after,
|
|
||||||
error_should_retry=should_retry,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_stream_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat_stream(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -342,21 +202,14 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
body = self._build_body(
|
body = self._build_body(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
provider_context,
|
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
response = await self._create_response_with_compaction_fallback(body)
|
response = cast(Any, await self._client.responses.create(**body))
|
||||||
return parse_response_output(
|
return parse_response_output(response)
|
||||||
response,
|
|
||||||
state_provider=self._responses_state_provider(),
|
|
||||||
state_model=str(body["model"]),
|
|
||||||
state_input_items=cast(list[dict[str, Any]], body["input"]),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e)
|
return self._handle_error(e)
|
||||||
|
|
||||||
@@ -372,43 +225,26 @@ class AzureOpenAIProvider(LLMProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
_ = on_thinking_delta
|
_ = on_thinking_delta
|
||||||
body = self._build_body(
|
body = self._build_body(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
provider_context,
|
|
||||||
)
|
)
|
||||||
body["stream"] = True
|
body["stream"] = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
stream = await self._create_response_with_compaction_fallback(body)
|
stream = cast(Any, await self._client.responses.create(**body))
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
content, tool_calls, finish_reason, usage, reasoning_content = (
|
content, tool_calls, finish_reason, usage, reasoning_content = (
|
||||||
await consume_sdk_stream(
|
await consume_sdk_stream(stream, on_content_delta, on_tool_call_delta)
|
||||||
stream,
|
|
||||||
on_content_delta,
|
|
||||||
on_tool_call_delta,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
result = LLMResponse(
|
return LLMResponse(
|
||||||
content=content or None,
|
content=content or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
reasoning_content=reasoning_content,
|
reasoning_content=reasoning_content,
|
||||||
)
|
)
|
||||||
if capture.completed and is_replayable_finish_reason(finish_reason):
|
|
||||||
result.provider_state = build_responses_state(
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=str(body["model"]),
|
|
||||||
input_items=cast(list[dict[str, Any]], body["input"]),
|
|
||||||
output_items=capture.output_items,
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self._handle_error(e)
|
return self._handle_error(e)
|
||||||
|
|
||||||
|
|||||||
+8
-201
@@ -1,7 +1,5 @@
|
|||||||
"""Base LLM provider interface."""
|
"""Base LLM provider interface."""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -9,7 +7,6 @@ import re
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email.utils import parsedate_to_datetime
|
from email.utils import parsedate_to_datetime
|
||||||
@@ -153,104 +150,6 @@ def tool_arguments_json_for_replay(arguments: Any) -> str:
|
|||||||
return json.dumps(tool_arguments_object_for_replay(arguments), ensure_ascii=False)
|
return json.dumps(tool_arguments_object_for_replay(arguments), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProviderConversationState:
|
|
||||||
"""Opaque provider-owned continuation state.
|
|
||||||
|
|
||||||
``payload`` may contain encrypted reasoning or other provider-private
|
|
||||||
protocol items. Keep it out of normal logs and public chat history.
|
|
||||||
``pending_messages`` are Chat-style messages produced after the most
|
|
||||||
recent provider response and are materialized by the owning provider on
|
|
||||||
the next request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
kind: str
|
|
||||||
provider: str
|
|
||||||
model: str
|
|
||||||
version: int
|
|
||||||
payload: dict[str, Any] = field(default_factory=dict, repr=False)
|
|
||||||
pending_messages: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
||||||
|
|
||||||
def with_pending_messages(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> ProviderConversationState:
|
|
||||||
"""Return a state copy with an isolated pending-message list."""
|
|
||||||
return ProviderConversationState(
|
|
||||||
kind=self.kind,
|
|
||||||
provider=self.provider,
|
|
||||||
model=self.model,
|
|
||||||
version=self.version,
|
|
||||||
payload=self.payload,
|
|
||||||
pending_messages=deepcopy(messages),
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_private_record(self) -> dict[str, Any]:
|
|
||||||
"""Serialize for the private session sidecar, never for public history."""
|
|
||||||
return {
|
|
||||||
"kind": self.kind,
|
|
||||||
"provider": self.provider,
|
|
||||||
"model": self.model,
|
|
||||||
"version": self.version,
|
|
||||||
"payload": deepcopy(self.payload),
|
|
||||||
"pending_messages": deepcopy(self.pending_messages),
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_private_record(
|
|
||||||
cls,
|
|
||||||
value: object,
|
|
||||||
) -> ProviderConversationState | None:
|
|
||||||
"""Validate and deserialize a private session-sidecar value."""
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return None
|
|
||||||
data = cast(dict[str, Any], value)
|
|
||||||
kind = data.get("kind")
|
|
||||||
provider = data.get("provider")
|
|
||||||
model = data.get("model")
|
|
||||||
version = data.get("version")
|
|
||||||
payload = data.get("payload")
|
|
||||||
pending = data.get("pending_messages", [])
|
|
||||||
if (
|
|
||||||
not isinstance(kind, str)
|
|
||||||
or not kind
|
|
||||||
or not isinstance(provider, str)
|
|
||||||
or not provider
|
|
||||||
or not isinstance(model, str)
|
|
||||||
or not model
|
|
||||||
or isinstance(version, bool)
|
|
||||||
or not isinstance(version, int)
|
|
||||||
or not isinstance(payload, dict)
|
|
||||||
or not isinstance(pending, list)
|
|
||||||
or any(
|
|
||||||
not isinstance(message, dict)
|
|
||||||
for message in cast(list[object], pending)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return cls(
|
|
||||||
kind=kind,
|
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
version=version,
|
|
||||||
payload=deepcopy(cast(dict[str, Any], payload)),
|
|
||||||
pending_messages=deepcopy(cast(list[dict[str, Any]], pending)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProviderCallContext:
|
|
||||||
"""Optional provider-owned continuation data for one model request.
|
|
||||||
|
|
||||||
The regular ``chat`` contract stays provider-agnostic. Responses-capable
|
|
||||||
providers consume this context through the opt-in ``chat_with_context``
|
|
||||||
hooks, while every other provider inherits the context-free delegation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
conversation_state: ProviderConversationState | None = field(default=None, repr=False)
|
|
||||||
context_window_tokens: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LLMResponse:
|
class LLMResponse:
|
||||||
"""Response from an LLM provider."""
|
"""Response from an LLM provider."""
|
||||||
@@ -261,10 +160,6 @@ class LLMResponse:
|
|||||||
retry_after: float | None = None # Provider supplied retry wait in seconds.
|
retry_after: float | None = None # Provider supplied retry wait in seconds.
|
||||||
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
reasoning_content: str | None = None # Kimi, DeepSeek-R1, MiMo etc.
|
||||||
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
thinking_blocks: list[dict[str, Any]] | None = None # Anthropic extended thinking
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
|
||||||
# Routing wrappers may preserve or discard an incoming provider-owned
|
|
||||||
# continuation independently of the final fallback error's retry policy.
|
|
||||||
preserve_provider_state_on_error: bool | None = field(default=None, repr=False)
|
|
||||||
# Structured error metadata used by retry policy when finish_reason == "error".
|
# Structured error metadata used by retry policy when finish_reason == "error".
|
||||||
error_status_code: int | None = None
|
error_status_code: int | None = None
|
||||||
error_kind: str | None = None # e.g. "timeout", "connection"
|
error_kind: str | None = None # e.g. "timeout", "connection"
|
||||||
@@ -379,18 +274,6 @@ class LLMProvider(ABC):
|
|||||||
self.api_base = api_base
|
self.api_base = api_base
|
||||||
self.generation: GenerationSettings = GenerationSettings()
|
self.generation: GenerationSettings = GenerationSettings()
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Whether this provider can safely consume an opaque saved state."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
"""Whether requests may include provider-native context compaction."""
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _sanitize_empty_content(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Sanitize message content: fix empty blocks, strip internal _meta fields.
|
"""Sanitize message content: fix empty blocks, strip internal _meta fields.
|
||||||
@@ -533,7 +416,7 @@ class LLMProvider(ABC):
|
|||||||
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
|
return any(marker in err for marker in cls._TRANSIENT_ERROR_MARKERS)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_transient_response(cls, response: LLMResponse) -> bool:
|
def _is_transient_response(cls, response: LLMResponse) -> bool:
|
||||||
"""Prefer structured error metadata, fallback to text markers for legacy providers."""
|
"""Prefer structured error metadata, fallback to text markers for legacy providers."""
|
||||||
if response.error_should_retry is not None:
|
if response.error_should_retry is not None:
|
||||||
return bool(response.error_should_retry)
|
return bool(response.error_should_retry)
|
||||||
@@ -724,21 +607,6 @@ class LLMProvider(ABC):
|
|||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result if found else None
|
return result if found else None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _contains_image_content(value: object) -> bool:
|
|
||||||
"""Return whether a JSON-like provider payload contains an input image."""
|
|
||||||
if isinstance(value, dict):
|
|
||||||
mapping = cast(dict[str, object], value)
|
|
||||||
if mapping.get("type") in {"image_url", "input_image"}:
|
|
||||||
return True
|
|
||||||
return any(LLMProvider._contains_image_content(item) for item in mapping.values())
|
|
||||||
if isinstance(value, list):
|
|
||||||
return any(
|
|
||||||
LLMProvider._contains_image_content(item)
|
|
||||||
for item in cast(list[object], value)
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
def _strip_image_content_inplace(messages: list[dict[str, Any]]) -> bool:
|
||||||
"""Replace image_url blocks with text placeholder *in-place*.
|
"""Replace image_url blocks with text placeholder *in-place*.
|
||||||
@@ -765,12 +633,6 @@ class LLMProvider(ABC):
|
|||||||
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
|
async def _safe_chat(self, **kwargs: Any) -> LLMResponse:
|
||||||
"""Call chat() and convert unexpected exceptions to error responses."""
|
"""Call chat() and convert unexpected exceptions to error responses."""
|
||||||
try:
|
try:
|
||||||
provider_context = kwargs.pop("provider_context", None)
|
|
||||||
if isinstance(provider_context, ProviderCallContext):
|
|
||||||
return await self.chat_with_context(
|
|
||||||
provider_context=provider_context,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return await self.chat(**kwargs)
|
return await self.chat(**kwargs)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -804,47 +666,17 @@ class LLMProvider(ABC):
|
|||||||
"""
|
"""
|
||||||
_ = on_thinking_delta, on_tool_call_delta
|
_ = on_thinking_delta, on_tool_call_delta
|
||||||
response = await self.chat(
|
response = await self.chat(
|
||||||
messages=messages,
|
messages=messages, tools=tools, model=model,
|
||||||
tools=tools,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
model=model,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
max_tokens=max_tokens,
|
|
||||||
temperature=temperature,
|
|
||||||
reasoning_effort=reasoning_effort,
|
|
||||||
tool_choice=tool_choice,
|
|
||||||
)
|
)
|
||||||
if on_content_delta and response.content:
|
if on_content_delta and response.content:
|
||||||
await on_content_delta(response.content)
|
await on_content_delta(response.content)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
"""Opt-in continuation hook; ordinary providers delegate to ``chat``."""
|
|
||||||
_ = provider_context
|
|
||||||
return await self.chat(**kwargs)
|
|
||||||
|
|
||||||
async def chat_stream_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
"""Streaming continuation hook with a context-free default."""
|
|
||||||
_ = provider_context
|
|
||||||
return await self.chat_stream(**kwargs)
|
|
||||||
|
|
||||||
async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
|
async def _safe_chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
"""Call chat_stream() and convert unexpected exceptions to error responses."""
|
"""Call chat_stream() and convert unexpected exceptions to error responses."""
|
||||||
try:
|
try:
|
||||||
provider_context = kwargs.pop("provider_context", None)
|
|
||||||
if isinstance(provider_context, ProviderCallContext):
|
|
||||||
return await self.chat_stream_with_context(
|
|
||||||
provider_context=provider_context,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
return await self.chat_stream(**kwargs)
|
return await self.chat_stream(**kwargs)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
@@ -866,7 +698,6 @@ class LLMProvider(ABC):
|
|||||||
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat_stream() with retry on transient provider failures."""
|
"""Call chat_stream() with retry on transient provider failures."""
|
||||||
if max_tokens is self._SENTINEL or max_tokens is None:
|
if max_tokens is self._SENTINEL or max_tokens is None:
|
||||||
@@ -899,8 +730,6 @@ class LLMProvider(ABC):
|
|||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
)
|
)
|
||||||
if provider_context is not None:
|
|
||||||
kw["provider_context"] = provider_context
|
|
||||||
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
|
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
|
||||||
kw["on_stream_recover"] = _recover_stream
|
kw["on_stream_recover"] = _recover_stream
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
@@ -924,7 +753,6 @@ class LLMProvider(ABC):
|
|||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
retry_mode: str = "standard",
|
retry_mode: str = "standard",
|
||||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Call chat() with retry on transient provider failures.
|
"""Call chat() with retry on transient provider failures.
|
||||||
|
|
||||||
@@ -947,8 +775,6 @@ class LLMProvider(ABC):
|
|||||||
max_tokens=max_tokens, temperature=temperature,
|
max_tokens=max_tokens, temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
reasoning_effort=reasoning_effort, tool_choice=tool_choice,
|
||||||
)
|
)
|
||||||
if provider_context is not None:
|
|
||||||
kw["provider_context"] = provider_context
|
|
||||||
return await self._run_with_retry(
|
return await self._run_with_retry(
|
||||||
self._safe_chat,
|
self._safe_chat,
|
||||||
kw,
|
kw,
|
||||||
@@ -1106,33 +932,14 @@ class LLMProvider(ABC):
|
|||||||
last_error_key = error_key
|
last_error_key = error_key
|
||||||
identical_error_count = 1 if error_key else 0
|
identical_error_count = 1 if error_key else 0
|
||||||
|
|
||||||
if not self.is_transient_response(response):
|
if not self._is_transient_response(response):
|
||||||
stripped = self._strip_image_content(kw["messages"])
|
stripped = self._strip_image_content(original_messages)
|
||||||
provider_context = kw.get("provider_context")
|
if stripped is not None and stripped != kw["messages"]:
|
||||||
stripped_context: ProviderCallContext | None = None
|
|
||||||
if isinstance(provider_context, ProviderCallContext):
|
|
||||||
state = provider_context.conversation_state
|
|
||||||
if state is not None and (
|
|
||||||
stripped is not None
|
|
||||||
or self._strip_image_content(state.pending_messages) is not None
|
|
||||||
or self._contains_image_content(state.payload)
|
|
||||||
):
|
|
||||||
# Provider-owned payloads may retain earlier input_image items.
|
|
||||||
# Rebuild from the stripped public transcript for this retry.
|
|
||||||
stripped_context = ProviderCallContext(
|
|
||||||
context_window_tokens=(
|
|
||||||
provider_context.context_window_tokens
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if stripped is not None or stripped_context is not None:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Non-transient LLM error with image content, retrying without images"
|
"Non-transient LLM error with image content, retrying without images"
|
||||||
)
|
)
|
||||||
retry_kw = dict(kw)
|
retry_kw = dict(kw)
|
||||||
if stripped is not None:
|
retry_kw["messages"] = stripped
|
||||||
retry_kw["messages"] = stripped
|
|
||||||
if stripped_context is not None:
|
|
||||||
retry_kw["provider_context"] = stripped_context
|
|
||||||
result = await call(**retry_kw)
|
result = await call(**retry_kw)
|
||||||
# Permanently strip images from the original messages so
|
# Permanently strip images from the original messages so
|
||||||
# subsequent iterations do not repeat the error-retry cycle.
|
# subsequent iterations do not repeat the error-retry cycle.
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
"""Provider-owned conversation-state lifecycle coordination."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
|
|
||||||
_PROVIDER_STATE_OUTPUT_META = "provider_state_output"
|
|
||||||
_PROVIDER_STATE_BOUNDARY_META = "provider_state_boundary"
|
|
||||||
|
|
||||||
|
|
||||||
def allows_conversation_message_merge(message: dict[str, Any]) -> bool:
|
|
||||||
"""Return whether new same-role input may merge into *message*."""
|
|
||||||
internal_meta = cast(object, message.get("_meta"))
|
|
||||||
return not (
|
|
||||||
isinstance(internal_meta, dict)
|
|
||||||
and cast(dict[str, Any], internal_meta).get(
|
|
||||||
_PROVIDER_STATE_BOUNDARY_META
|
|
||||||
) is True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProviderConversationStateController:
|
|
||||||
"""Keep provider conversation-state semantics outside the agent runner.
|
|
||||||
|
|
||||||
The runner owns the tool loop and reports lifecycle events here. This
|
|
||||||
controller owns capability checks, transcript deltas, response projections,
|
|
||||||
retry transitions, and durable snapshots for provider-private state.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider: LLMProvider,
|
|
||||||
model: str | None,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
state: ProviderConversationState | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._provider = provider
|
|
||||||
self._model = model
|
|
||||||
self._state = (
|
|
||||||
state
|
|
||||||
if state is not None
|
|
||||||
and provider.can_resume_conversation_state(state, model)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
self._boundary = len(messages)
|
|
||||||
self._request_messages: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
def independent_request_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
context_window_tokens: int | None,
|
|
||||||
) -> ProviderCallContext | None:
|
|
||||||
"""Return typed provider context for a request that does not resume state."""
|
|
||||||
if context_window_tokens is None:
|
|
||||||
return None
|
|
||||||
return ProviderCallContext(context_window_tokens=context_window_tokens)
|
|
||||||
|
|
||||||
def prepare_request(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
context_window_tokens: int | None,
|
|
||||||
model_messages: list[dict[str, Any]] | None = None,
|
|
||||||
supplemental_messages: list[dict[str, Any]] | None = None,
|
|
||||||
) -> ProviderCallContext | None:
|
|
||||||
"""Build typed context for the next request and remember its durable delta."""
|
|
||||||
independent_context = self.independent_request_context(
|
|
||||||
context_window_tokens=context_window_tokens,
|
|
||||||
)
|
|
||||||
if self._state is None:
|
|
||||||
self._request_messages = []
|
|
||||||
return independent_context
|
|
||||||
if not self._provider.can_resume_conversation_state(
|
|
||||||
self._state,
|
|
||||||
self._model,
|
|
||||||
):
|
|
||||||
self._state = None
|
|
||||||
self._request_messages = []
|
|
||||||
return independent_context
|
|
||||||
|
|
||||||
durable_messages = self._messages_after_boundary(messages)
|
|
||||||
governed_messages = (
|
|
||||||
self._model_messages_after_boundary(model_messages)
|
|
||||||
if model_messages is not None and durable_messages
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
request_messages = (
|
|
||||||
governed_messages
|
|
||||||
if governed_messages is not None
|
|
||||||
else durable_messages
|
|
||||||
)
|
|
||||||
supplemental = deepcopy(supplemental_messages or [])
|
|
||||||
self._request_messages = deepcopy(request_messages)
|
|
||||||
request_state = self._state.with_pending_messages([
|
|
||||||
*self._state.pending_messages,
|
|
||||||
*request_messages,
|
|
||||||
*supplemental,
|
|
||||||
])
|
|
||||||
return ProviderCallContext(
|
|
||||||
conversation_state=request_state,
|
|
||||||
context_window_tokens=(
|
|
||||||
independent_context.context_window_tokens
|
|
||||||
if independent_context is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
def observe_response(
|
|
||||||
self,
|
|
||||||
response: LLMResponse,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
adopt_candidate_state: bool = True,
|
|
||||||
) -> None:
|
|
||||||
"""Advance, preserve, or discard state after one provider response."""
|
|
||||||
candidate = response.provider_state if adopt_candidate_state else None
|
|
||||||
candidate_is_replayable = response.finish_reason in {
|
|
||||||
"stop",
|
|
||||||
"tool_calls",
|
|
||||||
"function_call",
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
candidate is not None
|
|
||||||
and candidate_is_replayable
|
|
||||||
and self._provider.can_resume_conversation_state(
|
|
||||||
candidate,
|
|
||||||
self._model,
|
|
||||||
)
|
|
||||||
):
|
|
||||||
self._state = candidate
|
|
||||||
self._boundary = len(messages)
|
|
||||||
self._seal_boundary(messages)
|
|
||||||
elif response.finish_reason == "error" and (
|
|
||||||
response.preserve_provider_state_on_error is True
|
|
||||||
or (
|
|
||||||
response.preserve_provider_state_on_error is None
|
|
||||||
and LLMProvider.is_transient_response(response)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
if self._state is not None and self._request_messages:
|
|
||||||
self._state = self._state.with_pending_messages([
|
|
||||||
*self._state.pending_messages,
|
|
||||||
*self._request_messages,
|
|
||||||
])
|
|
||||||
self._boundary = len(messages)
|
|
||||||
else:
|
|
||||||
self._state = None
|
|
||||||
self._boundary = len(messages)
|
|
||||||
self._request_messages = []
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def project_response_message(
|
|
||||||
message: dict[str, Any],
|
|
||||||
response: LLMResponse,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Mark a Chat projection already represented by provider output."""
|
|
||||||
if response.provider_state is None:
|
|
||||||
return message
|
|
||||||
internal_meta = dict(message.get("_meta") or {})
|
|
||||||
internal_meta[_PROVIDER_STATE_OUTPUT_META] = True
|
|
||||||
message["_meta"] = internal_meta
|
|
||||||
return message
|
|
||||||
|
|
||||||
def checkpoint(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
model_messages: list[dict[str, Any]] | None = None,
|
|
||||||
) -> ProviderConversationState | None:
|
|
||||||
"""Return a durable state snapshot without changing live state."""
|
|
||||||
if self._state is None:
|
|
||||||
return None
|
|
||||||
durable_messages = self._messages_after_boundary(messages)
|
|
||||||
governed_messages = (
|
|
||||||
self._model_messages_after_boundary(model_messages)
|
|
||||||
if model_messages is not None and durable_messages
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
pending_messages = (
|
|
||||||
governed_messages
|
|
||||||
if governed_messages is not None
|
|
||||||
else durable_messages
|
|
||||||
)
|
|
||||||
return self._state.with_pending_messages([
|
|
||||||
*self._state.pending_messages,
|
|
||||||
*pending_messages,
|
|
||||||
])
|
|
||||||
|
|
||||||
def finish(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> ProviderConversationState | None:
|
|
||||||
"""Return the final durable state after all runner messages are known."""
|
|
||||||
self._state = self.checkpoint(messages)
|
|
||||||
return self._state
|
|
||||||
|
|
||||||
def _messages_after_boundary(
|
|
||||||
self,
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
pending: list[dict[str, Any]] = []
|
|
||||||
for message in messages[self._boundary:]:
|
|
||||||
internal_meta = cast(object, message.get("_meta"))
|
|
||||||
if (
|
|
||||||
isinstance(internal_meta, dict)
|
|
||||||
and cast(dict[str, Any], internal_meta).get(
|
|
||||||
_PROVIDER_STATE_OUTPUT_META
|
|
||||||
) is True
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
pending.append(deepcopy(message))
|
|
||||||
return pending
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _model_messages_after_boundary(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]] | None:
|
|
||||||
"""Return the governed delta after the latest provider-owned boundary."""
|
|
||||||
boundary = None
|
|
||||||
for idx in range(len(messages) - 1, -1, -1):
|
|
||||||
internal_meta = cast(object, messages[idx].get("_meta"))
|
|
||||||
if (
|
|
||||||
isinstance(internal_meta, dict)
|
|
||||||
and cast(dict[str, Any], internal_meta).get(
|
|
||||||
_PROVIDER_STATE_BOUNDARY_META
|
|
||||||
) is True
|
|
||||||
):
|
|
||||||
boundary = idx
|
|
||||||
break
|
|
||||||
if boundary is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
pending: list[dict[str, Any]] = []
|
|
||||||
for message in messages[boundary + 1:]:
|
|
||||||
internal_meta = cast(object, message.get("_meta"))
|
|
||||||
if (
|
|
||||||
isinstance(internal_meta, dict)
|
|
||||||
and cast(dict[str, Any], internal_meta).get(
|
|
||||||
_PROVIDER_STATE_OUTPUT_META
|
|
||||||
) is True
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
pending.append(deepcopy(message))
|
|
||||||
return pending
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _seal_boundary(messages: list[dict[str, Any]]) -> None:
|
|
||||||
"""Prevent later same-role injection merging across a state boundary."""
|
|
||||||
if not messages:
|
|
||||||
return
|
|
||||||
internal_meta = dict(messages[-1].get("_meta") or {})
|
|
||||||
internal_meta[_PROVIDER_STATE_BOUNDARY_META] = True
|
|
||||||
messages[-1]["_meta"] = internal_meta
|
|
||||||
@@ -261,7 +261,6 @@ def make_provider(
|
|||||||
primary=provider,
|
primary=provider,
|
||||||
fallback_presets=fallback_presets,
|
fallback_presets=fallback_presets,
|
||||||
provider_factory=lambda fb: _make_provider_core(config, preset=fb),
|
provider_factory=lambda fb: _make_provider_core(config, preset=fb),
|
||||||
primary_context_window_tokens=resolved.context_window_tokens,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return provider
|
return provider
|
||||||
|
|||||||
@@ -6,18 +6,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import replace
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse
|
||||||
GenerationSettings,
|
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
# Circuit breaker tuned to match OpenAICompatProvider's Responses API breaker.
|
||||||
_PRIMARY_FAILURE_THRESHOLD = 3
|
_PRIMARY_FAILURE_THRESHOLD = 3
|
||||||
@@ -120,13 +113,11 @@ class FallbackProvider(LLMProvider):
|
|||||||
fallback_presets: list[Any],
|
fallback_presets: list[Any],
|
||||||
provider_factory: Callable[[Any], LLMProvider],
|
provider_factory: Callable[[Any], LLMProvider],
|
||||||
fallback_model_observer: FallbackModelObserver | None = None,
|
fallback_model_observer: FallbackModelObserver | None = None,
|
||||||
primary_context_window_tokens: int | None = None,
|
|
||||||
):
|
):
|
||||||
self._primary = primary
|
self._primary = primary
|
||||||
self._fallback_presets = list(fallback_presets)
|
self._fallback_presets = list(fallback_presets)
|
||||||
self._provider_factory = provider_factory
|
self._provider_factory = provider_factory
|
||||||
self._fallback_model_observer = fallback_model_observer
|
self._fallback_model_observer = fallback_model_observer
|
||||||
self._primary_context_window_tokens = primary_context_window_tokens
|
|
||||||
self._has_fallbacks = bool(fallback_presets)
|
self._has_fallbacks = bool(fallback_presets)
|
||||||
self._primary_failures = 0
|
self._primary_failures = 0
|
||||||
self._primary_tripped_at: float | None = None
|
self._primary_tripped_at: float | None = None
|
||||||
@@ -150,33 +141,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
def supports_progress_deltas(self) -> bool:
|
def supports_progress_deltas(self) -> bool:
|
||||||
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
return bool(getattr(self._primary, "supports_progress_deltas", False))
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return self._primary.can_resume_conversation_state(state, model)
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
return self._primary.supports_native_compaction(model)
|
|
||||||
|
|
||||||
def _primary_call_context(
|
|
||||||
self,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
model: str | None,
|
|
||||||
) -> ProviderCallContext:
|
|
||||||
context_window_tokens = (
|
|
||||||
self._primary_context_window_tokens
|
|
||||||
if self._primary_context_window_tokens is not None
|
|
||||||
else provider_context.context_window_tokens
|
|
||||||
)
|
|
||||||
if not self._primary.supports_native_compaction(model):
|
|
||||||
context_window_tokens = None
|
|
||||||
return ProviderCallContext(
|
|
||||||
conversation_state=provider_context.conversation_state,
|
|
||||||
context_window_tokens=context_window_tokens,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _primary_available(self) -> bool:
|
def _primary_available(self) -> bool:
|
||||||
"""Return True if the primary provider is not currently tripped."""
|
"""Return True if the primary provider is not currently tripped."""
|
||||||
if self._primary_tripped_at is None:
|
if self._primary_tripped_at is None:
|
||||||
@@ -193,25 +157,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
lambda p, kw: p.chat(**kw), kwargs, has_streamed=None
|
lambda p, kw: p.chat(**kw), kwargs, has_streamed=None
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
call_kwargs: dict[str, Any] = dict(kwargs)
|
|
||||||
call_kwargs["provider_context"] = self._primary_call_context(
|
|
||||||
provider_context,
|
|
||||||
kwargs.get("model"),
|
|
||||||
)
|
|
||||||
if not self._has_fallbacks:
|
|
||||||
return await self._primary.chat_with_context(**call_kwargs)
|
|
||||||
return await self._try_with_fallback(
|
|
||||||
lambda p, kw: p.chat_with_context(**kw),
|
|
||||||
call_kwargs,
|
|
||||||
has_streamed=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
|
||||||
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
||||||
if not self._has_fallbacks:
|
if not self._has_fallbacks:
|
||||||
@@ -234,38 +179,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
on_stream_recover=on_stream_recover,
|
on_stream_recover=on_stream_recover,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_stream_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
on_stream_recover = kwargs.pop("on_stream_recover", None)
|
|
||||||
call_kwargs: dict[str, Any] = dict(kwargs)
|
|
||||||
call_kwargs["provider_context"] = self._primary_call_context(
|
|
||||||
provider_context,
|
|
||||||
kwargs.get("model"),
|
|
||||||
)
|
|
||||||
if not self._has_fallbacks:
|
|
||||||
return await self._primary.chat_stream_with_context(**call_kwargs)
|
|
||||||
|
|
||||||
has_streamed: list[bool] = [False]
|
|
||||||
original_delta = call_kwargs.get("on_content_delta")
|
|
||||||
|
|
||||||
async def _tracking_delta(text: str) -> None:
|
|
||||||
if text:
|
|
||||||
has_streamed[0] = True
|
|
||||||
if original_delta:
|
|
||||||
await original_delta(text)
|
|
||||||
|
|
||||||
call_kwargs["on_content_delta"] = _tracking_delta
|
|
||||||
return await self._try_with_fallback(
|
|
||||||
lambda p, kw: p.chat_stream_with_context(**kw),
|
|
||||||
call_kwargs,
|
|
||||||
has_streamed=has_streamed,
|
|
||||||
on_stream_recover=on_stream_recover,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _try_with_fallback(
|
async def _try_with_fallback(
|
||||||
self,
|
self,
|
||||||
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
|
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
|
||||||
@@ -276,9 +189,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
primary_model = kwargs.get("model") or self._primary.get_default_model()
|
||||||
primary_was_attempted = False
|
primary_was_attempted = False
|
||||||
primary_error = "unknown error"
|
primary_error = "unknown error"
|
||||||
# A primary error eligible for failover did not return a replacement
|
|
||||||
# continuation, so the incoming primary state remains reusable.
|
|
||||||
preserve_primary_state = True
|
|
||||||
|
|
||||||
if self._primary_available():
|
if self._primary_available():
|
||||||
primary_was_attempted = True
|
primary_was_attempted = True
|
||||||
@@ -376,23 +286,6 @@ class FallbackProvider(LLMProvider):
|
|||||||
"max_tokens": fallback.max_tokens,
|
"max_tokens": fallback.max_tokens,
|
||||||
"temperature": fallback.temperature,
|
"temperature": fallback.temperature,
|
||||||
}
|
}
|
||||||
provider_context = fallback_kwargs.get("provider_context")
|
|
||||||
if isinstance(provider_context, ProviderCallContext):
|
|
||||||
state = provider_context.conversation_state
|
|
||||||
if state is not None and not fallback_provider.can_resume_conversation_state(
|
|
||||||
state,
|
|
||||||
fallback_model,
|
|
||||||
):
|
|
||||||
state = None
|
|
||||||
context_window_tokens = (
|
|
||||||
fallback.context_window_tokens
|
|
||||||
if fallback_provider.supports_native_compaction(fallback_model)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
fallback_kwargs["provider_context"] = ProviderCallContext(
|
|
||||||
conversation_state=state,
|
|
||||||
context_window_tokens=context_window_tokens,
|
|
||||||
)
|
|
||||||
if fallback.reasoning_effort is None:
|
if fallback.reasoning_effort is None:
|
||||||
fallback_kwargs.pop("reasoning_effort", None)
|
fallback_kwargs.pop("reasoning_effort", None)
|
||||||
else:
|
else:
|
||||||
@@ -419,15 +312,11 @@ class FallbackProvider(LLMProvider):
|
|||||||
)
|
)
|
||||||
# Return the last error response we saw (primary or last fallback).
|
# Return the last error response we saw (primary or last fallback).
|
||||||
if last_response is not None:
|
if last_response is not None:
|
||||||
return replace(
|
return last_response
|
||||||
last_response,
|
|
||||||
preserve_provider_state_on_error=preserve_primary_state,
|
|
||||||
)
|
|
||||||
# Primary was tripped and we have no fallbacks — synthesize an error.
|
# Primary was tripped and we have no fallbacks — synthesize an error.
|
||||||
return LLMResponse(
|
return LLMResponse(
|
||||||
content=f"Primary model '{primary_model}' circuit open and no fallbacks available",
|
content=f"Primary model '{primary_model}' circuit open and no fallbacks available",
|
||||||
finish_reason="error",
|
finish_reason="error",
|
||||||
preserve_provider_state_on_error=preserve_primary_state,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _notify_fallback_model(self, model: str) -> None:
|
async def _notify_fallback_model(self, model: str) -> None:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import httpx
|
|||||||
from oauth_cli_kit.models import OAuthToken
|
from oauth_cli_kit.models import OAuthToken
|
||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
|
|
||||||
from nanobot.providers.base import LLMResponse, ProviderCallContext
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
DEFAULT_GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"
|
||||||
@@ -248,7 +248,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
await self._refresh_client_api_key()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat(
|
return await super().chat(
|
||||||
@@ -259,7 +258,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
@@ -274,7 +272,6 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
await self._refresh_client_api_key()
|
await self._refresh_client_api_key()
|
||||||
return await super().chat_stream(
|
return await super().chat_stream(
|
||||||
@@ -288,5 +285,4 @@ class GitHubCopilotProvider(OpenAICompatProvider):
|
|||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_thinking_delta=on_thinking_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,27 +17,17 @@ from oauth_cli_kit import get_token as get_codex_token
|
|||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
ProviderCallContext,
|
ToolCallRequest,
|
||||||
ProviderConversationState,
|
|
||||||
resolve_stream_idle_timeout_s,
|
resolve_stream_idle_timeout_s,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
ResponsesStreamCapture,
|
|
||||||
build_responses_state,
|
|
||||||
consume_sse_with_reasoning,
|
consume_sse_with_reasoning,
|
||||||
|
convert_messages,
|
||||||
convert_tools,
|
convert_tools,
|
||||||
is_compaction_compatibility_error,
|
|
||||||
is_replayable_finish_reason,
|
|
||||||
prepare_responses_input,
|
|
||||||
resolve_compact_threshold,
|
|
||||||
responses_state_context_tokens,
|
|
||||||
responses_state_items,
|
|
||||||
responses_state_matches,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||||
DEFAULT_ORIGINATOR = "nanobot"
|
DEFAULT_ORIGINATOR = "nanobot"
|
||||||
_COMPACTION_RETAINED_CHAR_BUDGET = 256_000
|
|
||||||
|
|
||||||
|
|
||||||
class OpenAICodexProvider(LLMProvider):
|
class OpenAICodexProvider(LLMProvider):
|
||||||
@@ -55,39 +45,21 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
self.proxy = proxy or None
|
self.proxy = proxy or None
|
||||||
self._extra_body = dict(extra_body or {})
|
self._extra_body = dict(extra_body or {})
|
||||||
self._native_compaction_available = True
|
|
||||||
|
|
||||||
async def _call_codex(
|
async def _call_codex(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
tools: list[dict[str, Any]] | None,
|
tools: list[dict[str, Any]] | None,
|
||||||
model: str | None,
|
model: str | None,
|
||||||
max_tokens: int,
|
|
||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
tool_choice: str | dict[str, Any] | None,
|
tool_choice: str | dict[str, Any] | None,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
"""Shared request logic for both chat() and chat_stream()."""
|
"""Shared request logic for both chat() and chat_stream()."""
|
||||||
model = model or self.default_model
|
model = model or self.default_model
|
||||||
sanitized_messages = self._sanitize_empty_content(messages)
|
system_prompt, input_items = convert_messages(messages)
|
||||||
sanitized_state = (
|
|
||||||
provider_context.conversation_state
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if sanitized_state is not None:
|
|
||||||
sanitized_state = sanitized_state.with_pending_messages(
|
|
||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
|
||||||
)
|
|
||||||
system_prompt, input_items, replayed = prepare_responses_input(
|
|
||||||
sanitized_messages,
|
|
||||||
state=sanitized_state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=_strip_model_prefix(model),
|
|
||||||
)
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"model": _strip_model_prefix(model),
|
"model": _strip_model_prefix(model),
|
||||||
@@ -96,15 +68,12 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
"instructions": system_prompt,
|
"instructions": system_prompt,
|
||||||
"input": input_items,
|
"input": input_items,
|
||||||
"text": {"verbosity": "medium"},
|
"text": {"verbosity": "medium"},
|
||||||
|
"include": ["reasoning.encrypted_content"],
|
||||||
"prompt_cache_key": _prompt_cache_key(messages[:2]),
|
"prompt_cache_key": _prompt_cache_key(messages[:2]),
|
||||||
"tool_choice": tool_choice or "auto",
|
"tool_choice": tool_choice or "auto",
|
||||||
"parallel_tool_calls": True,
|
"parallel_tool_calls": True,
|
||||||
}
|
}
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
|
||||||
reasoning_options = _build_reasoning_options(reasoning_effort)
|
reasoning_options = _build_reasoning_options(reasoning_effort)
|
||||||
if replayed and "gpt-5.6" in _strip_model_prefix(model).lower():
|
|
||||||
reasoning_options = dict(reasoning_options or {})
|
|
||||||
reasoning_options["context"] = "all_turns"
|
|
||||||
if reasoning_options:
|
if reasoning_options:
|
||||||
body["reasoning"] = reasoning_options
|
body["reasoning"] = reasoning_options
|
||||||
if tools:
|
if tools:
|
||||||
@@ -118,90 +87,33 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
token = await asyncio.to_thread(get_codex_token, proxy=self.proxy)
|
||||||
headers = _build_headers(cast(str, token.account_id), token.access)
|
headers = _build_headers(cast(str, token.account_id), token.access)
|
||||||
|
|
||||||
async def _send(
|
|
||||||
request_body: dict[str, Any],
|
|
||||||
*,
|
|
||||||
emit_deltas: bool,
|
|
||||||
) -> LLMResponse:
|
|
||||||
wire_body = _without_response_item_ids(request_body)
|
|
||||||
try:
|
|
||||||
return await _request_codex(
|
|
||||||
DEFAULT_CODEX_URL,
|
|
||||||
headers,
|
|
||||||
wire_body,
|
|
||||||
verify=True,
|
|
||||||
proxy=self.proxy,
|
|
||||||
on_content_delta=on_content_delta if emit_deltas else None,
|
|
||||||
on_thinking_delta=on_thinking_delta if emit_deltas else None,
|
|
||||||
on_tool_call_delta=on_tool_call_delta if emit_deltas else None,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
if "CERTIFICATE_VERIFY_FAILED" not in str(exc):
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"SSL verification failed for Codex API; retrying with verify=False"
|
|
||||||
)
|
|
||||||
return await _request_codex(
|
|
||||||
DEFAULT_CODEX_URL,
|
|
||||||
headers,
|
|
||||||
wire_body,
|
|
||||||
verify=False,
|
|
||||||
proxy=self.proxy,
|
|
||||||
on_content_delta=on_content_delta if emit_deltas else None,
|
|
||||||
on_thinking_delta=on_thinking_delta if emit_deltas else None,
|
|
||||||
on_tool_call_delta=on_tool_call_delta if emit_deltas else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
compact_threshold = resolve_compact_threshold(
|
|
||||||
(
|
|
||||||
provider_context.context_window_tokens
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
max_tokens,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
self.supports_native_compaction(model)
|
|
||||||
and replayed
|
|
||||||
and sanitized_state is not None
|
|
||||||
and compact_threshold is not None
|
|
||||||
and responses_state_context_tokens(sanitized_state) >= compact_threshold
|
|
||||||
):
|
|
||||||
stage = "codex_compaction"
|
|
||||||
compact_body = {
|
|
||||||
**body,
|
|
||||||
"input": [*input_items, {"type": "compaction_trigger"}],
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
compact_result = await _send(compact_body, emit_deltas=False)
|
|
||||||
compact_items = (
|
|
||||||
responses_state_items(compact_result.provider_state)
|
|
||||||
if compact_result.provider_state is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if not compact_items or compact_items[-1].get("type") not in {
|
|
||||||
"compaction",
|
|
||||||
"compaction_summary",
|
|
||||||
"context_compaction",
|
|
||||||
}:
|
|
||||||
raise RuntimeError("Codex compaction returned no compaction item")
|
|
||||||
body["input"] = [
|
|
||||||
*_retained_compaction_messages(input_items),
|
|
||||||
*compact_items,
|
|
||||||
]
|
|
||||||
except Exception as compact_error:
|
|
||||||
if is_compaction_compatibility_error(compact_error):
|
|
||||||
self._native_compaction_available = False
|
|
||||||
logger.warning(
|
|
||||||
"Codex native compaction unavailable; continuing without it "
|
|
||||||
"(type={} status={} disabled={})",
|
|
||||||
type(compact_error).__name__,
|
|
||||||
getattr(compact_error, "status_code", None),
|
|
||||||
not self._native_compaction_available,
|
|
||||||
)
|
|
||||||
|
|
||||||
stage = "codex_request"
|
stage = "codex_request"
|
||||||
return await _send(body, emit_deltas=True)
|
try:
|
||||||
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
|
DEFAULT_CODEX_URL, headers, body, verify=True,
|
||||||
|
proxy=self.proxy,
|
||||||
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
if "CERTIFICATE_VERIFY_FAILED" not in str(e):
|
||||||
|
raise
|
||||||
|
logger.warning("SSL verification failed for Codex API; retrying with verify=False")
|
||||||
|
content, tool_calls, finish_reason, usage, reasoning_content = await _request_codex(
|
||||||
|
DEFAULT_CODEX_URL, headers, body, verify=False,
|
||||||
|
proxy=self.proxy,
|
||||||
|
on_content_delta=on_content_delta,
|
||||||
|
on_thinking_delta=on_thinking_delta,
|
||||||
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
|
)
|
||||||
|
return LLMResponse(
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
usage=usage,
|
||||||
|
reasoning_content=reasoning_content,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
response = _codex_error_response(e)
|
response = _codex_error_response(e)
|
||||||
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
|
exc_type = "CodexHTTPError" if isinstance(e, _CodexHTTPError) else type(e).__name__
|
||||||
@@ -225,28 +137,8 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
return await self._call_codex(
|
return await self._call_codex(messages, tools, model, reasoning_effort, tool_choice)
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
model,
|
|
||||||
max_tokens,
|
|
||||||
reasoning_effort,
|
|
||||||
tool_choice,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||||
@@ -256,55 +148,21 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
return await self._call_codex(
|
return await self._call_codex(
|
||||||
messages=messages,
|
messages,
|
||||||
tools=tools,
|
tools,
|
||||||
model=model,
|
model,
|
||||||
max_tokens=max_tokens,
|
reasoning_effort,
|
||||||
reasoning_effort=reasoning_effort,
|
tool_choice,
|
||||||
tool_choice=tool_choice,
|
on_content_delta,
|
||||||
on_content_delta=on_content_delta,
|
on_thinking_delta,
|
||||||
on_thinking_delta=on_thinking_delta,
|
on_tool_call_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_stream_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat_stream(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return self.default_model
|
return self.default_model
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _responses_state_provider() -> str:
|
|
||||||
return f"openai_codex:{DEFAULT_CODEX_URL.rstrip('/')}"
|
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return responses_state_matches(
|
|
||||||
state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=_strip_model_prefix(model or self.default_model),
|
|
||||||
)
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
"""Use the Codex backend's inline compaction trigger when needed."""
|
|
||||||
_ = model
|
|
||||||
return self._native_compaction_available
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_model_prefix(model: str) -> str:
|
def _strip_model_prefix(model: str) -> str:
|
||||||
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
|
||||||
@@ -312,58 +170,6 @@ def _strip_model_prefix(model: str) -> str:
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _without_response_item_ids(
|
|
||||||
request_body: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Match Codex's default ``store=false`` request-item contract."""
|
|
||||||
if request_body.get("store") is True:
|
|
||||||
return request_body
|
|
||||||
raw_input = request_body.get("input")
|
|
||||||
if not isinstance(raw_input, list):
|
|
||||||
return request_body
|
|
||||||
|
|
||||||
input_items: list[object] = cast(list[object], raw_input)
|
|
||||||
sanitized_input: list[object] = []
|
|
||||||
for raw_item in input_items:
|
|
||||||
if not isinstance(raw_item, dict):
|
|
||||||
sanitized_input.append(raw_item)
|
|
||||||
continue
|
|
||||||
item = cast(dict[str, Any], raw_item)
|
|
||||||
sanitized_input.append({
|
|
||||||
key: value
|
|
||||||
for key, value in item.items()
|
|
||||||
if key != "id"
|
|
||||||
})
|
|
||||||
|
|
||||||
body = dict(request_body)
|
|
||||||
body["input"] = sanitized_input
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def _retained_compaction_messages(
|
|
||||||
input_items: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Mirror Codex's bounded retention of user/developer/system messages."""
|
|
||||||
retained_reversed: list[dict[str, Any]] = []
|
|
||||||
remaining = _COMPACTION_RETAINED_CHAR_BUDGET
|
|
||||||
for item in reversed(input_items):
|
|
||||||
if item.get("type") not in {None, "message"} or item.get("role") not in {
|
|
||||||
"user",
|
|
||||||
"developer",
|
|
||||||
"system",
|
|
||||||
}:
|
|
||||||
continue
|
|
||||||
size = len(json.dumps(item, ensure_ascii=False))
|
|
||||||
if size > remaining and retained_reversed:
|
|
||||||
continue
|
|
||||||
retained_reversed.append(item)
|
|
||||||
remaining = max(0, remaining - size)
|
|
||||||
if remaining == 0:
|
|
||||||
break
|
|
||||||
retained_reversed.reverse()
|
|
||||||
return retained_reversed
|
|
||||||
|
|
||||||
|
|
||||||
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
|
def _build_reasoning_options(reasoning_effort: str | None) -> dict[str, str] | None:
|
||||||
"""Opt in to visible summaries without changing provider-default effort."""
|
"""Opt in to visible summaries without changing provider-default effort."""
|
||||||
if reasoning_effort and reasoning_effort.lower() == "none":
|
if reasoning_effort and reasoning_effort.lower() == "none":
|
||||||
@@ -396,7 +202,6 @@ class _CodexHTTPError(RuntimeError):
|
|||||||
error_type: str | None = None,
|
error_type: str | None = None,
|
||||||
error_code: str | None = None,
|
error_code: str | None = None,
|
||||||
should_retry: bool | None = None,
|
should_retry: bool | None = None,
|
||||||
compaction_unsupported: bool = False,
|
|
||||||
):
|
):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
@@ -404,7 +209,6 @@ class _CodexHTTPError(RuntimeError):
|
|||||||
self.error_type = error_type
|
self.error_type = error_type
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.should_retry = should_retry
|
self.should_retry = should_retry
|
||||||
self.compaction_unsupported = compaction_unsupported
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_codex(
|
async def _request_codex(
|
||||||
@@ -416,7 +220,7 @@ async def _request_codex(
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
) -> LLMResponse:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||||
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
client_kwargs: dict[str, Any] = {"timeout": idle_timeout_s, "verify": verify}
|
||||||
if proxy:
|
if proxy:
|
||||||
@@ -429,17 +233,6 @@ async def _request_codex(
|
|||||||
raw = text.decode("utf-8", "ignore")
|
raw = text.decode("utf-8", "ignore")
|
||||||
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
|
||||||
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
error_type, error_code = LLMProvider._extract_error_type_code(raw)
|
||||||
compaction_unsupported = (
|
|
||||||
response.status_code in {400, 404, 422}
|
|
||||||
and any(
|
|
||||||
marker in raw.lower()
|
|
||||||
for marker in (
|
|
||||||
"context_management",
|
|
||||||
"compact_threshold",
|
|
||||||
"compaction_trigger",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise _CodexHTTPError(
|
raise _CodexHTTPError(
|
||||||
_friendly_error(response.status_code, raw),
|
_friendly_error(response.status_code, raw),
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
@@ -447,38 +240,13 @@ async def _request_codex(
|
|||||||
error_type=error_type,
|
error_type=error_type,
|
||||||
error_code=error_code,
|
error_code=error_code,
|
||||||
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
|
should_retry=_should_retry_status(response.status_code, error_type, error_code, raw),
|
||||||
compaction_unsupported=compaction_unsupported,
|
|
||||||
)
|
)
|
||||||
capture = ResponsesStreamCapture()
|
return await consume_sse_with_reasoning(
|
||||||
(
|
|
||||||
content,
|
|
||||||
tool_calls,
|
|
||||||
finish_reason,
|
|
||||||
usage,
|
|
||||||
reasoning_content,
|
|
||||||
) = await consume_sse_with_reasoning(
|
|
||||||
response,
|
response,
|
||||||
on_content_delta=on_content_delta,
|
on_content_delta=on_content_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
on_reasoning_delta=on_thinking_delta,
|
on_reasoning_delta=on_thinking_delta,
|
||||||
capture=capture,
|
|
||||||
)
|
)
|
||||||
result = LLMResponse(
|
|
||||||
content=content,
|
|
||||||
tool_calls=tool_calls,
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
usage=usage,
|
|
||||||
reasoning_content=reasoning_content,
|
|
||||||
)
|
|
||||||
if capture.completed and is_replayable_finish_reason(finish_reason):
|
|
||||||
result.provider_state = build_responses_state(
|
|
||||||
provider=f"openai_codex:{url.rstrip('/')}",
|
|
||||||
model=str(body.get("model") or ""),
|
|
||||||
input_items=cast(list[dict[str, Any]], body.get("input") or []),
|
|
||||||
output_items=capture.output_items,
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
def _prompt_cache_key(messages: list[dict[str, Any]]) -> str:
|
||||||
|
|||||||
@@ -26,24 +26,16 @@ from pydantic.alias_generators import to_snake
|
|||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import (
|
||||||
LLMProvider,
|
LLMProvider,
|
||||||
LLMResponse,
|
LLMResponse,
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
ToolCallRequest,
|
ToolCallRequest,
|
||||||
parse_tool_arguments,
|
parse_tool_arguments,
|
||||||
resolve_stream_idle_timeout_s,
|
resolve_stream_idle_timeout_s,
|
||||||
tool_arguments_json_for_replay,
|
tool_arguments_json_for_replay,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses import (
|
from nanobot.providers.openai_responses import (
|
||||||
ResponsesStreamCapture,
|
|
||||||
build_responses_state,
|
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
|
convert_messages,
|
||||||
convert_tools,
|
convert_tools,
|
||||||
is_compaction_compatibility_error,
|
|
||||||
is_replayable_finish_reason,
|
|
||||||
parse_response_output,
|
parse_response_output,
|
||||||
prepare_responses_input,
|
|
||||||
resolve_compact_threshold,
|
|
||||||
responses_state_matches,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -451,8 +443,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
registry lookups needed.
|
registry lookups needed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_native_compaction_available = True
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
@@ -473,7 +463,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
self._api_type = api_type if spec and spec.name == "openai" else "auto"
|
||||||
self._extra_query = extra_query or {}
|
self._extra_query = extra_query or {}
|
||||||
self._proxy = proxy or None
|
self._proxy = proxy or None
|
||||||
self._native_compaction_available = True
|
|
||||||
|
|
||||||
if api_key and spec and spec.env_key:
|
if api_key and spec and spec.env_key:
|
||||||
self._setup_env(api_key, api_base)
|
self._setup_env(api_key, api_base)
|
||||||
@@ -958,34 +947,22 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
model: str | None,
|
model: str | None,
|
||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Choose Responses for providers/models that explicitly support it."""
|
"""Use Responses API only for direct OpenAI requests that benefit from it."""
|
||||||
if self._api_type == "chat_completions":
|
if self._api_type == "chat_completions":
|
||||||
return False
|
return False
|
||||||
spec_name = self._spec.name if self._spec is not None else None
|
if self._spec and self._spec.name not in ("openai", "github_copilot"):
|
||||||
model_name = self._request_model_name(model or self.default_model).lower()
|
|
||||||
supported_models = {
|
|
||||||
supported.lower()
|
|
||||||
for supported in getattr(self._spec, "responses_models", ())
|
|
||||||
}
|
|
||||||
model_responses = any(
|
|
||||||
model_name == supported or model_name.endswith(f"/{supported}")
|
|
||||||
for supported in supported_models
|
|
||||||
)
|
|
||||||
provider_responses = spec_name in ("openai", "github_copilot")
|
|
||||||
if not provider_responses and not model_responses:
|
|
||||||
return False
|
return False
|
||||||
if self._api_type == "responses":
|
if self._api_type == "responses":
|
||||||
# Explicit configuration means Responses is mandatory; do not
|
# Explicit configuration means Responses is mandatory; do not
|
||||||
# consult the circuit breaker or fall back to Chat Completions.
|
# consult the circuit breaker or fall back to Chat Completions.
|
||||||
return True
|
return True
|
||||||
if provider_responses and (self._spec is None or self._spec.name != "github_copilot"):
|
if self._spec is None or self._spec.name != "github_copilot":
|
||||||
if not _is_direct_openai_base(self._effective_base):
|
if not _is_direct_openai_base(self._effective_base):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
model_name = (model or self.default_model).lower()
|
||||||
wants = False
|
wants = False
|
||||||
if model_responses:
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
wants = True
|
|
||||||
elif reasoning_effort and reasoning_effort.lower() != "none":
|
|
||||||
wants = True
|
wants = True
|
||||||
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
|
||||||
wants = True
|
wants = True
|
||||||
@@ -994,37 +971,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
return self._responses_circuit_allows_probe(model, reasoning_effort)
|
||||||
|
|
||||||
def _responses_state_provider(self) -> str:
|
|
||||||
spec_name = self._spec.name if self._spec is not None else "custom"
|
|
||||||
effective_base = self._effective_base or "https://api.openai.com/v1"
|
|
||||||
return f"openai_compat:{spec_name}:{effective_base.rstrip('/')}"
|
|
||||||
|
|
||||||
def _responses_state_model(self, model: str | None) -> str:
|
|
||||||
return self._request_model_name(model or self.default_model)
|
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
return responses_state_matches(
|
|
||||||
state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=self._responses_state_model(model),
|
|
||||||
)
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
"""Enable server compaction only on direct OpenAI Responses endpoints."""
|
|
||||||
_ = model
|
|
||||||
if (
|
|
||||||
not self._native_compaction_available
|
|
||||||
or self._api_type == "chat_completions"
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if self._spec is not None and self._spec.name != "openai":
|
|
||||||
return False
|
|
||||||
return _is_direct_openai_base(self._effective_base)
|
|
||||||
|
|
||||||
def _responses_circuit_allows_probe(
|
def _responses_circuit_allows_probe(
|
||||||
self,
|
self,
|
||||||
model: str | None,
|
model: str | None,
|
||||||
@@ -1094,31 +1040,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
temperature: float,
|
temperature: float,
|
||||||
reasoning_effort: str | None,
|
reasoning_effort: str | None,
|
||||||
tool_choice: str | dict[str, Any] | None,
|
tool_choice: str | dict[str, Any] | None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build a Responses API body for direct OpenAI requests."""
|
"""Build a Responses API body for direct OpenAI requests."""
|
||||||
model_name = model or self.default_model
|
model_name = model or self.default_model
|
||||||
model_name = self._request_model_name(model_name)
|
model_name = self._request_model_name(model_name)
|
||||||
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
|
||||||
sanitized_state = (
|
instructions, input_items = convert_messages(sanitized_messages)
|
||||||
provider_context.conversation_state
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if sanitized_state is not None:
|
|
||||||
sanitized_state = sanitized_state.with_pending_messages(
|
|
||||||
self._sanitize_messages(
|
|
||||||
self._sanitize_empty_content(sanitized_state.pending_messages)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
preserve_reasoning = bool(self._spec and self._spec.name == "deepseek")
|
|
||||||
instructions, input_items, replayed = prepare_responses_input(
|
|
||||||
sanitized_messages,
|
|
||||||
state=sanitized_state,
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=model_name,
|
|
||||||
preserve_reasoning=preserve_reasoning,
|
|
||||||
)
|
|
||||||
|
|
||||||
body: dict[str, Any] = {
|
body: dict[str, Any] = {
|
||||||
"model": model_name,
|
"model": model_name,
|
||||||
@@ -1128,29 +1055,13 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
"store": False,
|
"store": False,
|
||||||
"stream": False,
|
"stream": False,
|
||||||
}
|
}
|
||||||
compact_threshold = resolve_compact_threshold(
|
|
||||||
(
|
|
||||||
provider_context.context_window_tokens
|
|
||||||
if provider_context is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
max_tokens,
|
|
||||||
)
|
|
||||||
if self.supports_native_compaction(model_name) and compact_threshold is not None:
|
|
||||||
body["context_management"] = [{
|
|
||||||
"type": "compaction",
|
|
||||||
"compact_threshold": compact_threshold,
|
|
||||||
}]
|
|
||||||
|
|
||||||
if self._supports_temperature(model_name, reasoning_effort):
|
if self._supports_temperature(model_name, reasoning_effort):
|
||||||
body["temperature"] = temperature
|
body["temperature"] = temperature
|
||||||
|
|
||||||
if not self._supports_temperature(model_name, reasoning_effort) and not preserve_reasoning:
|
|
||||||
body["include"] = ["reasoning.encrypted_content"]
|
|
||||||
if reasoning_effort and reasoning_effort.lower() != "none":
|
if reasoning_effort and reasoning_effort.lower() != "none":
|
||||||
body["reasoning"] = {"effort": reasoning_effort}
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
if replayed and "gpt-5.6" in model_name.lower():
|
body["include"] = ["reasoning.encrypted_content"]
|
||||||
body.setdefault("reasoning", {})["context"] = "all_turns"
|
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = convert_tools(tools)
|
body["tools"] = convert_tools(tools)
|
||||||
@@ -1162,29 +1073,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
|
|
||||||
return body
|
return body
|
||||||
|
|
||||||
async def _create_response_with_compaction_fallback(
|
|
||||||
self,
|
|
||||||
client: Any,
|
|
||||||
body: dict[str, Any],
|
|
||||||
) -> Any:
|
|
||||||
"""Retry Responses once without server compaction on compatibility errors."""
|
|
||||||
try:
|
|
||||||
return await client.responses.create(**body)
|
|
||||||
except Exception as exc:
|
|
||||||
if (
|
|
||||||
"context_management" not in body
|
|
||||||
or not is_compaction_compatibility_error(exc)
|
|
||||||
):
|
|
||||||
raise
|
|
||||||
self._native_compaction_available = False
|
|
||||||
body.pop("context_management", None)
|
|
||||||
logger.warning(
|
|
||||||
"Responses server compaction unsupported; disabled for this provider instance "
|
|
||||||
"(status={})",
|
|
||||||
getattr(exc, "status_code", None),
|
|
||||||
)
|
|
||||||
return await client.responses.create(**body)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Response parsing
|
# Response parsing
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1711,28 +1599,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_stream_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
return await self.chat_stream(
|
|
||||||
**kwargs,
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat(
|
async def chat(
|
||||||
self,
|
self,
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
@@ -1742,7 +1608,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
temperature: float = 0.7,
|
temperature: float = 0.7,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
tool_choice: str | dict[str, Any] | None = None,
|
tool_choice: str | dict[str, Any] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
client = await self._ensure_client()
|
client = await self._ensure_client()
|
||||||
try:
|
try:
|
||||||
@@ -1751,18 +1616,12 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
body = self._build_responses_body(
|
body = self._build_responses_body(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
provider_context,
|
|
||||||
)
|
)
|
||||||
responses_raw = await self._create_response_with_compaction_fallback(
|
responses_raw = cast(
|
||||||
client,
|
Any,
|
||||||
body,
|
await client.responses.create(**body),
|
||||||
)
|
|
||||||
result = parse_response_output(
|
|
||||||
responses_raw,
|
|
||||||
state_provider=self._responses_state_provider(),
|
|
||||||
state_model=str(body["model"]),
|
|
||||||
state_input_items=cast(list[dict[str, Any]], body["input"]),
|
|
||||||
)
|
)
|
||||||
|
result = parse_response_output(responses_raw)
|
||||||
self._record_responses_success(model, reasoning_effort)
|
self._record_responses_success(model, reasoning_effort)
|
||||||
return result
|
return result
|
||||||
except Exception as responses_error:
|
except Exception as responses_error:
|
||||||
@@ -1801,7 +1660,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
) -> LLMResponse:
|
) -> LLMResponse:
|
||||||
client = await self._ensure_client()
|
client = await self._ensure_client()
|
||||||
idle_timeout_s = resolve_stream_idle_timeout_s()
|
idle_timeout_s = resolve_stream_idle_timeout_s()
|
||||||
@@ -1811,12 +1669,11 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
body = self._build_responses_body(
|
body = self._build_responses_body(
|
||||||
messages, tools, model, max_tokens, temperature,
|
messages, tools, model, max_tokens, temperature,
|
||||||
reasoning_effort, tool_choice,
|
reasoning_effort, tool_choice,
|
||||||
provider_context,
|
|
||||||
)
|
)
|
||||||
body["stream"] = True
|
body["stream"] = True
|
||||||
responses_stream = await self._create_response_with_compaction_fallback(
|
responses_stream = cast(
|
||||||
client,
|
Any,
|
||||||
body,
|
await client.responses.create(**body),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _timed_stream() -> AsyncIterator[Any]:
|
async def _timed_stream() -> AsyncIterator[Any]:
|
||||||
@@ -1830,7 +1687,6 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
except StopAsyncIteration:
|
except StopAsyncIteration:
|
||||||
break
|
break
|
||||||
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
(
|
(
|
||||||
content,
|
content,
|
||||||
tool_calls,
|
tool_calls,
|
||||||
@@ -1841,26 +1697,15 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
_timed_stream(),
|
_timed_stream(),
|
||||||
on_content_delta,
|
on_content_delta,
|
||||||
on_tool_call_delta=on_tool_call_delta,
|
on_tool_call_delta=on_tool_call_delta,
|
||||||
on_reasoning_delta=on_thinking_delta,
|
|
||||||
capture=capture,
|
|
||||||
)
|
)
|
||||||
self._record_responses_success(model, reasoning_effort)
|
self._record_responses_success(model, reasoning_effort)
|
||||||
result = LLMResponse(
|
return LLMResponse(
|
||||||
content=content or None,
|
content=content or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
reasoning_content=reasoning_content,
|
reasoning_content=reasoning_content,
|
||||||
)
|
)
|
||||||
if capture.completed and is_replayable_finish_reason(finish_reason):
|
|
||||||
result.provider_state = build_responses_state(
|
|
||||||
provider=self._responses_state_provider(),
|
|
||||||
model=str(body["model"]),
|
|
||||||
input_items=cast(list[dict[str, Any]], body["input"]),
|
|
||||||
output_items=capture.output_items,
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as responses_error:
|
except Exception as responses_error:
|
||||||
if self._spec and self._spec.name == "github_copilot":
|
if self._spec and self._spec.name == "github_copilot":
|
||||||
# Copilot gateway exposes GPT-5/o-series only via /responses;
|
# Copilot gateway exposes GPT-5/o-series only via /responses;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Shared helpers for provider backends that implement the OpenAI Responses protocol."""
|
"""Shared helpers for OpenAI Responses API providers (Codex, Azure OpenAI)."""
|
||||||
|
|
||||||
from nanobot.providers.openai_responses.converters import (
|
from nanobot.providers.openai_responses.converters import (
|
||||||
convert_messages,
|
convert_messages,
|
||||||
@@ -8,24 +8,13 @@ from nanobot.providers.openai_responses.converters import (
|
|||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.parsing import (
|
from nanobot.providers.openai_responses.parsing import (
|
||||||
FINISH_REASON_MAP,
|
FINISH_REASON_MAP,
|
||||||
ResponsesStreamCapture,
|
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
consume_sse,
|
consume_sse,
|
||||||
consume_sse_with_reasoning,
|
consume_sse_with_reasoning,
|
||||||
is_replayable_finish_reason,
|
|
||||||
iter_sse,
|
iter_sse,
|
||||||
map_finish_reason,
|
map_finish_reason,
|
||||||
parse_response_output,
|
parse_response_output,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.state import (
|
|
||||||
build_responses_state,
|
|
||||||
is_compaction_compatibility_error,
|
|
||||||
prepare_responses_input,
|
|
||||||
resolve_compact_threshold,
|
|
||||||
responses_state_context_tokens,
|
|
||||||
responses_state_items,
|
|
||||||
responses_state_matches,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"convert_messages",
|
"convert_messages",
|
||||||
@@ -36,16 +25,7 @@ __all__ = [
|
|||||||
"consume_sse",
|
"consume_sse",
|
||||||
"consume_sse_with_reasoning",
|
"consume_sse_with_reasoning",
|
||||||
"consume_sdk_stream",
|
"consume_sdk_stream",
|
||||||
"ResponsesStreamCapture",
|
|
||||||
"is_replayable_finish_reason",
|
|
||||||
"map_finish_reason",
|
"map_finish_reason",
|
||||||
"parse_response_output",
|
"parse_response_output",
|
||||||
"build_responses_state",
|
|
||||||
"is_compaction_compatibility_error",
|
|
||||||
"prepare_responses_input",
|
|
||||||
"resolve_compact_threshold",
|
|
||||||
"responses_state_context_tokens",
|
|
||||||
"responses_state_items",
|
|
||||||
"responses_state_matches",
|
|
||||||
"FINISH_REASON_MAP",
|
"FINISH_REASON_MAP",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ def _as_json_object(value: object) -> dict[str, Any] | None:
|
|||||||
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
def convert_messages(
|
def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
preserve_reasoning: bool = False,
|
|
||||||
) -> tuple[str, list[dict[str, Any]]]:
|
|
||||||
"""Convert Chat Completions messages to Responses API input items.
|
"""Convert Chat Completions messages to Responses API input items.
|
||||||
|
|
||||||
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
|
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
|
||||||
@@ -40,13 +36,6 @@ def convert_messages(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if role == "assistant":
|
if role == "assistant":
|
||||||
if preserve_reasoning:
|
|
||||||
reasoning = msg.get("reasoning_content")
|
|
||||||
if isinstance(reasoning, str) and reasoning:
|
|
||||||
input_items.append({
|
|
||||||
"type": "reasoning",
|
|
||||||
"content": reasoning,
|
|
||||||
})
|
|
||||||
if isinstance(content, str) and content:
|
if isinstance(content, str) and content:
|
||||||
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
|
||||||
input_items.append({
|
input_items.append({
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, AsyncGenerator, cast
|
from typing import Any, AsyncGenerator, cast
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest, parse_tool_arguments
|
from nanobot.providers.base import LLMResponse, ToolCallRequest, parse_tool_arguments
|
||||||
from nanobot.providers.openai_responses.state import build_responses_state
|
|
||||||
|
|
||||||
FINISH_REASON_MAP = {
|
FINISH_REASON_MAP = {
|
||||||
"completed": "stop",
|
"completed": "stop",
|
||||||
@@ -19,42 +17,6 @@ FINISH_REASON_MAP = {
|
|||||||
"failed": "error",
|
"failed": "error",
|
||||||
"cancelled": "error",
|
"cancelled": "error",
|
||||||
}
|
}
|
||||||
REPLAYABLE_FINISH_REASONS = frozenset({"stop", "tool_calls", "function_call"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ResponsesStreamCapture:
|
|
||||||
"""Losslessly capture terminal output items without changing stream results."""
|
|
||||||
|
|
||||||
completed: bool = False
|
|
||||||
response: dict[str, Any] | None = field(default=None, repr=False)
|
|
||||||
_items_by_index: dict[int, dict[str, Any]] = field(default_factory=dict, repr=False)
|
|
||||||
|
|
||||||
def record_output_item(self, index: object, item: object) -> None:
|
|
||||||
item_object = _response_object(item)
|
|
||||||
if item_object is None:
|
|
||||||
return
|
|
||||||
output_index = (
|
|
||||||
index
|
|
||||||
if isinstance(index, int) and not isinstance(index, bool)
|
|
||||||
else len(self._items_by_index)
|
|
||||||
)
|
|
||||||
self._items_by_index[output_index] = item_object
|
|
||||||
|
|
||||||
def record_completed(self, response: object) -> None:
|
|
||||||
response_object = _response_object(response)
|
|
||||||
if response_object is None:
|
|
||||||
return
|
|
||||||
self.completed = True
|
|
||||||
self.response = response_object
|
|
||||||
|
|
||||||
@property
|
|
||||||
def output_items(self) -> list[dict[str, Any]]:
|
|
||||||
if self.response is not None:
|
|
||||||
output = _response_object_list(self.response.get("output"))
|
|
||||||
if output:
|
|
||||||
return output
|
|
||||||
return [self._items_by_index[index] for index in sorted(self._items_by_index)]
|
|
||||||
|
|
||||||
|
|
||||||
def _as_json_object(value: object) -> dict[str, Any] | None:
|
def _as_json_object(value: object) -> dict[str, Any] | None:
|
||||||
@@ -69,9 +31,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
|
|||||||
return object_value
|
return object_value
|
||||||
dump = getattr(value, "model_dump", None)
|
dump = getattr(value, "model_dump", None)
|
||||||
if callable(dump):
|
if callable(dump):
|
||||||
dumped = _as_json_object(dump())
|
return _as_json_object(dump())
|
||||||
if dumped is not None:
|
|
||||||
return dumped
|
|
||||||
try:
|
try:
|
||||||
return _as_json_object(vars(value))
|
return _as_json_object(vars(value))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -94,27 +54,6 @@ def map_finish_reason(status: str | None) -> str:
|
|||||||
return FINISH_REASON_MAP.get(status or "completed", "stop")
|
return FINISH_REASON_MAP.get(status or "completed", "stop")
|
||||||
|
|
||||||
|
|
||||||
def is_replayable_finish_reason(finish_reason: str) -> bool:
|
|
||||||
"""Return whether a response can safely advance opaque conversation state."""
|
|
||||||
return finish_reason in REPLAYABLE_FINISH_REASONS
|
|
||||||
|
|
||||||
|
|
||||||
def _response_finish_reason(
|
|
||||||
response: object,
|
|
||||||
*,
|
|
||||||
fallback_status: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Map terminal response details without treating content filtering as truncation."""
|
|
||||||
response_object = _response_object(response) or {}
|
|
||||||
status = response_object.get("status")
|
|
||||||
terminal_status = status if isinstance(status, str) else fallback_status
|
|
||||||
if terminal_status == "incomplete":
|
|
||||||
details = _response_object(response_object.get("incomplete_details"))
|
|
||||||
if details is not None and details.get("reason") == "content_filter":
|
|
||||||
return "content_filter"
|
|
||||||
return map_finish_reason(terminal_status)
|
|
||||||
|
|
||||||
|
|
||||||
def _usage_from_response_obj(response: object) -> dict[str, int]:
|
def _usage_from_response_obj(response: object) -> dict[str, int]:
|
||||||
response_object = _response_object(response)
|
response_object = _response_object(response)
|
||||||
usage_raw: object = (
|
usage_raw: object = (
|
||||||
@@ -160,47 +99,6 @@ def _tool_arguments_source(*values: Any) -> Any:
|
|||||||
return "{}"
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
def _refusal_event_key(
|
|
||||||
item_id: object,
|
|
||||||
content_index: object,
|
|
||||||
) -> tuple[str | None, int | None]:
|
|
||||||
"""Identify one streamed refusal content part across delta/done events."""
|
|
||||||
return (
|
|
||||||
item_id if isinstance(item_id, str) else None,
|
|
||||||
(
|
|
||||||
content_index
|
|
||||||
if isinstance(content_index, int) and not isinstance(content_index, bool)
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _remaining_refusal_text(streamed_text: str, refusal_text: str) -> str:
|
|
||||||
"""Return only text not already surfaced by refusal deltas."""
|
|
||||||
if not streamed_text:
|
|
||||||
return refusal_text
|
|
||||||
if refusal_text.startswith(streamed_text):
|
|
||||||
return refusal_text[len(streamed_text):]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_refusal_text_from_output(output: object) -> tuple[bool, str]:
|
|
||||||
"""Extract refusal content from terminal Responses output items."""
|
|
||||||
refusal_seen = False
|
|
||||||
parts: list[str] = []
|
|
||||||
for item in _response_object_list(output):
|
|
||||||
if item.get("type") != "message":
|
|
||||||
continue
|
|
||||||
for block in _response_object_list(item.get("content")):
|
|
||||||
if block.get("type") != "refusal":
|
|
||||||
continue
|
|
||||||
refusal_seen = True
|
|
||||||
refusal_text = block.get("refusal")
|
|
||||||
if isinstance(refusal_text, str):
|
|
||||||
parts.append(refusal_text)
|
|
||||||
return refusal_seen, "".join(parts)
|
|
||||||
|
|
||||||
|
|
||||||
async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
async def iter_sse(response: httpx.Response) -> AsyncGenerator[dict[str, Any], None]:
|
||||||
"""Yield parsed JSON events from a Responses API SSE stream."""
|
"""Yield parsed JSON events from a Responses API SSE stream."""
|
||||||
buffer: list[str] = []
|
buffer: list[str] = []
|
||||||
@@ -255,7 +153,6 @@ async def consume_sse_with_reasoning(
|
|||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_response_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_response_event: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
capture: ResponsesStreamCapture | None = None,
|
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
|
"""Consume a Responses API SSE stream, including visible reasoning summaries."""
|
||||||
content = ""
|
content = ""
|
||||||
@@ -266,9 +163,6 @@ async def consume_sse_with_reasoning(
|
|||||||
usage: dict[str, int] = {}
|
usage: dict[str, int] = {}
|
||||||
reasoning_content: str | None = None
|
reasoning_content: str | None = None
|
||||||
streamed_reasoning = False
|
streamed_reasoning = False
|
||||||
refusal_seen = False
|
|
||||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
|
||||||
emitted_refusal_text = ""
|
|
||||||
|
|
||||||
async for event in iter_sse(response):
|
async for event in iter_sse(response):
|
||||||
if on_response_event:
|
if on_response_event:
|
||||||
@@ -297,33 +191,6 @@ async def consume_sse_with_reasoning(
|
|||||||
content += delta_text
|
content += delta_text
|
||||||
if on_content_delta and delta_text:
|
if on_content_delta and delta_text:
|
||||||
await on_content_delta(delta_text)
|
await on_content_delta(delta_text)
|
||||||
elif event_type == "response.refusal.delta":
|
|
||||||
refusal_seen = True
|
|
||||||
delta_text = event.get("delta")
|
|
||||||
if isinstance(delta_text, str) and delta_text:
|
|
||||||
key = _refusal_event_key(
|
|
||||||
event.get("item_id"),
|
|
||||||
event.get("content_index"),
|
|
||||||
)
|
|
||||||
refusal_deltas[key] = refusal_deltas.get(key, "") + delta_text
|
|
||||||
content += delta_text
|
|
||||||
emitted_refusal_text += delta_text
|
|
||||||
if on_content_delta:
|
|
||||||
await on_content_delta(delta_text)
|
|
||||||
elif event_type == "response.refusal.done":
|
|
||||||
refusal_seen = True
|
|
||||||
refusal_text = event.get("refusal")
|
|
||||||
key = _refusal_event_key(
|
|
||||||
event.get("item_id"),
|
|
||||||
event.get("content_index"),
|
|
||||||
)
|
|
||||||
streamed_text = refusal_deltas.pop(key, "")
|
|
||||||
if isinstance(refusal_text, str) and refusal_text:
|
|
||||||
remaining_text = _remaining_refusal_text(streamed_text, refusal_text)
|
|
||||||
content += remaining_text
|
|
||||||
emitted_refusal_text += remaining_text
|
|
||||||
if on_content_delta and remaining_text:
|
|
||||||
await on_content_delta(remaining_text)
|
|
||||||
elif event_type == "response.reasoning_summary_text.delta":
|
elif event_type == "response.reasoning_summary_text.delta":
|
||||||
delta_text = event.get("delta") or ""
|
delta_text = event.get("delta") or ""
|
||||||
if delta_text:
|
if delta_text:
|
||||||
@@ -372,8 +239,6 @@ async def consume_sse_with_reasoning(
|
|||||||
})
|
})
|
||||||
elif event_type == "response.output_item.done":
|
elif event_type == "response.output_item.done":
|
||||||
item = _as_json_object(event.get("item")) or {}
|
item = _as_json_object(event.get("item")) or {}
|
||||||
if capture is not None:
|
|
||||||
capture.record_output_item(event.get("output_index"), item)
|
|
||||||
if item.get("type") == "function_call":
|
if item.get("type") == "function_call":
|
||||||
call_id = item.get("call_id")
|
call_id = item.get("call_id")
|
||||||
if not call_id:
|
if not call_id:
|
||||||
@@ -404,28 +269,11 @@ async def consume_sse_with_reasoning(
|
|||||||
reasoning_content = summary
|
reasoning_content = summary
|
||||||
if on_reasoning_delta:
|
if on_reasoning_delta:
|
||||||
await on_reasoning_delta(summary)
|
await on_reasoning_delta(summary)
|
||||||
elif event_type in {"response.completed", "response.incomplete"}:
|
elif event_type == "response.completed":
|
||||||
response_obj = _response_object(event.get("response")) or {}
|
response_obj = _response_object(event.get("response")) or {}
|
||||||
if capture is not None:
|
status = response_obj.get("status")
|
||||||
capture.record_completed(response_obj)
|
finish_reason = map_finish_reason(status)
|
||||||
finish_reason = _response_finish_reason(
|
|
||||||
response_obj,
|
|
||||||
fallback_status=event_type.removeprefix("response."),
|
|
||||||
)
|
|
||||||
usage = _usage_from_response_obj(response_obj) or usage
|
usage = _usage_from_response_obj(response_obj) or usage
|
||||||
terminal_refusal, terminal_refusal_text = _extract_refusal_text_from_output(
|
|
||||||
response_obj.get("output")
|
|
||||||
)
|
|
||||||
if terminal_refusal:
|
|
||||||
refusal_seen = True
|
|
||||||
remaining_text = _remaining_refusal_text(
|
|
||||||
emitted_refusal_text,
|
|
||||||
terminal_refusal_text,
|
|
||||||
)
|
|
||||||
content += remaining_text
|
|
||||||
emitted_refusal_text += remaining_text
|
|
||||||
if on_content_delta and remaining_text:
|
|
||||||
await on_content_delta(remaining_text)
|
|
||||||
if not reasoning_content:
|
if not reasoning_content:
|
||||||
summary = _extract_reasoning_summary_from_output(response_obj.get("output"))
|
summary = _extract_reasoning_summary_from_output(response_obj.get("output"))
|
||||||
if summary:
|
if summary:
|
||||||
@@ -436,8 +284,6 @@ async def consume_sse_with_reasoning(
|
|||||||
detail = event.get("error") or event.get("message") or event
|
detail = event.get("error") or event.get("message") or event
|
||||||
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
||||||
|
|
||||||
if refusal_seen:
|
|
||||||
finish_reason = "refusal"
|
|
||||||
return content, tool_calls, finish_reason, usage, reasoning_content
|
return content, tool_calls, finish_reason, usage, reasoning_content
|
||||||
|
|
||||||
|
|
||||||
@@ -446,14 +292,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
|||||||
for item in _response_object_list(output):
|
for item in _response_object_list(output):
|
||||||
if item.get("type") != "reasoning":
|
if item.get("type") != "reasoning":
|
||||||
continue
|
continue
|
||||||
content = item.get("content")
|
|
||||||
if isinstance(content, str) and content:
|
|
||||||
parts.append(content)
|
|
||||||
elif isinstance(content, list):
|
|
||||||
for block in _response_object_list(cast(list[object], content)):
|
|
||||||
text = block.get("text")
|
|
||||||
if isinstance(text, str) and text:
|
|
||||||
parts.append(text)
|
|
||||||
for summary in _response_object_list(item.get("summary")):
|
for summary in _response_object_list(item.get("summary")):
|
||||||
if summary.get("type") == "summary_text" and summary.get("text"):
|
if summary.get("type") == "summary_text" and summary.get("text"):
|
||||||
text = summary.get("text")
|
text = summary.get("text")
|
||||||
@@ -462,13 +300,7 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
|
|||||||
return "".join(parts) or None
|
return "".join(parts) or None
|
||||||
|
|
||||||
|
|
||||||
def parse_response_output(
|
def parse_response_output(response: object) -> LLMResponse:
|
||||||
response: object,
|
|
||||||
*,
|
|
||||||
state_provider: str | None = None,
|
|
||||||
state_model: str | None = None,
|
|
||||||
state_input_items: list[dict[str, Any]] | None = None,
|
|
||||||
) -> LLMResponse:
|
|
||||||
"""Parse an SDK ``Response`` object into an ``LLMResponse``."""
|
"""Parse an SDK ``Response`` object into an ``LLMResponse``."""
|
||||||
response_object = _response_object(response) or {}
|
response_object = _response_object(response) or {}
|
||||||
|
|
||||||
@@ -476,26 +308,21 @@ def parse_response_output(
|
|||||||
content_parts: list[str] = []
|
content_parts: list[str] = []
|
||||||
tool_calls: list[ToolCallRequest] = []
|
tool_calls: list[ToolCallRequest] = []
|
||||||
reasoning_content: str | None = None
|
reasoning_content: str | None = None
|
||||||
refusal_seen = False
|
|
||||||
|
|
||||||
for item in output:
|
for item in output:
|
||||||
item_type = item.get("type")
|
item_type = item.get("type")
|
||||||
if item_type == "message":
|
if item_type == "message":
|
||||||
for block in _response_object_list(item.get("content")):
|
for block in _response_object_list(item.get("content")):
|
||||||
block_type = block.get("type")
|
if block.get("type") == "output_text":
|
||||||
if block_type == "output_text":
|
|
||||||
text = block.get("text")
|
text = block.get("text")
|
||||||
if isinstance(text, str):
|
if isinstance(text, str):
|
||||||
content_parts.append(text)
|
content_parts.append(text)
|
||||||
elif block_type == "refusal":
|
|
||||||
refusal_seen = True
|
|
||||||
refusal = block.get("refusal")
|
|
||||||
if isinstance(refusal, str):
|
|
||||||
content_parts.append(refusal)
|
|
||||||
elif item_type == "reasoning":
|
elif item_type == "reasoning":
|
||||||
text = _extract_reasoning_summary_from_output([item])
|
for s in _response_object_list(item.get("summary")):
|
||||||
if text:
|
if s.get("type") == "summary_text" and s.get("text"):
|
||||||
reasoning_content = (reasoning_content or "") + text
|
text = s.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
reasoning_content = (reasoning_content or "") + text
|
||||||
elif item_type == "function_call":
|
elif item_type == "function_call":
|
||||||
call_id = item.get("call_id") or ""
|
call_id = item.get("call_id") or ""
|
||||||
item_id = item.get("id") or "fc_0"
|
item_id = item.get("id") or "fc_0"
|
||||||
@@ -510,38 +337,21 @@ def parse_response_output(
|
|||||||
usage = _usage_from_response_obj(response_object)
|
usage = _usage_from_response_obj(response_object)
|
||||||
|
|
||||||
status = response_object.get("status")
|
status = response_object.get("status")
|
||||||
finish_reason = "refusal" if refusal_seen else _response_finish_reason(response_object)
|
finish_reason = map_finish_reason(status if isinstance(status, str) else None)
|
||||||
|
|
||||||
result = LLMResponse(
|
return LLMResponse(
|
||||||
content="".join(content_parts) or None,
|
content="".join(content_parts) or None,
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=finish_reason,
|
finish_reason=finish_reason,
|
||||||
usage=usage,
|
usage=usage,
|
||||||
reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None,
|
reasoning_content=reasoning_content if isinstance(reasoning_content, str) else None,
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
state_provider is not None
|
|
||||||
and state_model is not None
|
|
||||||
and state_input_items is not None
|
|
||||||
and (status is None or status == "completed")
|
|
||||||
and is_replayable_finish_reason(finish_reason)
|
|
||||||
):
|
|
||||||
result.provider_state = build_responses_state(
|
|
||||||
provider=state_provider,
|
|
||||||
model=state_model,
|
|
||||||
input_items=state_input_items,
|
|
||||||
output_items=output,
|
|
||||||
usage=usage,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def consume_sdk_stream(
|
async def consume_sdk_stream(
|
||||||
stream: Any,
|
stream: Any,
|
||||||
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
|
||||||
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
|
||||||
on_reasoning_delta: Callable[[str], Awaitable[None]] | None = None,
|
|
||||||
capture: ResponsesStreamCapture | None = None,
|
|
||||||
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
|
||||||
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
"""Consume an SDK async stream from ``client.responses.create(stream=True)``."""
|
||||||
content = ""
|
content = ""
|
||||||
@@ -551,10 +361,6 @@ async def consume_sdk_stream(
|
|||||||
finish_reason = "stop"
|
finish_reason = "stop"
|
||||||
usage: dict[str, int] = {}
|
usage: dict[str, int] = {}
|
||||||
reasoning_content: str | None = None
|
reasoning_content: str | None = None
|
||||||
streamed_reasoning = False
|
|
||||||
refusal_seen = False
|
|
||||||
refusal_deltas: dict[tuple[str | None, int | None], str] = {}
|
|
||||||
emitted_refusal_text = ""
|
|
||||||
|
|
||||||
async for raw_event in stream:
|
async for raw_event in stream:
|
||||||
event: Any = raw_event
|
event: Any = raw_event
|
||||||
@@ -582,46 +388,6 @@ async def consume_sdk_stream(
|
|||||||
content += delta_text
|
content += delta_text
|
||||||
if on_content_delta and delta_text:
|
if on_content_delta and delta_text:
|
||||||
await on_content_delta(delta_text)
|
await on_content_delta(delta_text)
|
||||||
elif event_type == "response.reasoning_text.delta":
|
|
||||||
delta_text = getattr(event, "delta", "") or ""
|
|
||||||
if delta_text:
|
|
||||||
reasoning_content = (reasoning_content or "") + delta_text
|
|
||||||
streamed_reasoning = True
|
|
||||||
if on_reasoning_delta:
|
|
||||||
await on_reasoning_delta(delta_text)
|
|
||||||
elif event_type == "response.reasoning_text.done":
|
|
||||||
text = getattr(event, "text", "") or ""
|
|
||||||
if text and not streamed_reasoning and not reasoning_content:
|
|
||||||
reasoning_content = text
|
|
||||||
if on_reasoning_delta:
|
|
||||||
await on_reasoning_delta(text)
|
|
||||||
elif event_type == "response.refusal.delta":
|
|
||||||
refusal_seen = True
|
|
||||||
delta_text = getattr(event, "delta", None)
|
|
||||||
if isinstance(delta_text, str) and delta_text:
|
|
||||||
key = _refusal_event_key(
|
|
||||||
getattr(event, "item_id", None),
|
|
||||||
getattr(event, "content_index", None),
|
|
||||||
)
|
|
||||||
refusal_deltas[key] = refusal_deltas.get(key, "") + delta_text
|
|
||||||
content += delta_text
|
|
||||||
emitted_refusal_text += delta_text
|
|
||||||
if on_content_delta:
|
|
||||||
await on_content_delta(delta_text)
|
|
||||||
elif event_type == "response.refusal.done":
|
|
||||||
refusal_seen = True
|
|
||||||
refusal_text = getattr(event, "refusal", None)
|
|
||||||
key = _refusal_event_key(
|
|
||||||
getattr(event, "item_id", None),
|
|
||||||
getattr(event, "content_index", None),
|
|
||||||
)
|
|
||||||
streamed_text = refusal_deltas.pop(key, "")
|
|
||||||
if isinstance(refusal_text, str) and refusal_text:
|
|
||||||
remaining_text = _remaining_refusal_text(streamed_text, refusal_text)
|
|
||||||
content += remaining_text
|
|
||||||
emitted_refusal_text += remaining_text
|
|
||||||
if on_content_delta and remaining_text:
|
|
||||||
await on_content_delta(remaining_text)
|
|
||||||
elif event_type == "response.function_call_arguments.delta":
|
elif event_type == "response.function_call_arguments.delta":
|
||||||
call_id = getattr(event, "call_id", None)
|
call_id = getattr(event, "call_id", None)
|
||||||
if call_id and call_id in tool_call_buffers:
|
if call_id and call_id in tool_call_buffers:
|
||||||
@@ -650,8 +416,6 @@ async def consume_sdk_stream(
|
|||||||
})
|
})
|
||||||
elif event_type == "response.output_item.done":
|
elif event_type == "response.output_item.done":
|
||||||
item = getattr(event, "item", None)
|
item = getattr(event, "item", None)
|
||||||
if capture is not None:
|
|
||||||
capture.record_output_item(getattr(event, "output_index", None), item)
|
|
||||||
if item and getattr(item, "type", None) == "function_call":
|
if item and getattr(item, "type", None) == "function_call":
|
||||||
call_id = getattr(item, "call_id", None)
|
call_id = getattr(item, "call_id", None)
|
||||||
if not call_id:
|
if not call_id:
|
||||||
@@ -679,31 +443,10 @@ async def consume_sdk_stream(
|
|||||||
arguments=args,
|
arguments=args,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif event_type in {"response.completed", "response.incomplete"}:
|
elif event_type == "response.completed":
|
||||||
resp = getattr(event, "response", None)
|
resp = getattr(event, "response", None)
|
||||||
response_obj = _response_object(resp) or {}
|
status = getattr(resp, "status", None) if resp else None
|
||||||
if capture is not None:
|
finish_reason = map_finish_reason(status)
|
||||||
capture.record_completed(resp)
|
|
||||||
finish_reason = _response_finish_reason(
|
|
||||||
resp,
|
|
||||||
fallback_status=event_type.removeprefix("response."),
|
|
||||||
)
|
|
||||||
terminal_output = response_obj.get("output")
|
|
||||||
if terminal_output is None:
|
|
||||||
terminal_output = getattr(resp, "output", None)
|
|
||||||
terminal_refusal, terminal_refusal_text = _extract_refusal_text_from_output(
|
|
||||||
terminal_output
|
|
||||||
)
|
|
||||||
if terminal_refusal:
|
|
||||||
refusal_seen = True
|
|
||||||
remaining_text = _remaining_refusal_text(
|
|
||||||
emitted_refusal_text,
|
|
||||||
terminal_refusal_text,
|
|
||||||
)
|
|
||||||
content += remaining_text
|
|
||||||
emitted_refusal_text += remaining_text
|
|
||||||
if on_content_delta and remaining_text:
|
|
||||||
await on_content_delta(remaining_text)
|
|
||||||
if resp:
|
if resp:
|
||||||
usage_obj = getattr(resp, "usage", None)
|
usage_obj = getattr(resp, "usage", None)
|
||||||
if usage_obj:
|
if usage_obj:
|
||||||
@@ -712,16 +455,15 @@ async def consume_sdk_stream(
|
|||||||
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
|
"completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
|
||||||
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
|
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
|
||||||
}
|
}
|
||||||
if not reasoning_content:
|
for out_item in cast(list[Any], getattr(resp, "output", None) or []):
|
||||||
reasoning_content = _extract_reasoning_summary_from_output(
|
if getattr(out_item, "type", None) == "reasoning":
|
||||||
getattr(resp, "output", None)
|
for s in cast(list[Any], getattr(out_item, "summary", None) or []):
|
||||||
)
|
if getattr(s, "type", None) == "summary_text":
|
||||||
if reasoning_content and on_reasoning_delta:
|
text = getattr(s, "text", None)
|
||||||
await on_reasoning_delta(reasoning_content)
|
if text:
|
||||||
|
reasoning_content = (reasoning_content or "") + text
|
||||||
elif event_type in {"error", "response.failed"}:
|
elif event_type in {"error", "response.failed"}:
|
||||||
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
|
detail = getattr(event, "error", None) or getattr(event, "message", None) or event
|
||||||
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
raise RuntimeError(f"Response failed: {str(detail)[:500]}")
|
||||||
|
|
||||||
if refusal_seen:
|
|
||||||
finish_reason = "refusal"
|
|
||||||
return content, tool_calls, finish_reason, usage, reasoning_content
|
return content, tool_calls, finish_reason, usage, reasoning_content
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
"""Opaque conversation state for Responses API item replay."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.providers.openai_responses.converters import convert_messages
|
|
||||||
|
|
||||||
RESPONSES_STATE_KIND = "openai_responses"
|
|
||||||
RESPONSES_STATE_VERSION = 1
|
|
||||||
_ITEMS_KEY = "items"
|
|
||||||
_CONTEXT_TOKENS_KEY = "context_tokens"
|
|
||||||
_COMPACTION_ITEM_TYPES = frozenset({
|
|
||||||
"compaction",
|
|
||||||
"compaction_summary",
|
|
||||||
"context_compaction",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def responses_state_matches(
|
|
||||||
state: ProviderConversationState,
|
|
||||||
*,
|
|
||||||
provider: str,
|
|
||||||
model: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Return whether *state* belongs to this exact Responses endpoint/model."""
|
|
||||||
return (
|
|
||||||
state.kind == RESPONSES_STATE_KIND
|
|
||||||
and state.version == RESPONSES_STATE_VERSION
|
|
||||||
and state.provider == provider
|
|
||||||
and state.model == model
|
|
||||||
and _state_items(state) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_responses_input(
|
|
||||||
messages: list[dict[str, Any]],
|
|
||||||
*,
|
|
||||||
state: ProviderConversationState | None,
|
|
||||||
provider: str,
|
|
||||||
model: str,
|
|
||||||
preserve_reasoning: bool = False,
|
|
||||||
) -> tuple[str, list[dict[str, Any]], bool]:
|
|
||||||
"""Build a request from exact prior items plus only newly appended messages.
|
|
||||||
|
|
||||||
The full Chat transcript remains the source for the current instructions.
|
|
||||||
When no compatible state exists, it is converted normally as a safe
|
|
||||||
fallback.
|
|
||||||
"""
|
|
||||||
instructions, fallback_items = convert_messages(
|
|
||||||
messages,
|
|
||||||
preserve_reasoning=preserve_reasoning,
|
|
||||||
)
|
|
||||||
if state is None or not responses_state_matches(
|
|
||||||
state,
|
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
):
|
|
||||||
return instructions, fallback_items, False
|
|
||||||
|
|
||||||
prior_items = _state_items(state)
|
|
||||||
if prior_items is None:
|
|
||||||
return instructions, fallback_items, False
|
|
||||||
|
|
||||||
_, delta_items = convert_messages(
|
|
||||||
state.pending_messages,
|
|
||||||
preserve_reasoning=preserve_reasoning,
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"Replaying Responses state: prior_items={} pending_messages={}",
|
|
||||||
len(prior_items),
|
|
||||||
len(state.pending_messages),
|
|
||||||
)
|
|
||||||
return instructions, [*deepcopy(prior_items), *delta_items], True
|
|
||||||
|
|
||||||
|
|
||||||
def build_responses_state(
|
|
||||||
*,
|
|
||||||
provider: str,
|
|
||||||
model: str,
|
|
||||||
input_items: list[dict[str, Any]],
|
|
||||||
output_items: list[dict[str, Any]],
|
|
||||||
usage: dict[str, int] | None = None,
|
|
||||||
) -> ProviderConversationState:
|
|
||||||
"""Create the canonical next state from request input and every output item."""
|
|
||||||
unpruned_items = [*input_items, *output_items]
|
|
||||||
items = _prune_before_latest_output_compaction(input_items, output_items)
|
|
||||||
if len(items) < len(unpruned_items):
|
|
||||||
logger.info(
|
|
||||||
"Installed Responses compaction: dropped_items={} retained_items={}",
|
|
||||||
len(unpruned_items) - len(items),
|
|
||||||
len(items),
|
|
||||||
)
|
|
||||||
payload: dict[str, Any] = {_ITEMS_KEY: deepcopy(items)}
|
|
||||||
context_tokens = _context_tokens_from_usage(usage)
|
|
||||||
if context_tokens > 0:
|
|
||||||
payload[_CONTEXT_TOKENS_KEY] = context_tokens
|
|
||||||
return ProviderConversationState(
|
|
||||||
kind=RESPONSES_STATE_KIND,
|
|
||||||
provider=provider,
|
|
||||||
model=model,
|
|
||||||
version=RESPONSES_STATE_VERSION,
|
|
||||||
payload=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def responses_state_items(
|
|
||||||
state: ProviderConversationState,
|
|
||||||
) -> list[dict[str, Any]] | None:
|
|
||||||
"""Return an isolated copy of canonical input items for tests/consumers."""
|
|
||||||
items = _state_items(state)
|
|
||||||
return deepcopy(items) if items is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def responses_state_context_tokens(state: ProviderConversationState) -> int:
|
|
||||||
"""Return the last server-reported active context size."""
|
|
||||||
value = state.payload.get(_CONTEXT_TOKENS_KEY)
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
|
||||||
return 0
|
|
||||||
return max(0, value)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_compact_threshold(
|
|
||||||
context_window_tokens: int | None,
|
|
||||||
max_output_tokens: int,
|
|
||||||
) -> int | None:
|
|
||||||
"""Derive Codex-compatible 90% compaction headroom for a model window."""
|
|
||||||
if context_window_tokens is None or context_window_tokens <= 0:
|
|
||||||
return None
|
|
||||||
ninety_percent = max(1, context_window_tokens * 9 // 10)
|
|
||||||
output_headroom = max(1, context_window_tokens - max(1, max_output_tokens))
|
|
||||||
return min(ninety_percent, output_headroom)
|
|
||||||
|
|
||||||
|
|
||||||
def is_compaction_compatibility_error(exc: Exception) -> bool:
|
|
||||||
"""Recognize endpoints that reject native Responses compaction fields."""
|
|
||||||
if getattr(exc, "compaction_unsupported", False) is True:
|
|
||||||
return True
|
|
||||||
response = getattr(exc, "response", None)
|
|
||||||
status_code = getattr(exc, "status_code", None)
|
|
||||||
if status_code is None and response is not None:
|
|
||||||
status_code = getattr(response, "status_code", None)
|
|
||||||
body = (
|
|
||||||
getattr(exc, "body", None)
|
|
||||||
or getattr(exc, "doc", None)
|
|
||||||
or getattr(response, "text", None)
|
|
||||||
or str(exc)
|
|
||||||
)
|
|
||||||
text = str(body).lower()
|
|
||||||
has_compaction_marker = any(
|
|
||||||
marker in text
|
|
||||||
for marker in ("context_management", "compact_threshold", "compaction_trigger")
|
|
||||||
)
|
|
||||||
if not has_compaction_marker:
|
|
||||||
return False
|
|
||||||
return isinstance(exc, TypeError) or status_code in {400, 404, 422}
|
|
||||||
|
|
||||||
|
|
||||||
def _prune_before_latest_output_compaction(
|
|
||||||
input_items: list[dict[str, Any]],
|
|
||||||
output_items: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Drop old input only when this response emits a new compaction item.
|
|
||||||
|
|
||||||
A canonical compacted input may intentionally retain messages before its
|
|
||||||
compaction item. Those messages must survive ordinary subsequent responses.
|
|
||||||
"""
|
|
||||||
latest = None
|
|
||||||
for index, item in enumerate(output_items):
|
|
||||||
if item.get("type") in _COMPACTION_ITEM_TYPES:
|
|
||||||
latest = index
|
|
||||||
if latest is None:
|
|
||||||
return [*input_items, *output_items]
|
|
||||||
return output_items[latest:]
|
|
||||||
|
|
||||||
|
|
||||||
def _context_tokens_from_usage(usage: dict[str, int] | None) -> int:
|
|
||||||
if not usage:
|
|
||||||
return 0
|
|
||||||
prompt_tokens = usage.get("prompt_tokens", 0)
|
|
||||||
completion_tokens = usage.get("completion_tokens", 0)
|
|
||||||
total_tokens = usage.get("total_tokens", 0)
|
|
||||||
values = (prompt_tokens, completion_tokens, total_tokens)
|
|
||||||
if any(isinstance(value, bool) for value in values):
|
|
||||||
return 0
|
|
||||||
return max(0, total_tokens or prompt_tokens + completion_tokens)
|
|
||||||
|
|
||||||
|
|
||||||
def _state_items(
|
|
||||||
state: ProviderConversationState,
|
|
||||||
) -> list[dict[str, Any]] | None:
|
|
||||||
raw_items = state.payload.get(_ITEMS_KEY)
|
|
||||||
if not isinstance(raw_items, list):
|
|
||||||
return None
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for raw in cast(list[object], raw_items):
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
return None
|
|
||||||
items.append(cast(dict[str, Any], raw))
|
|
||||||
return items
|
|
||||||
@@ -111,11 +111,6 @@ class ProviderSpec:
|
|||||||
# Substring match against the wire model name (lowercased).
|
# Substring match against the wire model name (lowercased).
|
||||||
implicit_reasoning_models: tuple[str, ...] = ()
|
implicit_reasoning_models: tuple[str, ...] = ()
|
||||||
|
|
||||||
# Models that expose the OpenAI Responses wire format. This is model-level
|
|
||||||
# because providers may add Responses support incrementally (DeepSeek V4
|
|
||||||
# Flash is supported before V4 Pro).
|
|
||||||
responses_models: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
# When the model returns content as a list of {"type":"thinking",...} +
|
# When the model returns content as a list of {"type":"thinking",...} +
|
||||||
# {"type":"text",...} blocks, extract the thinking text into
|
# {"type":"text",...} blocks, extract the thinking text into
|
||||||
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use
|
||||||
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
|||||||
backend="openai_compat",
|
backend="openai_compat",
|
||||||
default_api_base="https://api.deepseek.com",
|
default_api_base="https://api.deepseek.com",
|
||||||
thinking_style="thinking_type",
|
thinking_style="thinking_type",
|
||||||
responses_models=("deepseek-v4-flash",),
|
|
||||||
),
|
),
|
||||||
# Gemini: Google's OpenAI-compatible endpoint
|
# Gemini: Google's OpenAI-compatible endpoint
|
||||||
ProviderSpec(
|
ProviderSpec(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
|
|||||||
from urllib.request import getproxies, proxy_bypass
|
from urllib.request import getproxies, proxy_bypass
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import httpx2
|
||||||
|
|
||||||
_BLOCKED_NETWORKS = [
|
_BLOCKED_NETWORKS = [
|
||||||
ipaddress.ip_network("0.0.0.0/8"),
|
ipaddress.ip_network("0.0.0.0/8"),
|
||||||
@@ -29,6 +30,7 @@ _BLOCKED_NETWORKS = [
|
|||||||
|
|
||||||
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
||||||
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
||||||
|
_DNS_PIN_RESOLVER_LOCK = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
def is_loopback_host(host: str) -> bool:
|
def is_loopback_host(host: str) -> bool:
|
||||||
@@ -195,6 +197,30 @@ def httpx_env_proxy_mounts() -> dict[str, httpx.AsyncBaseTransport | None]:
|
|||||||
return mounts
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
|
def httpx2_env_proxy_mounts() -> dict[str, httpx2.AsyncBaseTransport | None]:
|
||||||
|
"""Build HTTPX2 proxy mounts while leaving direct routes to the base transport."""
|
||||||
|
proxies = getproxies()
|
||||||
|
mounts: dict[str, httpx2.AsyncBaseTransport | None] = {}
|
||||||
|
for scheme in ("http", "https", "all"):
|
||||||
|
proxy_url = proxies.get(scheme)
|
||||||
|
if proxy_url:
|
||||||
|
if "://" not in proxy_url:
|
||||||
|
proxy_url = f"http://{proxy_url}"
|
||||||
|
mounts[f"{scheme}://"] = httpx2.AsyncHTTPTransport(proxy=httpx2.Proxy(proxy_url))
|
||||||
|
|
||||||
|
if not mounts:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
no_proxy = proxies.get("no", "")
|
||||||
|
if no_proxy == "*":
|
||||||
|
return {}
|
||||||
|
for entry in no_proxy.split(","):
|
||||||
|
pattern = _no_proxy_mount_pattern(entry.strip())
|
||||||
|
if pattern:
|
||||||
|
mounts[pattern] = None
|
||||||
|
return mounts
|
||||||
|
|
||||||
|
|
||||||
def _no_proxy_mount_pattern(hostname: str) -> str | None:
|
def _no_proxy_mount_pattern(hostname: str) -> str | None:
|
||||||
if not hostname:
|
if not hostname:
|
||||||
return None
|
return None
|
||||||
@@ -264,7 +290,7 @@ class UnsafeURLRequestError(httpx.RequestError):
|
|||||||
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
||||||
"""HTTPX transport that pins each request to the IPs validated for its URL."""
|
"""HTTPX transport that pins each request to the IPs validated for its URL."""
|
||||||
|
|
||||||
_resolver_lock = asyncio.Lock()
|
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -288,6 +314,37 @@ class PinnedDNSAsyncTransport(httpx.AsyncBaseTransport):
|
|||||||
await self._inner.aclose()
|
await self._inner.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class Httpx2UnsafeURLRequestError(httpx2.RequestError):
|
||||||
|
"""Raised when an HTTPX2 request is rejected by URL safety validation."""
|
||||||
|
|
||||||
|
|
||||||
|
class Httpx2PinnedDNSAsyncTransport(httpx2.AsyncBaseTransport):
|
||||||
|
"""HTTPX2 transport that pins each request to the IPs validated for its URL."""
|
||||||
|
|
||||||
|
_resolver_lock = _DNS_PIN_RESOLVER_LOCK
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
allow_loopback: bool = False,
|
||||||
|
inner: httpx2.AsyncBaseTransport | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._allow_loopback = allow_loopback
|
||||||
|
self._inner = inner or httpx2.AsyncHTTPTransport()
|
||||||
|
|
||||||
|
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
ok, error, resolved_ips = resolve_url_target(url, allow_loopback=self._allow_loopback)
|
||||||
|
if not ok:
|
||||||
|
raise Httpx2UnsafeURLRequestError(error, request=request)
|
||||||
|
async with self._resolver_lock:
|
||||||
|
with pin_resolved_url_dns(url, resolved_ips):
|
||||||
|
return await self._inner.handle_async_request(request)
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
await self._inner.aclose()
|
||||||
|
|
||||||
|
|
||||||
def validate_resolved_url(url: str) -> tuple[bool, str]:
|
def validate_resolved_url(url: str) -> tuple[bool, str]:
|
||||||
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
|
"""Validate an already-fetched URL (e.g. after redirect). Only checks the IP, skips DNS."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from weakref import WeakValueDictionary
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_legacy_sessions_dir
|
from nanobot.config.paths import get_legacy_sessions_dir
|
||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
public_history_message,
|
public_history_message,
|
||||||
@@ -44,10 +43,6 @@ _SESSION_PREVIEW_MAX_CHARS = 120
|
|||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
_SESSION_LIST_PREVIEW_MAX_RECORDS = 200
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
_SESSION_LIST_PREVIEW_MAX_CHARS = 1_000_000
|
||||||
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
_SESSION_DATA_ERRORS = (ValueError, TypeError, AttributeError, KeyError)
|
||||||
_PROVIDER_STATE_RECORD_TYPE = "provider_state"
|
|
||||||
_PROVIDER_STATE_RECORD_PREFIX_RE = re.compile(
|
|
||||||
r'^\s*\{\s*"_type"\s*:\s*"provider_state"\s*(?:,|\})'
|
|
||||||
)
|
|
||||||
_FORK_VOLATILE_METADATA_KEYS = {
|
_FORK_VOLATILE_METADATA_KEYS = {
|
||||||
"goal_state",
|
"goal_state",
|
||||||
"pending_user_turn",
|
"pending_user_turn",
|
||||||
@@ -65,11 +60,6 @@ def _json_object(value: object) -> dict[str, Any]:
|
|||||||
return cast(dict[str, Any], value)
|
return cast(dict[str, Any], value)
|
||||||
|
|
||||||
|
|
||||||
def _is_provider_state_record_line(line: str) -> bool:
|
|
||||||
"""Recognize the canonical private record without decoding its opaque payload."""
|
|
||||||
return _PROVIDER_STATE_RECORD_PREFIX_RE.match(line) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
|
def replay_max_messages_for_context(context_window_tokens: int | None) -> int:
|
||||||
if not context_window_tokens or context_window_tokens <= 0:
|
if not context_window_tokens or context_window_tokens <= 0:
|
||||||
return FILE_MAX_MESSAGES
|
return FILE_MAX_MESSAGES
|
||||||
@@ -156,14 +146,10 @@ class Session:
|
|||||||
updated_at: datetime = field(default_factory=datetime.now)
|
updated_at: datetime = field(default_factory=datetime.now)
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
|
||||||
transient: bool = field(default=False, repr=False, compare=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(cast(object, self.metadata), dict):
|
if not isinstance(cast(object, self.metadata), dict):
|
||||||
self.metadata = {}
|
self.metadata = {}
|
||||||
if not isinstance(cast(object, self.provider_state), ProviderConversationState):
|
|
||||||
self.provider_state = None
|
|
||||||
# An out-of-range offset (corrupt metadata) would hide all history; reset it.
|
# An out-of-range offset (corrupt metadata) would hide all history; reset it.
|
||||||
last_consolidated = cast(object, self.last_consolidated)
|
last_consolidated = cast(object, self.last_consolidated)
|
||||||
if (
|
if (
|
||||||
@@ -318,7 +304,6 @@ class Session:
|
|||||||
"""Clear all messages and reset session to initial state."""
|
"""Clear all messages and reset session to initial state."""
|
||||||
self.messages = []
|
self.messages = []
|
||||||
self.last_consolidated = 0
|
self.last_consolidated = 0
|
||||||
self.provider_state = None
|
|
||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
self.metadata.pop("_last_summary", None)
|
self.metadata.pop("_last_summary", None)
|
||||||
|
|
||||||
@@ -411,8 +396,6 @@ class Session:
|
|||||||
|
|
||||||
self.messages = retained
|
self.messages = retained
|
||||||
self.last_consolidated = new_lc
|
self.last_consolidated = new_lc
|
||||||
if dropped:
|
|
||||||
self.provider_state = None
|
|
||||||
self.updated_at = datetime.now()
|
self.updated_at = datetime.now()
|
||||||
return RetentionResult(
|
return RetentionResult(
|
||||||
dropped=dropped,
|
dropped=dropped,
|
||||||
@@ -534,7 +517,6 @@ class JsonlSessionStore:
|
|||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
last_consolidated = 0
|
last_consolidated = 0
|
||||||
provider_state: ProviderConversationState | None = None
|
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
@@ -545,8 +527,7 @@ class JsonlSessionStore:
|
|||||||
raw_data: object = json.loads(line)
|
raw_data: object = json.loads(line)
|
||||||
data = _json_object(raw_data)
|
data = _json_object(raw_data)
|
||||||
|
|
||||||
record_type = data.get("_type")
|
if data.get("_type") == "metadata":
|
||||||
if record_type == "metadata":
|
|
||||||
metadata_value = cast(object, data.get("metadata", {}))
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
metadata = (
|
metadata = (
|
||||||
cast(dict[str, Any], metadata_value)
|
cast(dict[str, Any], metadata_value)
|
||||||
@@ -571,10 +552,6 @@ class JsonlSessionStore:
|
|||||||
if isinstance(offset, int) and not isinstance(offset, bool)
|
if isinstance(offset, int) and not isinstance(offset, bool)
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
|
|
||||||
provider_state = ProviderConversationState.from_private_record(
|
|
||||||
data.get("state")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
|
|
||||||
@@ -585,7 +562,6 @@ class JsonlSessionStore:
|
|||||||
updated_at=updated_at or datetime.now(),
|
updated_at=updated_at or datetime.now(),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
|
||||||
)
|
)
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Failed to load session {}: {}", key, e)
|
logger.warning("Failed to load session {}: {}", key, e)
|
||||||
@@ -610,7 +586,6 @@ class JsonlSessionStore:
|
|||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
last_consolidated = 0
|
last_consolidated = 0
|
||||||
provider_state: ProviderConversationState | None = None
|
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -628,8 +603,7 @@ class JsonlSessionStore:
|
|||||||
continue
|
continue
|
||||||
data = cast(dict[str, Any], raw_data)
|
data = cast(dict[str, Any], raw_data)
|
||||||
|
|
||||||
record_type = data.get("_type")
|
if data.get("_type") == "metadata":
|
||||||
if record_type == "metadata":
|
|
||||||
metadata_value = cast(object, data.get("metadata", {}))
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
metadata = (
|
metadata = (
|
||||||
cast(dict[str, Any], metadata_value)
|
cast(dict[str, Any], metadata_value)
|
||||||
@@ -650,21 +624,13 @@ class JsonlSessionStore:
|
|||||||
if isinstance(offset, int) and not isinstance(offset, bool)
|
if isinstance(offset, int) and not isinstance(offset, bool)
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
|
|
||||||
candidate = ProviderConversationState.from_private_record(
|
|
||||||
data.get("state")
|
|
||||||
)
|
|
||||||
if candidate is None:
|
|
||||||
skipped += 1
|
|
||||||
else:
|
|
||||||
provider_state = candidate
|
|
||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
|
|
||||||
if skipped:
|
if skipped:
|
||||||
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
|
logger.warning("Skipped {} corrupt lines in session {}", skipped, key)
|
||||||
|
|
||||||
if not messages and not metadata and provider_state is None:
|
if not messages and not metadata:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return Session(
|
return Session(
|
||||||
@@ -674,7 +640,6 @@ class JsonlSessionStore:
|
|||||||
updated_at=updated_at or datetime.now(),
|
updated_at=updated_at or datetime.now(),
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
last_consolidated=last_consolidated,
|
last_consolidated=last_consolidated,
|
||||||
provider_state=provider_state,
|
|
||||||
)
|
)
|
||||||
except _SESSION_DATA_ERRORS as e:
|
except _SESSION_DATA_ERRORS as e:
|
||||||
logger.warning("Repair failed for session {}: {}", key, e)
|
logger.warning("Repair failed for session {}: {}", key, e)
|
||||||
@@ -705,12 +670,6 @@ class JsonlSessionStore:
|
|||||||
"last_consolidated": session.last_consolidated,
|
"last_consolidated": session.last_consolidated,
|
||||||
}
|
}
|
||||||
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
|
||||||
if session.provider_state is not None:
|
|
||||||
provider_state_line = {
|
|
||||||
"_type": _PROVIDER_STATE_RECORD_TYPE,
|
|
||||||
"state": session.provider_state.to_private_record(),
|
|
||||||
}
|
|
||||||
f.write(json.dumps(provider_state_line, ensure_ascii=False) + "\n")
|
|
||||||
for msg in session.messages:
|
for msg in session.messages:
|
||||||
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||||
if fsync:
|
if fsync:
|
||||||
@@ -767,8 +726,7 @@ class JsonlSessionStore:
|
|||||||
continue
|
continue
|
||||||
raw_data: object = json.loads(line)
|
raw_data: object = json.loads(line)
|
||||||
data = _json_object(raw_data)
|
data = _json_object(raw_data)
|
||||||
record_type = data.get("_type")
|
if data.get("_type") == "metadata":
|
||||||
if record_type == "metadata":
|
|
||||||
metadata_value = cast(object, data.get("metadata", {}))
|
metadata_value = cast(object, data.get("metadata", {}))
|
||||||
metadata = (
|
metadata = (
|
||||||
cast(dict[str, Any], metadata_value)
|
cast(dict[str, Any], metadata_value)
|
||||||
@@ -787,8 +745,6 @@ class JsonlSessionStore:
|
|||||||
stored_key = (
|
stored_key = (
|
||||||
stored_key_value if isinstance(stored_key_value, str) else None
|
stored_key_value if isinstance(stored_key_value, str) else None
|
||||||
)
|
)
|
||||||
elif record_type == _PROVIDER_STATE_RECORD_TYPE:
|
|
||||||
continue
|
|
||||||
else:
|
else:
|
||||||
messages.append(data)
|
messages.append(data)
|
||||||
return {
|
return {
|
||||||
@@ -881,8 +837,6 @@ class JsonlSessionStore:
|
|||||||
for line in f:
|
for line in f:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
if _is_provider_state_record_line(line):
|
|
||||||
continue
|
|
||||||
scanned_records += 1
|
scanned_records += 1
|
||||||
scanned_chars += len(line)
|
scanned_chars += len(line)
|
||||||
if (
|
if (
|
||||||
@@ -892,10 +846,7 @@ class JsonlSessionStore:
|
|||||||
break
|
break
|
||||||
raw_item: object = json.loads(line)
|
raw_item: object = json.loads(line)
|
||||||
item = _json_object(raw_item)
|
item = _json_object(raw_item)
|
||||||
if item.get("_type") in {
|
if item.get("_type") == "metadata":
|
||||||
"metadata",
|
|
||||||
_PROVIDER_STATE_RECORD_TYPE,
|
|
||||||
}:
|
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
@@ -965,7 +916,6 @@ class SessionManager:
|
|||||||
self._cache: OrderedDict[str, Session] = OrderedDict()
|
self._cache: OrderedDict[str, Session] = OrderedDict()
|
||||||
# Preserve identity for sessions held by active callers without retaining idle ones.
|
# Preserve identity for sessions held by active callers without retaining idle ones.
|
||||||
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
||||||
self._transient_sessions: dict[str, Session] = {}
|
|
||||||
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
||||||
self._file_cap_archiver: Callable[..., None] | None = None
|
self._file_cap_archiver: Callable[..., None] | None = None
|
||||||
|
|
||||||
@@ -979,10 +929,6 @@ class SessionManager:
|
|||||||
self._overflow_cache[key] = evicted
|
self._overflow_cache[key] = evicted
|
||||||
|
|
||||||
def _cached(self, key: str) -> Session | None:
|
def _cached(self, key: str) -> Session | None:
|
||||||
transient = self._transient_sessions.get(key)
|
|
||||||
if transient is not None:
|
|
||||||
return transient
|
|
||||||
|
|
||||||
session = self._cache.get(key)
|
session = self._cache.get(key)
|
||||||
if session is not None:
|
if session is not None:
|
||||||
self._cache.move_to_end(key)
|
self._cache.move_to_end(key)
|
||||||
@@ -1059,24 +1005,6 @@ class SessionManager:
|
|||||||
self._remember(session)
|
self._remember(session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def get_or_create_transient(self, key: str) -> Session:
|
|
||||||
"""Return an active in-memory session that can never reach the store."""
|
|
||||||
session = self._transient_sessions.get(key)
|
|
||||||
if session is None:
|
|
||||||
self._cache.pop(key, None)
|
|
||||||
self._overflow_cache.pop(key, None)
|
|
||||||
session = Session(key=key, transient=True)
|
|
||||||
self._transient_sessions[key] = session
|
|
||||||
return session
|
|
||||||
|
|
||||||
def is_transient_active(self, key: str) -> bool:
|
|
||||||
"""Return whether *key* still accepts transient turns."""
|
|
||||||
return key in self._transient_sessions
|
|
||||||
|
|
||||||
def discard_transient(self, key: str) -> bool:
|
|
||||||
"""Forget all transient contents without retaining a discarded-key tombstone."""
|
|
||||||
return self._transient_sessions.pop(key, None) is not None
|
|
||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
return self._store.load(key)
|
return self._store.load(key)
|
||||||
|
|
||||||
@@ -1090,9 +1018,6 @@ class SessionManager:
|
|||||||
|
|
||||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Persist a session and retain it in the cache."""
|
"""Persist a session and retain it in the cache."""
|
||||||
if session.transient is True:
|
|
||||||
return
|
|
||||||
|
|
||||||
archiver = self._file_cap_archiver
|
archiver = self._file_cap_archiver
|
||||||
if archiver is not None:
|
if archiver is not None:
|
||||||
session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
@@ -1125,7 +1050,6 @@ class SessionManager:
|
|||||||
|
|
||||||
def invalidate(self, key: str) -> None:
|
def invalidate(self, key: str) -> None:
|
||||||
"""Remove a session from the in-memory cache."""
|
"""Remove a session from the in-memory cache."""
|
||||||
self._transient_sessions.pop(key, None)
|
|
||||||
self._cache.pop(key, None)
|
self._cache.pop(key, None)
|
||||||
self._overflow_cache.pop(key, None)
|
self._overflow_cache.pop(key, None)
|
||||||
|
|
||||||
|
|||||||
@@ -334,16 +334,6 @@ def clear_websocket_turn_if_current(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def clear_websocket_turns(chat_id: str) -> int:
|
|
||||||
"""Clear every in-memory lifecycle owner for a discarded chat."""
|
|
||||||
turns = _WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
|
||||||
count = len(turns) if turns is not None else 0
|
|
||||||
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
|
|
||||||
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
|
|
||||||
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
def build_bus_progress_callback(
|
def build_bus_progress_callback(
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -176,10 +176,7 @@ class GitStore:
|
|||||||
)
|
)
|
||||||
if cast(object, sha_bytes) is None:
|
if cast(object, sha_bytes) is None:
|
||||||
return None
|
return None
|
||||||
# porcelain.commit returns the id as a 40-char hex string that is
|
sha = sha_bytes.hex()[:8]
|
||||||
# already encoded to bytes; .hex() would encode those ASCII bytes
|
|
||||||
# again and produce an id no git command can resolve.
|
|
||||||
sha = sha_bytes.decode()[:8]
|
|
||||||
logger.debug("Git auto-commit: {} ({})", sha, message)
|
logger.debug("Git auto-commit: {} ({})", sha, message)
|
||||||
return sha
|
return sha
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -203,7 +200,7 @@ class GitStore:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
while sha:
|
while sha:
|
||||||
if sha.decode().startswith(short_sha):
|
if sha.hex().startswith(short_sha):
|
||||||
return sha
|
return sha
|
||||||
commit_obj = repo[sha]
|
commit_obj = repo[sha]
|
||||||
if commit_obj.type_name != b"commit":
|
if commit_obj.type_name != b"commit":
|
||||||
@@ -283,7 +280,7 @@ class GitStore:
|
|||||||
msg = commit.message.decode("utf-8", errors="replace").strip()
|
msg = commit.message.decode("utf-8", errors="replace").strip()
|
||||||
if message_prefix is None or msg.startswith(message_prefix):
|
if message_prefix is None or msg.startswith(message_prefix):
|
||||||
entries.append(CommitInfo(
|
entries.append(CommitInfo(
|
||||||
sha=sha.decode()[:8],
|
sha=sha.hex()[:8],
|
||||||
message=msg,
|
message=msg,
|
||||||
timestamp=ts,
|
timestamp=ts,
|
||||||
))
|
))
|
||||||
@@ -487,7 +484,7 @@ class GitStore:
|
|||||||
with Repo(str(self._workspace)) as repo:
|
with Repo(str(self._workspace)) as repo:
|
||||||
commit = cast("Commit", repo[full_sha])
|
commit = cast("Commit", repo[full_sha])
|
||||||
parent = commit.parents[0] if commit.parents else None
|
parent = commit.parents[0] if commit.parents else None
|
||||||
diff = self.diff_commits(parent.decode()[:8], c.sha) if parent else ""
|
diff = self.diff_commits(parent.hex()[:8], c.sha) if parent else ""
|
||||||
return c, diff
|
return c, diff
|
||||||
return None
|
return None
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
from loguru import logger as default_logger
|
from loguru import logger as default_logger
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ class GatewayServices:
|
|||||||
local_trigger_store: LocalTriggerStore | None
|
local_trigger_store: LocalTriggerStore | None
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None
|
cron_pending_job_ids: Callable[[str], set[str]] | None
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
local_trigger_pending_ids: Callable[[str], set[str]] | None
|
||||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None
|
|
||||||
|
|
||||||
|
|
||||||
def build_gateway_services(
|
def build_gateway_services(
|
||||||
@@ -58,7 +56,6 @@ def build_gateway_services(
|
|||||||
local_trigger_store: LocalTriggerStore | None = None,
|
local_trigger_store: LocalTriggerStore | None = None,
|
||||||
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
|
||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
|
|
||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
@@ -120,5 +117,4 @@ def build_gateway_services(
|
|||||||
local_trigger_store=local_trigger_store,
|
local_trigger_store=local_trigger_store,
|
||||||
cron_pending_job_ids=cron_pending_job_ids,
|
cron_pending_job_ids=cron_pending_job_ids,
|
||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
cancel_active_turn=cancel_active_turn,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ from loguru import logger
|
|||||||
from nanobot.config.paths import get_webui_dir
|
from nanobot.config.paths import get_webui_dir
|
||||||
from nanobot.session.history_visibility import is_hidden_history_message
|
from nanobot.session.history_visibility import is_hidden_history_message
|
||||||
from nanobot.session.manager import (
|
from nanobot.session.manager import (
|
||||||
_PROVIDER_STATE_RECORD_TYPE, # pyright: ignore[reportPrivateUsage]
|
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS, # pyright: ignore[reportPrivateUsage]
|
_SESSION_LIST_PREVIEW_MAX_CHARS, # pyright: ignore[reportPrivateUsage]
|
||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS, # pyright: ignore[reportPrivateUsage]
|
_SESSION_LIST_PREVIEW_MAX_RECORDS, # pyright: ignore[reportPrivateUsage]
|
||||||
Session,
|
Session,
|
||||||
SessionManager,
|
SessionManager,
|
||||||
_is_provider_state_record_line, # pyright: ignore[reportPrivateUsage]
|
|
||||||
_message_preview_text, # pyright: ignore[reportPrivateUsage]
|
_message_preview_text, # pyright: ignore[reportPrivateUsage]
|
||||||
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
_metadata_title, # pyright: ignore[reportPrivateUsage]
|
||||||
)
|
)
|
||||||
@@ -300,11 +298,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
for line in f:
|
for line in f:
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
if _is_provider_state_record_line(line):
|
|
||||||
continue
|
|
||||||
item = json.loads(line)
|
item = json.loads(line)
|
||||||
if item.get("_type") == _PROVIDER_STATE_RECORD_TYPE:
|
|
||||||
continue
|
|
||||||
timestamp = _visible_message_timestamp(item)
|
timestamp = _visible_message_timestamp(item)
|
||||||
if timestamp is not None:
|
if timestamp is not None:
|
||||||
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
visible_message_at = _latest_updated_at(visible_message_at, timestamp)
|
||||||
|
|||||||
@@ -159,13 +159,6 @@ def normalize_token_usage_state(raw: Any) -> dict[str, Any]:
|
|||||||
if not isinstance(date, str) or len(date) != 10 or not isinstance(row_value, dict):
|
if not isinstance(date, str) or len(date) != 10 or not isinstance(row_value, dict):
|
||||||
continue
|
continue
|
||||||
row = cast(dict[str, Any], row_value)
|
row = cast(dict[str, Any], row_value)
|
||||||
try:
|
|
||||||
datetime.fromisoformat(date)
|
|
||||||
except ValueError:
|
|
||||||
# A hand-edited or foreign day key that is not a real date would
|
|
||||||
# otherwise reach token_usage_payload's date parsing and fail every
|
|
||||||
# settings request; drop it like any other malformed row.
|
|
||||||
continue
|
|
||||||
normalized = _normalize_usage_row(row)
|
normalized = _normalize_usage_row(row)
|
||||||
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
|
if normalized["total_tokens"] <= 0 and normalized["requests"] <= 0:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2026,7 +2026,6 @@ def replay_transcript_to_ui_messages(
|
|||||||
continue
|
continue
|
||||||
close_activity_for_answer()
|
close_activity_for_answer()
|
||||||
turn_fields = _turn_fields(rec, "answer")
|
turn_fields = _turn_fields(rec, "answer")
|
||||||
source_fields = _source_fields(rec)
|
|
||||||
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
|
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
|
||||||
if buffer_message_id is None:
|
if buffer_message_id is None:
|
||||||
if adopted:
|
if adopted:
|
||||||
@@ -2039,8 +2038,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
**_turn_fields(rec, "answer"),
|
||||||
**source_fields,
|
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2052,8 +2050,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
**m,
|
**m,
|
||||||
"content": combined,
|
"content": combined,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
**_turn_fields(rec, "answer"),
|
||||||
**source_fields,
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
@@ -2065,8 +2062,6 @@ def replay_transcript_to_ui_messages(
|
|||||||
continue
|
continue
|
||||||
merge_next = rec.get("resuming") is True and rec.get("merge_next") is True
|
merge_next = rec.get("resuming") is True and rec.get("merge_next") is True
|
||||||
final_text = rec.get("text")
|
final_text = rec.get("text")
|
||||||
turn_fields = _turn_fields(rec, "answer")
|
|
||||||
source_fields = _source_fields(rec)
|
|
||||||
if isinstance(final_text, str):
|
if isinstance(final_text, str):
|
||||||
if buffer_message_id is None:
|
if buffer_message_id is None:
|
||||||
buffer_message_id = _new_id("buf", idx)
|
buffer_message_id = _new_id("buf", idx)
|
||||||
@@ -2076,8 +2071,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": final_text,
|
"content": final_text,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
**_turn_fields(rec, "answer"),
|
||||||
**source_fields,
|
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2088,21 +2082,11 @@ def replay_transcript_to_ui_messages(
|
|||||||
**m,
|
**m,
|
||||||
"content": final_text,
|
"content": final_text,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**turn_fields,
|
**_turn_fields(rec, "answer"),
|
||||||
**source_fields,
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
if merge_next:
|
if merge_next:
|
||||||
buffer_parts = [final_text]
|
buffer_parts = [final_text]
|
||||||
elif source_fields and buffer_message_id is not None:
|
|
||||||
for i, m in enumerate(messages):
|
|
||||||
if m.get("id") == buffer_message_id:
|
|
||||||
messages[i] = {
|
|
||||||
**m,
|
|
||||||
**turn_fields,
|
|
||||||
**source_fields,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
if not merge_next:
|
if not merge_next:
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
|
|||||||
+4
-2
@@ -31,6 +31,8 @@ dependencies = [
|
|||||||
"websockets>=15.0,<17.0",
|
"websockets>=15.0,<17.0",
|
||||||
"websocket-client>=1.9.0,<2.0.0",
|
"websocket-client>=1.9.0,<2.0.0",
|
||||||
"httpx>=0.28.0,<1.0.0",
|
"httpx>=0.28.0,<1.0.0",
|
||||||
|
# MCP v2 uses the independently versioned httpx2 package for HTTP transports.
|
||||||
|
"httpx2>=2.5.0,<3.0.0",
|
||||||
"ddgs>=9.5.5,<10.0.0",
|
"ddgs>=9.5.5,<10.0.0",
|
||||||
"oauth-cli-kit>=0.1.6,<1.0.0",
|
"oauth-cli-kit>=0.1.6,<1.0.0",
|
||||||
"loguru>=0.7.3,<1.0.0",
|
"loguru>=0.7.3,<1.0.0",
|
||||||
@@ -40,7 +42,7 @@ dependencies = [
|
|||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
"mcp>=1.26.0,<2.0.0",
|
"mcp>=2.0.0,<3.0.0",
|
||||||
"json-repair>=0.57.0,<1.0.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
"chardet>=3.0.2,<6.0.0",
|
"chardet>=3.0.2,<6.0.0",
|
||||||
"openai>=2.8.0",
|
"openai>=2.8.0",
|
||||||
@@ -51,7 +53,7 @@ dependencies = [
|
|||||||
"filelock>=3.25.2",
|
"filelock>=3.25.2",
|
||||||
"watchfiles>=1.1.1,<2.0.0",
|
"watchfiles>=1.1.1,<2.0.0",
|
||||||
"packaging>=24.0",
|
"packaging>=24.0",
|
||||||
"tzdata>=2025.2",
|
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||||
"defusedxml>=0.7.1,<1.0.0",
|
"defusedxml>=0.7.1,<1.0.0",
|
||||||
"pypdf>=5.0.0,<6.0.0",
|
"pypdf>=5.0.0,<6.0.0",
|
||||||
"python-docx>=1.1.0,<2.0.0",
|
"python-docx>=1.1.0,<2.0.0",
|
||||||
|
|||||||
@@ -154,26 +154,6 @@ class TestIsExpired:
|
|||||||
now_over = datetime(2026, 1, 1, 10, 10, 0)
|
now_over = datetime(2026, 1, 1, 10, 10, 0)
|
||||||
assert ac._is_expired(ts, now=now_over) is True
|
assert ac._is_expired(ts, now=now_over) is True
|
||||||
|
|
||||||
def test_unparseable_string_timestamp_returns_false(self):
|
|
||||||
"""A persisted timestamp that no longer parses must not raise.
|
|
||||||
|
|
||||||
list_sessions() forwards the raw persisted updated_at string, and
|
|
||||||
SessionManager._load already tolerates a malformed value through its
|
|
||||||
recovery path. The idle scan must mirror that tolerance instead of crashing.
|
|
||||||
"""
|
|
||||||
ac = _make_autocompact(ttl=15)
|
|
||||||
assert ac._is_expired("not-a-timestamp") is False
|
|
||||||
|
|
||||||
def test_tz_aware_string_timestamp_is_compared_by_instant(self):
|
|
||||||
"""A valid timestamp with an offset remains eligible for expiry."""
|
|
||||||
ac = _make_autocompact(ttl=15)
|
|
||||||
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
||||||
recent = (now - timedelta(minutes=10)).astimezone().isoformat()
|
|
||||||
expired = (now - timedelta(minutes=20)).astimezone().isoformat()
|
|
||||||
|
|
||||||
assert ac._is_expired(recent, now=now) is False
|
|
||||||
assert ac._is_expired(expired, now=now) is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _format_summary
|
# _format_summary
|
||||||
@@ -241,36 +221,6 @@ class TestCheckExpired:
|
|||||||
assert len(scheduled) == 1
|
assert len(scheduled) == 1
|
||||||
assert "cli:old" in ac._archiving
|
assert "cli:old" in ac._archiving
|
||||||
|
|
||||||
def test_unparseable_updated_at_does_not_stop_scan(self):
|
|
||||||
"""A malformed timestamp is skipped without hiding later sessions.
|
|
||||||
|
|
||||||
The idle scan runs from the agent loop's inbound-timeout branch, so a
|
|
||||||
raised exception here would tear down the loop. list_sessions() forwards
|
|
||||||
the raw string, so check_expired must tolerate it like SessionManager
|
|
||||||
does when loading.
|
|
||||||
"""
|
|
||||||
ac = _make_autocompact(ttl=15)
|
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
|
||||||
old_dt = datetime.now() - timedelta(minutes=20)
|
|
||||||
session = _make_session("cli:old", updated_at=old_dt)
|
|
||||||
_add_turns(session, 5)
|
|
||||||
mock_sm.list_sessions.return_value = [
|
|
||||||
{"key": "cli:corrupt", "updated_at": "not-a-timestamp"},
|
|
||||||
{"key": "cli:old", "updated_at": old_dt.isoformat()},
|
|
||||||
]
|
|
||||||
mock_sm.get_or_create.return_value = session
|
|
||||||
ac.sessions = mock_sm
|
|
||||||
scheduled = []
|
|
||||||
|
|
||||||
def scheduler(coro):
|
|
||||||
scheduled.append(coro)
|
|
||||||
coro.close()
|
|
||||||
|
|
||||||
ac.check_expired(scheduler, _runtime)
|
|
||||||
|
|
||||||
assert len(scheduled) == 1
|
|
||||||
assert ac._archiving == {"cli:old"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runtime_is_captured_before_background_starts(self):
|
async def test_runtime_is_captured_before_background_starts(self):
|
||||||
ac = _make_autocompact(ttl=15)
|
ac = _make_autocompact(ttl=15)
|
||||||
@@ -592,58 +542,6 @@ class TestPrepareSession:
|
|||||||
assert summary is not None
|
assert summary is not None
|
||||||
assert "Cold summary." in summary
|
assert "Cold summary." in summary
|
||||||
|
|
||||||
def test_cold_path_tolerates_malformed_last_active(self):
|
|
||||||
"""A malformed persisted last_active must not raise on the turn path.
|
|
||||||
|
|
||||||
prepare_session runs from _compact_session on every turn. Persisted
|
|
||||||
_last_summary can be hand-edited or written by another version, so a bad
|
|
||||||
last_active should degrade gracefully (mirror estimate_session_prompt_tokens
|
|
||||||
and _archive) instead of crashing the turn.
|
|
||||||
"""
|
|
||||||
ac = _make_autocompact(ttl=0)
|
|
||||||
fallback = datetime(2026, 1, 2, 3, 4, 5)
|
|
||||||
session = _make_session(
|
|
||||||
metadata={
|
|
||||||
"_last_summary": {"text": "Cold summary.", "last_active": "not-a-date"},
|
|
||||||
},
|
|
||||||
updated_at=fallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
|
||||||
|
|
||||||
assert result_session is session
|
|
||||||
assert summary is not None
|
|
||||||
assert "Cold summary." in summary
|
|
||||||
assert fallback.isoformat() in summary
|
|
||||||
|
|
||||||
def test_cold_path_tolerates_missing_last_active(self):
|
|
||||||
"""A _last_summary dict without last_active must not raise."""
|
|
||||||
ac = _make_autocompact(ttl=0)
|
|
||||||
fallback = datetime(2026, 1, 2, 3, 4, 5)
|
|
||||||
session = _make_session(
|
|
||||||
metadata={"_last_summary": {"text": "Cold summary."}},
|
|
||||||
updated_at=fallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
|
||||||
|
|
||||||
assert result_session is session
|
|
||||||
assert summary is not None
|
|
||||||
assert "Cold summary." in summary
|
|
||||||
assert fallback.isoformat() in summary
|
|
||||||
|
|
||||||
def test_cold_path_missing_text_returns_none(self):
|
|
||||||
"""A _last_summary without a non-empty string text yields no summary."""
|
|
||||||
ac = _make_autocompact()
|
|
||||||
session = _make_session(metadata={
|
|
||||||
"_last_summary": {"last_active": datetime(2026, 1, 1).isoformat()},
|
|
||||||
})
|
|
||||||
|
|
||||||
result_session, summary = ac.prepare_session(session, "cli:test")
|
|
||||||
|
|
||||||
assert result_session is session
|
|
||||||
assert summary is None
|
|
||||||
|
|
||||||
def test_no_summary_available_returns_none(self):
|
def test_no_summary_available_returns_none(self):
|
||||||
"""When no summary is available, should return (session, None)."""
|
"""When no summary is available, should return (session, None)."""
|
||||||
ac = _make_autocompact()
|
ac = _make_autocompact()
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ from nanobot.agent.memory import (
|
|||||||
Consolidator,
|
Consolidator,
|
||||||
MemoryStore,
|
MemoryStore,
|
||||||
)
|
)
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||||
GenerationSettings,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
@@ -78,16 +74,6 @@ def _tool_round(call_id: str) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _provider_state() -> ProviderConversationState:
|
|
||||||
return ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": []},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestConsolidatorSummarize:
|
class TestConsolidatorSummarize:
|
||||||
async def test_archive_prompt_includes_media_breadcrumb(
|
async def test_archive_prompt_includes_media_breadcrumb(
|
||||||
self, consolidator, mock_provider, store, runtime
|
self, consolidator, mock_provider, store, runtime
|
||||||
@@ -399,7 +385,6 @@ class TestConsolidatorTokenBudget:
|
|||||||
"""Old messages that cannot be replayed should be materialized first."""
|
"""Old messages that cannot be replayed should be materialized first."""
|
||||||
consolidator._SAFETY_BUFFER = 0
|
consolidator._SAFETY_BUFFER = 0
|
||||||
session = Session(key="test:replay-overflow")
|
session = Session(key="test:replay-overflow")
|
||||||
session.provider_state = _provider_state()
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
session.add_message("user", f"u{i}")
|
session.add_message("user", f"u{i}")
|
||||||
session.add_message("assistant", f"a{i}")
|
session.add_message("assistant", f"a{i}")
|
||||||
@@ -419,7 +404,6 @@ class TestConsolidatorTokenBudget:
|
|||||||
assert archived_chunk[-1]["content"] == "a6"
|
assert archived_chunk[-1]["content"] == "a6"
|
||||||
assert session.last_consolidated == 14
|
assert session.last_consolidated == 14
|
||||||
assert session.metadata["_last_summary"]["text"] == "old conversation summary"
|
assert session.metadata["_last_summary"]["text"] == "old conversation summary"
|
||||||
assert session.provider_state is None
|
|
||||||
consolidator.sessions.save.assert_called()
|
consolidator.sessions.save.assert_called()
|
||||||
|
|
||||||
async def test_replay_window_overflow_extends_to_long_recent_user_turn(
|
async def test_replay_window_overflow_extends_to_long_recent_user_turn(
|
||||||
@@ -495,7 +479,6 @@ class TestConsolidatorTokenBudget:
|
|||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.last_consolidated = 0
|
session.last_consolidated = 0
|
||||||
session.key = "test:key"
|
session.key = "test:key"
|
||||||
session.provider_state = _provider_state()
|
|
||||||
session.messages = [
|
session.messages = [
|
||||||
{
|
{
|
||||||
"role": "user" if i in {0, 50, 61} else "assistant",
|
"role": "user" if i in {0, 50, 61} else "assistant",
|
||||||
@@ -517,7 +500,6 @@ class TestConsolidatorTokenBudget:
|
|||||||
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
|
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
|
||||||
assert archived_chunk[0]["content"] == "m0"
|
assert archived_chunk[0]["content"] == "m0"
|
||||||
assert session.last_consolidated > 0
|
assert session.last_consolidated > 0
|
||||||
assert session.provider_state is None
|
|
||||||
|
|
||||||
async def test_raw_archive_fallback_advances_last_consolidated(
|
async def test_raw_archive_fallback_advances_last_consolidated(
|
||||||
self, consolidator, runtime
|
self, consolidator, runtime
|
||||||
@@ -628,7 +610,6 @@ class TestCompactIdleSession:
|
|||||||
)
|
)
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
session = sessions.get_or_create("cli:test")
|
session = sessions.get_or_create("cli:test")
|
||||||
session.provider_state = _provider_state()
|
|
||||||
old_ts = session.updated_at
|
old_ts = session.updated_at
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
session.add_message("user", f"user msg {i}")
|
session.add_message("user", f"user msg {i}")
|
||||||
@@ -646,7 +627,6 @@ class TestCompactIdleSession:
|
|||||||
assert len(reloaded.messages) == 40
|
assert len(reloaded.messages) == 40
|
||||||
assert reloaded.messages[0]["content"] == "user msg 0"
|
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||||
assert reloaded.last_consolidated == 32
|
assert reloaded.last_consolidated == 32
|
||||||
assert reloaded.provider_state is None
|
|
||||||
visible = reloaded.get_history(max_messages=40)
|
visible = reloaded.get_history(max_messages=40)
|
||||||
assert len(visible) == 8
|
assert len(visible) == 8
|
||||||
assert visible[0]["content"] == "user msg 16"
|
assert visible[0]["content"] == "user msg 16"
|
||||||
|
|||||||
@@ -15,20 +15,6 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
|
|||||||
return ContextBuilder(workspace=tmp_path, **kw)
|
return ContextBuilder(workspace=tmp_path, **kw)
|
||||||
|
|
||||||
|
|
||||||
def test_conversation_only_messages_omit_the_system_prompt(tmp_path) -> None:
|
|
||||||
(tmp_path / "AGENTS.md").write_text("SECRET PROJECT INSTRUCTIONS", encoding="utf-8")
|
|
||||||
builder = _builder(tmp_path)
|
|
||||||
|
|
||||||
messages = builder.build_messages(
|
|
||||||
[],
|
|
||||||
"hello",
|
|
||||||
conversation_only=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert messages == [{"role": "user", "content": "hello"}]
|
|
||||||
assert "SECRET PROJECT INSTRUCTIONS" not in str(messages)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _merge_message_content (static)
|
# _merge_message_content (static)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -466,20 +452,6 @@ class TestBuildMessages:
|
|||||||
assert "previous user message" in str(messages[1]["content"])
|
assert "previous user message" in str(messages[1]["content"])
|
||||||
assert "new message" in str(messages[1]["content"])
|
assert "new message" in str(messages[1]["content"])
|
||||||
|
|
||||||
def test_current_message_can_be_built_without_history_merge(self, tmp_path):
|
|
||||||
builder = _builder(tmp_path)
|
|
||||||
current = builder.build_current_message(
|
|
||||||
"new message",
|
|
||||||
runtime_context_blocks=[
|
|
||||||
RuntimeContextBlock(source="test", content="fresh context"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert current["role"] == "user"
|
|
||||||
assert "new message" in current["content"]
|
|
||||||
assert "fresh context" in current["content"]
|
|
||||||
assert current["_meta"]["runtime_context"]["sources"] == ["test"]
|
|
||||||
|
|
||||||
def test_different_role_appended(self, tmp_path):
|
def test_different_role_appended(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
history = [{"role": "assistant", "content": "previous response"}]
|
history = [{"role": "assistant", "content": "previous response"}]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
@@ -20,7 +19,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ProviderConversationState
|
from nanobot.providers.base import LLMResponse
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
@@ -60,16 +59,6 @@ def _mk_loop() -> AgentLoop:
|
|||||||
return loop
|
return loop
|
||||||
|
|
||||||
|
|
||||||
def _provider_state() -> ProviderConversationState:
|
|
||||||
return ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": []},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict:
|
def _runtime_message(content, blocks: list[RuntimeContextBlock]) -> dict:
|
||||||
merged, marker = append_runtime_context(content, blocks)
|
merged, marker = append_runtime_context(content, blocks)
|
||||||
assert marker is not None
|
assert marker is not None
|
||||||
@@ -505,7 +494,6 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
|
|||||||
loop = _mk_loop()
|
loop = _mk_loop()
|
||||||
session = Session(
|
session = Session(
|
||||||
key="test:checkpoint",
|
key="test:checkpoint",
|
||||||
provider_state=_provider_state(),
|
|
||||||
metadata={
|
metadata={
|
||||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
||||||
"assistant_message": {
|
"assistant_message": {
|
||||||
@@ -551,104 +539,6 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
|
|||||||
assert session.messages[1]["tool_call_id"] == "call_done"
|
assert session.messages[1]["tool_call_id"] == "call_done"
|
||||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||||
assert "interrupted before this tool finished" in session.messages[2]["content"].lower()
|
assert "interrupted before this tool finished" in session.messages[2]["content"].lower()
|
||||||
assert session.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_final_response_checkpoint_preserves_matching_provider_state() -> None:
|
|
||||||
loop = _mk_loop()
|
|
||||||
state = _provider_state()
|
|
||||||
session = Session(
|
|
||||||
key="test:final-checkpoint",
|
|
||||||
provider_state=state,
|
|
||||||
metadata={
|
|
||||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
|
||||||
"phase": "final_response",
|
|
||||||
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY: (
|
|
||||||
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
),
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "finished",
|
|
||||||
},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
restored = loop._restore_runtime_checkpoint(session)
|
|
||||||
|
|
||||||
assert restored is True
|
|
||||||
assert session.messages[-1]["content"] == "finished"
|
|
||||||
assert session.provider_state is state
|
|
||||||
assert session.metadata.get(AgentLoop._RUNTIME_CHECKPOINT_KEY) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_legacy_final_checkpoint_discards_unproven_provider_state() -> None:
|
|
||||||
loop = _mk_loop()
|
|
||||||
session = Session(
|
|
||||||
key="test:legacy-final-checkpoint",
|
|
||||||
provider_state=_provider_state(),
|
|
||||||
metadata={
|
|
||||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
|
||||||
"phase": "final_response",
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "finished",
|
|
||||||
},
|
|
||||||
"completed_tool_results": [],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
restored = loop._restore_runtime_checkpoint(session)
|
|
||||||
|
|
||||||
assert restored is True
|
|
||||||
assert session.messages[-1]["content"] == "finished"
|
|
||||||
assert session.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_completed_tools_checkpoint_preserves_matching_provider_state() -> None:
|
|
||||||
loop = _mk_loop()
|
|
||||||
tool_result = {
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_done",
|
|
||||||
"name": "read_file",
|
|
||||||
"content": "compacted result",
|
|
||||||
}
|
|
||||||
state = _provider_state().with_pending_messages([tool_result])
|
|
||||||
session = Session(
|
|
||||||
key="test:completed-tools-checkpoint",
|
|
||||||
provider_state=state,
|
|
||||||
metadata={
|
|
||||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
|
||||||
"phase": "tools_completed",
|
|
||||||
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY: (
|
|
||||||
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
),
|
|
||||||
"assistant_message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": "call_done",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "read_file", "arguments": "{}"},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"completed_tool_results": [tool_result],
|
|
||||||
"pending_tool_calls": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
restored = loop._restore_runtime_checkpoint(session)
|
|
||||||
|
|
||||||
assert restored is True
|
|
||||||
assert session.messages[-1]["content"] == "compacted result"
|
|
||||||
assert session.provider_state is state
|
|
||||||
|
|
||||||
|
|
||||||
def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
||||||
@@ -726,55 +616,6 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
|||||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runtime_checkpoint_keeps_provider_state_out_of_public_metadata(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"encrypted_content": "private-checkpoint-blob",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
loop.provider.can_resume_conversation_state.return_value = True
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(
|
|
||||||
return_value=LLMResponse(content="done", provider_state=state)
|
|
||||||
)
|
|
||||||
session = loop.sessions.get_or_create("cli:private-checkpoint")
|
|
||||||
|
|
||||||
await loop._run_agent_loop(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "question"},
|
|
||||||
],
|
|
||||||
runtime=loop.llm_runtime(),
|
|
||||||
session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.provider_state is not None
|
|
||||||
checkpoint = session.metadata[AgentLoop._RUNTIME_CHECKPOINT_KEY]
|
|
||||||
assert "provider_state" not in checkpoint
|
|
||||||
assert checkpoint[AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION_KEY] == (
|
|
||||||
AgentLoop._PROVIDER_STATE_CHECKPOINT_VERSION
|
|
||||||
)
|
|
||||||
assert "private-checkpoint-blob" not in json.dumps(session.metadata)
|
|
||||||
|
|
||||||
public_payload = loop.sessions.read_session_file(session.key)
|
|
||||||
assert public_payload is not None
|
|
||||||
assert "private-checkpoint-blob" not in json.dumps(public_payload)
|
|
||||||
raw = loop.sessions._get_session_path(session.key).read_text(encoding="utf-8")
|
|
||||||
assert "private-checkpoint-blob" in raw
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
|
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
@@ -793,150 +634,6 @@ async def test_process_message_persists_user_message_before_turn_completes(tmp_p
|
|||||||
assert persisted.updated_at >= persisted.created_at
|
assert persisted.updated_at >= persisted.created_at
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subagent_followup_stages_provider_state_before_turn_runs(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop._run_agent_loop = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign]
|
|
||||||
loop.provider.can_resume_conversation_state.return_value = True
|
|
||||||
session = loop.sessions.get_or_create("cli:subagent-crash")
|
|
||||||
session.provider_state = _provider_state()
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system",
|
|
||||||
sender_id="subagent",
|
|
||||||
chat_id="cli:subagent-crash",
|
|
||||||
content="subagent result",
|
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
|
||||||
)
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
loop.sessions.invalidate("cli:subagent-crash")
|
|
||||||
persisted = loop.sessions.get_or_create("cli:subagent-crash")
|
|
||||||
assert persisted.messages[-1]["content"] == "subagent result"
|
|
||||||
assert persisted.provider_state is not None
|
|
||||||
assert persisted.provider_state.pending_messages[-1]["role"] == "user"
|
|
||||||
assert persisted.provider_state.pending_messages[-1]["content"] == "subagent result"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subagent_followup_state_is_durable_before_prompt_assembly(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop.provider.can_resume_conversation_state.return_value = True
|
|
||||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
|
||||||
side_effect=RuntimeError("prompt boom"),
|
|
||||||
)
|
|
||||||
session = loop.sessions.get_or_create("cli:subagent-prompt-crash")
|
|
||||||
session.provider_state = _provider_state()
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system",
|
|
||||||
sender_id="subagent",
|
|
||||||
chat_id="cli:subagent-prompt-crash",
|
|
||||||
content="subagent result",
|
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
|
||||||
)
|
|
||||||
with pytest.raises(RuntimeError, match="prompt boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
loop.sessions.invalidate("cli:subagent-prompt-crash")
|
|
||||||
persisted = loop.sessions.get_or_create("cli:subagent-prompt-crash")
|
|
||||||
assert persisted.messages[-1]["content"] == "subagent result"
|
|
||||||
assert persisted.provider_state is not None
|
|
||||||
assert persisted.provider_state.pending_messages[-1]["content"] == (
|
|
||||||
"subagent result"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subagent_redelivery_does_not_duplicate_staged_provider_input(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop.provider.can_resume_conversation_state.return_value = True
|
|
||||||
build_initial_messages = loop._build_initial_messages
|
|
||||||
loop._build_initial_messages = MagicMock( # type: ignore[method-assign]
|
|
||||||
side_effect=RuntimeError("prompt boom"),
|
|
||||||
)
|
|
||||||
session = loop.sessions.get_or_create("cli:subagent-redelivery")
|
|
||||||
session.provider_state = _provider_state()
|
|
||||||
loop.sessions.save(session)
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system",
|
|
||||||
sender_id="subagent",
|
|
||||||
chat_id="cli:subagent-redelivery",
|
|
||||||
content="subagent result",
|
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="prompt boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
loop.sessions.invalidate("cli:subagent-redelivery")
|
|
||||||
persisted = loop.sessions.get_or_create("cli:subagent-redelivery")
|
|
||||||
assert persisted.provider_state is not None
|
|
||||||
assert [
|
|
||||||
message.get("content")
|
|
||||||
for message in persisted.provider_state.pending_messages
|
|
||||||
].count("subagent result") == 1
|
|
||||||
loop._build_initial_messages = build_initial_messages # type: ignore[method-assign]
|
|
||||||
loop._run_agent_loop = AsyncMock( # type: ignore[method-assign]
|
|
||||||
side_effect=RuntimeError("provider boom"),
|
|
||||||
)
|
|
||||||
with pytest.raises(RuntimeError, match="provider boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
provider_state = loop._run_agent_loop.await_args.kwargs["provider_state"]
|
|
||||||
assert provider_state is not None
|
|
||||||
pending_results = [
|
|
||||||
message
|
|
||||||
for message in provider_state.pending_messages
|
|
||||||
if message.get("content") == "subagent result"
|
|
||||||
]
|
|
||||||
assert len(pending_results) == 1
|
|
||||||
assert LLMProvider._sanitize_empty_content(pending_results) == [
|
|
||||||
{"role": "user", "content": "subagent result"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_subagent_followup_clears_state_before_compatibility_failure(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_full_loop(tmp_path)
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
|
||||||
loop.provider.can_resume_conversation_state.side_effect = RuntimeError(
|
|
||||||
"compatibility boom"
|
|
||||||
)
|
|
||||||
session = loop.sessions.get_or_create("cli:subagent-compat-crash")
|
|
||||||
session.provider_state = _provider_state()
|
|
||||||
loop.sessions.save(session)
|
|
||||||
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="system",
|
|
||||||
sender_id="subagent",
|
|
||||||
chat_id="cli:subagent-compat-crash",
|
|
||||||
content="subagent result",
|
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
|
||||||
)
|
|
||||||
with pytest.raises(RuntimeError, match="compatibility boom"):
|
|
||||||
await loop._process_message(msg)
|
|
||||||
|
|
||||||
loop.sessions.invalidate("cli:subagent-compat-crash")
|
|
||||||
persisted = loop.sessions.get_or_create("cli:subagent-compat-crash")
|
|
||||||
assert persisted.messages[-1]["content"] == "subagent result"
|
|
||||||
assert persisted.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_persists_unified_session_delivery_route(tmp_path: Path) -> None:
|
async def test_process_message_persists_unified_session_delivery_route(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
@@ -1548,9 +1245,6 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
|
|||||||
session = loop.sessions.get_or_create("feishu:c3")
|
session = loop.sessions.get_or_create("feishu:c3")
|
||||||
session.add_message("user", "old question")
|
session.add_message("user", "old question")
|
||||||
session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True
|
session.metadata[AgentLoop._PENDING_USER_TURN_KEY] = True
|
||||||
session.provider_state = _provider_state().with_pending_messages([
|
|
||||||
{"role": "user", "content": "old question"},
|
|
||||||
])
|
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
|
|
||||||
loop._run_agent_loop = AsyncMock(return_value=(
|
loop._run_agent_loop = AsyncMock(return_value=(
|
||||||
@@ -1584,7 +1278,6 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
|
|||||||
{"role": "assistant", "content": "new answer"},
|
{"role": "assistant", "content": "new answer"},
|
||||||
]
|
]
|
||||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
||||||
assert session.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp import MCPError
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
from mcp.types import ErrorData
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools import mcp as mcp_runtime
|
from nanobot.agent.tools import mcp as mcp_runtime
|
||||||
@@ -26,12 +25,10 @@ from nanobot.config.schema import MCPServerConfig
|
|||||||
|
|
||||||
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
|
def _mcp_notification(method: str, params: dict[str, Any] | None = None) -> SessionMessage:
|
||||||
return SessionMessage(
|
return SessionMessage(
|
||||||
message=mcp_types.JSONRPCMessage(
|
message=mcp_types.JSONRPCNotification(
|
||||||
mcp_types.JSONRPCNotification(
|
jsonrpc="2.0",
|
||||||
jsonrpc="2.0",
|
method=method,
|
||||||
method=method,
|
params=params,
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,7 +425,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
self.call_count += 1
|
self.call_count += 1
|
||||||
assert arguments == {"symbol": "AAPL"}
|
assert arguments == {"symbol": "AAPL"}
|
||||||
if self.index == 1:
|
if self.index == 1:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[mcp_types.TextContent(type="text", text="recovered")]
|
content=[mcp_types.TextContent(type="text", text="recovered")]
|
||||||
)
|
)
|
||||||
@@ -443,7 +440,7 @@ async def test_mcp_tool_reconnects_after_session_terminated(
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="quote",
|
name="quote",
|
||||||
description="quote tool",
|
description="quote tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
|
registry.register(MCPToolWrapper(session, name, tool_def, tool_timeout=5))
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
@@ -484,7 +481,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
|
async def call_tool(self, _name: str, arguments: dict[str, Any]) -> Any:
|
||||||
assert arguments == {}
|
assert arguments == {}
|
||||||
if self.index == 1:
|
if self.index == 1:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[mcp_types.TextContent(type="text", text="recovered")]
|
content=[mcp_types.TextContent(type="text", text="recovered")]
|
||||||
)
|
)
|
||||||
@@ -497,7 +494,7 @@ async def test_mcp_reconnect_handler_uses_sanitized_server_prefix(
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="quote",
|
name="quote",
|
||||||
description="quote tool",
|
description="quote tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
|
registry.register(MCPToolWrapper(_FakeSession(connect_count), name, tool_def))
|
||||||
stack = AsyncExitStack()
|
stack = AsyncExitStack()
|
||||||
@@ -532,7 +529,7 @@ async def test_concurrent_mcp_reconnect_reuses_fresh_session(
|
|||||||
|
|
||||||
class _DeadSession:
|
class _DeadSession:
|
||||||
async def read_resource(self, _uri: str) -> Any:
|
async def read_resource(self, _uri: str) -> Any:
|
||||||
raise McpError(ErrorData(code=-32000, message="Session terminated"))
|
raise MCPError(-32000, "Session terminated")
|
||||||
|
|
||||||
class _LiveSession:
|
class _LiveSession:
|
||||||
async def read_resource(self, uri: str) -> Any:
|
async def read_resource(self, uri: str) -> Any:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import socket
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
@@ -27,10 +27,8 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security import network as security_network
|
from nanobot.security import network as security_network
|
||||||
|
|
||||||
# Leave enough headroom for reconnect handshakes on slower CI hosts; each test
|
_IDLE_TIMEOUT_SECONDS = 0.25
|
||||||
# still waits beyond this deadline explicitly before exercising recovery.
|
_IDLE_EXPIRY_GRACE_SECONDS = 0.25
|
||||||
_IDLE_TIMEOUT_SECONDS = 1.0
|
|
||||||
_IDLE_EXPIRY_GRACE_SECONDS = 0.5
|
|
||||||
_TOOL_TIMEOUT_SECONDS = 10
|
_TOOL_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
@@ -41,31 +39,29 @@ def _free_port() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
|
def _run_mcp_server(port: int, ready_event: multiprocessing.Event) -> None:
|
||||||
"""FastMCP server target for ``multiprocessing.Process``.
|
"""MCPServer target for ``multiprocessing.Process``.
|
||||||
|
|
||||||
The server exposes a single ``greet`` tool and terminates idle sessions
|
The server exposes a single ``greet`` tool and terminates idle sessions
|
||||||
after ``_IDLE_TIMEOUT_SECONDS``.
|
after ``_IDLE_TIMEOUT_SECONDS``.
|
||||||
"""
|
"""
|
||||||
from mcp.server.fastmcp import FastMCP
|
import uvicorn
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
from mcp.server import MCPServer
|
||||||
|
|
||||||
mcp = FastMCP("IdleTimeoutDemo", json_response=True, port=port)
|
mcp = MCPServer("IdleTimeoutDemo")
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def greet(name: str = "World") -> str: # noqa: N802
|
def greet(name: str = "World") -> str: # noqa: N802
|
||||||
"""Greet someone."""
|
"""Greet someone."""
|
||||||
return f"Hello, {name}!"
|
return f"Hello, {name}!"
|
||||||
|
|
||||||
mcp._session_manager = StreamableHTTPSessionManager(
|
app = mcp.streamable_http_app(
|
||||||
app=mcp._mcp_server,
|
json_response=True,
|
||||||
json_response=mcp.settings.json_response,
|
host="127.0.0.1",
|
||||||
stateless=mcp.settings.stateless_http,
|
|
||||||
security_settings=mcp.settings.transport_security,
|
|
||||||
session_idle_timeout=_IDLE_TIMEOUT_SECONDS,
|
|
||||||
)
|
)
|
||||||
|
mcp.session_manager.session_idle_timeout = _IDLE_TIMEOUT_SECONDS
|
||||||
|
|
||||||
ready_event.set()
|
ready_event.set()
|
||||||
mcp.run(transport="streamable-http")
|
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
|
async def _wait_for_server(url: str, timeout: float = 10.0) -> bool:
|
||||||
@@ -130,10 +126,14 @@ def _make_loop(tmp_path, *, mcp_servers: dict) -> AgentLoop:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
||||||
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
|
"""The repro server runs on 127.0.0.1; allow nanobot to talk to it."""
|
||||||
class TestPinnedDNSAsyncTransport(security_network.PinnedDNSAsyncTransport):
|
class TestPinnedDNSAsyncTransport(security_network.Httpx2PinnedDNSAsyncTransport):
|
||||||
_resolver_lock = asyncio.Lock()
|
_resolver_lock = asyncio.Lock()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_module, "PinnedDNSAsyncTransport", TestPinnedDNSAsyncTransport)
|
monkeypatch.setattr(
|
||||||
|
mcp_module,
|
||||||
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
|
TestPinnedDNSAsyncTransport,
|
||||||
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_module,
|
mcp_module,
|
||||||
"validate_url_target",
|
"validate_url_target",
|
||||||
@@ -156,7 +156,7 @@ def allow_loopback_mcp_urls(monkeypatch: pytest.MonkeyPatch):
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_module,
|
mcp_module,
|
||||||
"httpx_env_proxy_mounts",
|
"httpx2_env_proxy_mounts",
|
||||||
lambda: {},
|
lambda: {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ from types import SimpleNamespace
|
|||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from mcp import MCPError
|
||||||
from mcp import types as mcp_types
|
from mcp import types as mcp_types
|
||||||
from mcp.shared.exceptions import McpError
|
|
||||||
from mcp.types import ErrorData
|
|
||||||
|
|
||||||
from nanobot.agent.tools.mcp import (
|
from nanobot.agent.tools.mcp import (
|
||||||
MCPPromptWrapper,
|
MCPPromptWrapper,
|
||||||
@@ -37,12 +36,16 @@ class _FakeEndOfStreamError(Exception):
|
|||||||
_FakeEndOfStreamError.__name__ = "EndOfStream"
|
_FakeEndOfStreamError.__name__ = "EndOfStream"
|
||||||
|
|
||||||
|
|
||||||
def _session_terminated_error() -> McpError:
|
def _session_terminated_error() -> MCPError:
|
||||||
return McpError(ErrorData(code=-32000, message="Session terminated"))
|
return MCPError(-32000, "Session terminated")
|
||||||
|
|
||||||
|
|
||||||
def _connection_closed_error() -> McpError:
|
def _connection_closed_error() -> MCPError:
|
||||||
return McpError(ErrorData(code=-32000, message="Connection closed"))
|
return MCPError(-32000, "Connection closed")
|
||||||
|
|
||||||
|
|
||||||
|
def _session_not_found_error() -> MCPError:
|
||||||
|
return MCPError(-32600, "Session not found")
|
||||||
|
|
||||||
|
|
||||||
def test_is_transient_recognizes_closed_resource():
|
def test_is_transient_recognizes_closed_resource():
|
||||||
@@ -85,6 +88,10 @@ def test_is_session_terminated_recognizes_connection_closed_mcp_error():
|
|||||||
assert _is_session_terminated(_connection_closed_error())
|
assert _is_session_terminated(_connection_closed_error())
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_session_terminated_recognizes_v2_session_not_found_error():
|
||||||
|
assert _is_session_terminated(_session_not_found_error())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# MCPToolWrapper retry behaviour
|
# MCPToolWrapper retry behaviour
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -94,7 +101,7 @@ def _make_tool_def(name="test_tool"):
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
name=name,
|
name=name,
|
||||||
description="A test tool",
|
description="A test tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -415,10 +422,10 @@ async def test_prompt_fails_after_retry_exhausted():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_no_retry_on_mcp_error():
|
async def test_prompt_no_retry_on_mcp_error():
|
||||||
"""McpError (application-level) should NOT trigger retry."""
|
"""MCPError (application-level) should NOT trigger retry."""
|
||||||
session = AsyncMock()
|
session = AsyncMock()
|
||||||
session.get_prompt = AsyncMock(
|
session.get_prompt = AsyncMock(
|
||||||
side_effect=McpError(ErrorData(code=-1, message="not found"))
|
side_effect=MCPError(-1, "not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
wrapper = MCPPromptWrapper(session, "test_server", _make_prompt_def())
|
||||||
@@ -443,7 +450,7 @@ async def test_prompt_no_retry_on_non_transient():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_reconnects_on_session_terminated():
|
async def test_prompt_reconnects_on_session_terminated():
|
||||||
"""Prompt should reconnect once before falling back to McpError handling."""
|
"""Prompt should reconnect once before falling back to MCPError handling."""
|
||||||
old_session = AsyncMock()
|
old_session = AsyncMock()
|
||||||
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
|
old_session.get_prompt = AsyncMock(side_effect=_session_terminated_error())
|
||||||
new_session = AsyncMock()
|
new_session = AsyncMock()
|
||||||
|
|||||||
@@ -579,21 +579,3 @@ def test_history_skips_non_dict_jsonl_lines(tmp_path: Path) -> None:
|
|||||||
}]
|
}]
|
||||||
next_cursor = memory.append_history("next", session_key="cli:t")
|
next_cursor = memory.append_history("next", session_key="cli:t")
|
||||||
assert next_cursor == 2
|
assert next_cursor == 2
|
||||||
|
|
||||||
def test_raw_archive_handles_none_timestamp_and_missing_role(tmp_path: Path) -> None:
|
|
||||||
"""raw_archive and _format_messages must safely format messages with None timestamp or missing role.
|
|
||||||
|
|
||||||
Prevents TypeError on NoneType[:16] slicing and KeyError on missing 'role'
|
|
||||||
when raw-dumping unconsolidated history entries without timestamps or role fields.
|
|
||||||
"""
|
|
||||||
memory = MemoryStore(tmp_path)
|
|
||||||
messages = [
|
|
||||||
{"content": "message with none timestamp", "timestamp": None, "role": "user"},
|
|
||||||
{"content": "message with int timestamp", "timestamp": 1720000000, "role": "assistant"},
|
|
||||||
{"content": "message with missing role", "timestamp": "2026-07-28T12:00:00"},
|
|
||||||
]
|
|
||||||
memory.raw_archive(messages, session_key="cli:test")
|
|
||||||
raw_history = memory.history_file.read_text(encoding="utf-8")
|
|
||||||
assert "[?] USER: message with none timestamp" in raw_history
|
|
||||||
assert "[1720000000] ASSISTANT: message with int timestamp" in raw_history
|
|
||||||
assert "[2026-07-28T12:00] UNKNOWN: message with missing role" in raw_history
|
|
||||||
|
|||||||
@@ -11,13 +11,7 @@ import pytest
|
|||||||
|
|
||||||
from agent.runner_helpers import make_run_spec
|
from agent.runner_helpers import make_run_spec
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
ToolCallRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
@@ -79,311 +73,6 @@ async def test_runner_preserves_reasoning_fields_and_tool_results():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_replays_provider_state_without_chat_projection_duplicates():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.supports_native_compaction.return_value = False
|
|
||||||
captured_second_kwargs: dict = {}
|
|
||||||
checkpoints: list[dict] = []
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def checkpoint(payload: dict) -> None:
|
|
||||||
checkpoints.append(payload)
|
|
||||||
|
|
||||||
first_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
)
|
|
||||||
second_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "message", "role": "assistant"}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_with_retry(**kwargs):
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
provider_context = kwargs["provider_context"]
|
|
||||||
assert isinstance(provider_context, ProviderCallContext)
|
|
||||||
assert provider_context.conversation_state is None
|
|
||||||
return LLMResponse(
|
|
||||||
content=None,
|
|
||||||
tool_calls=[
|
|
||||||
ToolCallRequest(
|
|
||||||
id="call_1|fc_1",
|
|
||||||
name="list_dir",
|
|
||||||
arguments={"path": "."},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
provider_state=first_state,
|
|
||||||
)
|
|
||||||
captured_second_kwargs.update(kwargs)
|
|
||||||
return LLMResponse(content="done", provider_state=second_state)
|
|
||||||
|
|
||||||
provider.chat_with_retry = chat_with_retry
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
tools.execute = AsyncMock(return_value="tool result")
|
|
||||||
|
|
||||||
result = await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "do task"},
|
|
||||||
],
|
|
||||||
tools=tools,
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
checkpoint_callback=checkpoint,
|
|
||||||
))
|
|
||||||
|
|
||||||
provider_context = captured_second_kwargs["provider_context"]
|
|
||||||
assert isinstance(provider_context, ProviderCallContext)
|
|
||||||
assert provider_context.conversation_state is not None
|
|
||||||
assert provider_context.conversation_state.payload == first_state.payload
|
|
||||||
assert provider_context.conversation_state.pending_messages == [{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1|fc_1",
|
|
||||||
"name": "list_dir",
|
|
||||||
"content": "tool result",
|
|
||||||
}]
|
|
||||||
assert not any(
|
|
||||||
message.get("role") == "assistant"
|
|
||||||
for message in provider_context.conversation_state.pending_messages
|
|
||||||
)
|
|
||||||
assert result.provider_state is not None
|
|
||||||
assert result.provider_state.payload == second_state.payload
|
|
||||||
assert result.provider_state.pending_messages == []
|
|
||||||
assert checkpoints[0]["phase"] == "awaiting_tools"
|
|
||||||
assert "provider_state" not in checkpoints[0]
|
|
||||||
assert checkpoints[1]["phase"] == "tools_completed"
|
|
||||||
assert checkpoints[1]["provider_state"].pending_messages == [{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1|fc_1",
|
|
||||||
"name": "list_dir",
|
|
||||||
"content": "tool result",
|
|
||||||
}]
|
|
||||||
assert checkpoints[2]["phase"] == "final_response"
|
|
||||||
assert checkpoints[2]["provider_state"].payload == second_state.payload
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_governs_tool_result_before_adding_it_to_provider_state():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.supports_native_compaction.return_value = False
|
|
||||||
calls = 0
|
|
||||||
captured_context: ProviderCallContext | None = None
|
|
||||||
checkpoints: list[dict] = []
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def chat_with_retry(**kwargs):
|
|
||||||
nonlocal calls, captured_context
|
|
||||||
calls += 1
|
|
||||||
if calls == 1:
|
|
||||||
return LLMResponse(
|
|
||||||
content=None,
|
|
||||||
tool_calls=[
|
|
||||||
ToolCallRequest(
|
|
||||||
id="call_1",
|
|
||||||
name="read_file",
|
|
||||||
arguments={"path": "large.txt"},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
provider_state=state,
|
|
||||||
)
|
|
||||||
captured_context = kwargs["provider_context"]
|
|
||||||
return LLMResponse(content="done")
|
|
||||||
|
|
||||||
provider.chat_with_retry = chat_with_retry
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
tools.execute = AsyncMock(return_value="x" * 5_000)
|
|
||||||
|
|
||||||
async def checkpoint(payload: dict) -> None:
|
|
||||||
checkpoints.append(payload)
|
|
||||||
|
|
||||||
await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "read the file"},
|
|
||||||
],
|
|
||||||
tools=tools,
|
|
||||||
model="gpt-5.6",
|
|
||||||
context_window_tokens=3_000,
|
|
||||||
context_block_limit=200,
|
|
||||||
max_tokens=1_000,
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=10_000,
|
|
||||||
checkpoint_callback=checkpoint,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert captured_context is not None
|
|
||||||
assert captured_context.conversation_state is not None
|
|
||||||
pending = captured_context.conversation_state.pending_messages
|
|
||||||
assert len(pending) == 1
|
|
||||||
assert pending[0]["role"] == "tool"
|
|
||||||
assert "compacted to fit context" in pending[0]["content"]
|
|
||||||
assert pending[0]["content"] != "x" * 5_000
|
|
||||||
completed_checkpoint = next(
|
|
||||||
checkpoint
|
|
||||||
for checkpoint in checkpoints
|
|
||||||
if checkpoint["phase"] == "tools_completed"
|
|
||||||
)
|
|
||||||
checkpoint_pending = completed_checkpoint["provider_state"].pending_messages
|
|
||||||
assert "compacted to fit context" in checkpoint_pending[0]["content"]
|
|
||||||
assert checkpoint_pending[0]["content"] != "x" * 5_000
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_injected_final_response_checkpoint_includes_provider_state():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.supports_native_compaction.return_value = False
|
|
||||||
first_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "message", "content": "first answer"}]},
|
|
||||||
)
|
|
||||||
second_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "message", "content": "second answer"}]},
|
|
||||||
)
|
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
||||||
LLMResponse(content="first answer", provider_state=first_state),
|
|
||||||
LLMResponse(content="second answer", provider_state=second_state),
|
|
||||||
])
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
checkpoints: list[dict] = []
|
|
||||||
injections = [[{"role": "user", "content": "follow up"}], []]
|
|
||||||
|
|
||||||
async def checkpoint(payload: dict) -> None:
|
|
||||||
checkpoints.append(payload)
|
|
||||||
|
|
||||||
async def inject() -> list[dict]:
|
|
||||||
return injections.pop(0)
|
|
||||||
|
|
||||||
await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "start"}],
|
|
||||||
tools=tools,
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
checkpoint_callback=checkpoint,
|
|
||||||
injection_callback=inject,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert checkpoints[0]["phase"] == "final_response"
|
|
||||||
assert checkpoints[0]["provider_state"].payload == first_state.payload
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_preserves_last_completed_provider_state_on_model_error():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="temporary upstream failure",
|
|
||||||
finish_reason="error",
|
|
||||||
error_kind="timeout",
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
)
|
|
||||||
unsaved_input = {"role": "user", "content": "ephemeral follow-up"}
|
|
||||||
|
|
||||||
result = await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
unsaved_input,
|
|
||||||
],
|
|
||||||
tools=tools,
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_iterations=1,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
provider_state=state.with_pending_messages([unsaved_input]),
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "error"
|
|
||||||
assert result.provider_state is not None
|
|
||||||
assert result.provider_state.payload == state.payload
|
|
||||||
assert result.provider_state.pending_messages[0] == unsaved_input
|
|
||||||
assert result.provider_state.pending_messages[1]["role"] == "assistant"
|
|
||||||
assert "model error" in result.provider_state.pending_messages[1]["content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_discards_provider_state_on_non_retryable_model_error():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="context length exceeded",
|
|
||||||
finish_reason="error",
|
|
||||||
error_status_code=400,
|
|
||||||
error_should_retry=False,
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "continue"}],
|
|
||||||
tools=tools,
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_iterations=1,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
provider_state=state,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.stop_reason == "error"
|
|
||||||
assert result.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_returns_max_iterations_fallback():
|
async def test_runner_returns_max_iterations_fallback():
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
@@ -733,66 +422,6 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
|
|||||||
assert result.usage["completion_tokens"] == 9
|
assert result.usage["completion_tokens"] == 9
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("finish_reason", ["refusal", "content_filter"])
|
|
||||||
async def test_runner_does_not_retry_blank_policy_terminal(
|
|
||||||
finish_reason: str,
|
|
||||||
) -> None:
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content=None,
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
result = await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert provider.chat_with_retry.await_count == 1
|
|
||||||
assert result.final_content == EMPTY_FINAL_RESPONSE_MESSAGE
|
|
||||||
assert result.stop_reason == "empty_final_response"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("finish_reason", ["refusal", "content_filter"])
|
|
||||||
async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
|
|
||||||
finish_reason: str,
|
|
||||||
) -> None:
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
|
||||||
content="Request blocked by provider policy.",
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
))
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
result = await AgentRunner().run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
goal_active_predicate=lambda: True,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert provider.chat_with_retry.await_count == 1
|
|
||||||
assert result.final_content == "Request blocked by provider policy."
|
|
||||||
assert result.stop_reason == "completed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
||||||
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
|
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
|
||||||
@@ -821,56 +450,6 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
|||||||
assert result.stop_reason == "empty_final_response"
|
assert result.stop_reason == "empty_final_response"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_empty_finalization_retry_discards_candidate_provider_state():
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
|
|
||||||
candidate = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={
|
|
||||||
"items": [{
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": "call_1",
|
|
||||||
"name": "exec",
|
|
||||||
"arguments": "{}",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = True
|
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
||||||
LLMResponse(content=None, tool_calls=[], usage={}),
|
|
||||||
LLMResponse(content=None, tool_calls=[], usage={}),
|
|
||||||
LLMResponse(
|
|
||||||
content="finalized without tools",
|
|
||||||
tool_calls=[ToolCallRequest(id="call_1", name="exec", arguments={})],
|
|
||||||
finish_reason="stop",
|
|
||||||
provider_state=candidate,
|
|
||||||
usage={},
|
|
||||||
),
|
|
||||||
])
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
tools.execute = AsyncMock(return_value="must not run")
|
|
||||||
|
|
||||||
runner = AgentRunner()
|
|
||||||
result = await runner.run(make_run_spec(
|
|
||||||
provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "do task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=3,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
))
|
|
||||||
|
|
||||||
tools.execute.assert_not_awaited()
|
|
||||||
assert result.final_content == "finalized without tools"
|
|
||||||
assert result.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_length_recovery_returns_all_segments():
|
async def test_runner_length_recovery_returns_all_segments():
|
||||||
"""Recovered output segments are returned together instead of only the tail."""
|
"""Recovered output segments are returned together instead of only the tail."""
|
||||||
|
|||||||
@@ -310,50 +310,3 @@ async def test_runner_tool_error_preserves_tool_results_in_messages():
|
|||||||
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
|
i for i, m in enumerate(result.messages) if m.get("role") == "tool"
|
||||||
]
|
]
|
||||||
assert all(ti > asst_tc_idx for ti in tool_indices)
|
assert all(ti > asst_tc_idx for ti in tool_indices)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_length_finish_with_blank_content_routes_to_length_recovery():
|
|
||||||
"""Regression test for #5133.
|
|
||||||
|
|
||||||
A response with finish_reason='length' and blank content (e.g. the model
|
|
||||||
spent its whole output budget on a tool call whose closing tag was
|
|
||||||
truncated) must take the length-recovery path, not the empty-response
|
|
||||||
retry path. Retrying the same prompt cannot recover from output-budget
|
|
||||||
exhaustion.
|
|
||||||
"""
|
|
||||||
from nanobot.agent.runner import AgentRunner
|
|
||||||
from nanobot.utils.runtime import LENGTH_RECOVERY_PROMPT
|
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
# First call: truncated (length) with blank content and a dropped tool call.
|
|
||||||
# Second call: normal completion so the loop can terminate.
|
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=[
|
|
||||||
LLMResponse(
|
|
||||||
content="",
|
|
||||||
finish_reason="length",
|
|
||||||
tool_calls=[ToolCallRequest(id="call_1", name="exec", arguments={})],
|
|
||||||
usage={},
|
|
||||||
),
|
|
||||||
LLMResponse(content="done", finish_reason="stop", tool_calls=[], usage={}),
|
|
||||||
])
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
|
|
||||||
runner = AgentRunner()
|
|
||||||
result = await runner.run(make_run_spec(provider,
|
|
||||||
initial_messages=[{"role": "user", "content": "do a long task"}],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=5,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
))
|
|
||||||
|
|
||||||
# The runner must have injected a length-recovery prompt and continued,
|
|
||||||
# rather than exhausting empty-response retries into a generic apology.
|
|
||||||
user_msgs = [m.get("content") or "" for m in result.messages if m.get("role") == "user"]
|
|
||||||
assert any(LENGTH_RECOVERY_PROMPT in c for c in user_msgs), (
|
|
||||||
"expected a length-recovery message to be appended for a "
|
|
||||||
"finish_reason='length' response with blank content"
|
|
||||||
)
|
|
||||||
assert result.final_content == "done"
|
|
||||||
|
|||||||
@@ -9,15 +9,8 @@ import pytest
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
from nanobot.providers.conversation_state import ProviderConversationStateController
|
|
||||||
from nanobot.providers.fallback_provider import FallbackProvider
|
from nanobot.providers.fallback_provider import FallbackProvider
|
||||||
from nanobot.providers.openai_responses import resolve_compact_threshold
|
|
||||||
|
|
||||||
|
|
||||||
def _make_response(
|
def _make_response(
|
||||||
@@ -73,9 +66,6 @@ class _FakeProvider(LLMProvider):
|
|||||||
self._response = response or _make_response()
|
self._response = response or _make_response()
|
||||||
self.chat_calls: list[dict[str, Any]] = []
|
self.chat_calls: list[dict[str, Any]] = []
|
||||||
self.chat_stream_calls: list[dict[str, Any]] = []
|
self.chat_stream_calls: list[dict[str, Any]] = []
|
||||||
self.context_calls: list[ProviderCallContext | None] = []
|
|
||||||
self.resumable = False
|
|
||||||
self.compact = False
|
|
||||||
|
|
||||||
def get_default_model(self) -> str:
|
def get_default_model(self) -> str:
|
||||||
return f"{self.name}/model"
|
return f"{self.name}/model"
|
||||||
@@ -91,26 +81,6 @@ class _FakeProvider(LLMProvider):
|
|||||||
await on_delta(self._response.content)
|
await on_delta(self._response.content)
|
||||||
return self._response
|
return self._response
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
provider_context: ProviderCallContext | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> LLMResponse:
|
|
||||||
self.context_calls.append(provider_context)
|
|
||||||
return await self.chat(**kwargs)
|
|
||||||
|
|
||||||
def can_resume_conversation_state(
|
|
||||||
self,
|
|
||||||
state: ProviderConversationState,
|
|
||||||
model: str | None = None,
|
|
||||||
) -> bool:
|
|
||||||
_ = state, model
|
|
||||||
return self.resumable
|
|
||||||
|
|
||||||
def supports_native_compaction(self, model: str | None = None) -> bool:
|
|
||||||
_ = model
|
|
||||||
return self.compact
|
|
||||||
|
|
||||||
|
|
||||||
# -- config-level tests --
|
# -- config-level tests --
|
||||||
|
|
||||||
@@ -241,8 +211,6 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
|||||||
snapshot = build_provider_snapshot(config)
|
snapshot = build_provider_snapshot(config)
|
||||||
|
|
||||||
assert snapshot.context_window_tokens == 64000
|
assert snapshot.context_window_tokens == 64000
|
||||||
assert isinstance(snapshot.provider, FallbackProvider)
|
|
||||||
assert snapshot.provider._primary_context_window_tokens == 128000
|
|
||||||
|
|
||||||
|
|
||||||
def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
def test_inline_fallback_reasoning_effort_does_not_inherit_primary() -> None:
|
||||||
@@ -317,257 +285,6 @@ class TestFallbackOnPrimaryError:
|
|||||||
assert primary.chat_calls[0]["model"] == "primary-model"
|
assert primary.chat_calls[0]["model"] == "primary-model"
|
||||||
assert fallback.chat_calls[0]["model"] == "fallback-a"
|
assert fallback.chat_calls[0]["model"] == "fallback-a"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_primary_compaction_uses_primary_context_window(self) -> None:
|
|
||||||
primary = _FakeProvider("primary", _make_response("primary ok"))
|
|
||||||
primary.compact = True
|
|
||||||
fb = FallbackProvider(
|
|
||||||
primary=primary,
|
|
||||||
fallback_presets=[
|
|
||||||
_fallback("small-chat", context_window_tokens=50_000),
|
|
||||||
],
|
|
||||||
provider_factory=MagicMock(),
|
|
||||||
primary_context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
await fb.chat_with_context(
|
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_tokens=10_000,
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=50_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
primary_context = primary.context_calls[0]
|
|
||||||
assert primary_context is not None
|
|
||||||
assert primary_context.context_window_tokens == 200_000
|
|
||||||
assert resolve_compact_threshold(
|
|
||||||
primary_context.context_window_tokens,
|
|
||||||
10_000,
|
|
||||||
) == 180_000
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_native_fallback_compaction_uses_its_own_context_window(self) -> None:
|
|
||||||
primary = _FakeProvider("primary", _error_response())
|
|
||||||
primary.compact = True
|
|
||||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
|
||||||
fallback.compact = True
|
|
||||||
fb = FallbackProvider(
|
|
||||||
primary=primary,
|
|
||||||
fallback_presets=[
|
|
||||||
_fallback("fallback-a", context_window_tokens=120_000),
|
|
||||||
],
|
|
||||||
provider_factory=MagicMock(return_value=fallback),
|
|
||||||
primary_context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await fb.chat_with_context(
|
|
||||||
messages=[{"role": "user", "content": "hi"}],
|
|
||||||
model="gpt-5.6",
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=50_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "fallback ok"
|
|
||||||
assert primary.context_calls == [
|
|
||||||
ProviderCallContext(context_window_tokens=200_000)
|
|
||||||
]
|
|
||||||
assert fallback.context_calls == [
|
|
||||||
ProviderCallContext(context_window_tokens=120_000)
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_native_fallback_gets_context_when_primary_does_not_use_it(self) -> None:
|
|
||||||
primary = _FakeProvider("primary", _error_response())
|
|
||||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
|
||||||
fallback.compact = True
|
|
||||||
fb = FallbackProvider(
|
|
||||||
primary=primary,
|
|
||||||
fallback_presets=[
|
|
||||||
_fallback("fallback-a", context_window_tokens=120_000),
|
|
||||||
],
|
|
||||||
provider_factory=MagicMock(return_value=fallback),
|
|
||||||
primary_context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
messages = [{"role": "user", "content": "hi"}]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=fb,
|
|
||||||
model="primary-model",
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
assert fb.supports_native_compaction("primary-model") is False
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=50_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert provider_context == ProviderCallContext(
|
|
||||||
context_window_tokens=50_000
|
|
||||||
)
|
|
||||||
result = await fb.chat_with_context(
|
|
||||||
messages=messages,
|
|
||||||
model="primary-model",
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "fallback ok"
|
|
||||||
assert primary.context_calls == [ProviderCallContext()]
|
|
||||||
assert fallback.context_calls == [
|
|
||||||
ProviderCallContext(context_window_tokens=120_000)
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_responses_chat_fallback_responses_rebuilds_state(self) -> None:
|
|
||||||
primary = _FakeProvider("primary", _error_response())
|
|
||||||
primary.resumable = True
|
|
||||||
primary.compact = True
|
|
||||||
fallback = _FakeProvider("fallback", _make_response("fallback ok"))
|
|
||||||
messages = [{"role": "user", "content": "hi"}]
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
pending_messages=list(messages),
|
|
||||||
)
|
|
||||||
fb = FallbackProvider(
|
|
||||||
primary=primary,
|
|
||||||
fallback_presets=[_fallback("fallback-a")],
|
|
||||||
provider_factory=MagicMock(return_value=fallback),
|
|
||||||
)
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=fb,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
assert provider_context is not None
|
|
||||||
|
|
||||||
result = await fb.chat_with_context(
|
|
||||||
messages=messages,
|
|
||||||
model="gpt-5.6",
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "fallback ok"
|
|
||||||
assert primary.context_calls == [provider_context]
|
|
||||||
assert fallback.context_calls == [ProviderCallContext()]
|
|
||||||
assert fallback.chat_calls[0]["messages"] == messages
|
|
||||||
|
|
||||||
controller.observe_response(result, messages)
|
|
||||||
messages.append({"role": "assistant", "content": result.content})
|
|
||||||
assert controller.finish(messages) is None
|
|
||||||
|
|
||||||
recovered_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "recovered"}]},
|
|
||||||
)
|
|
||||||
primary._response = LLMResponse(
|
|
||||||
content="primary recovered",
|
|
||||||
provider_state=recovered_state,
|
|
||||||
)
|
|
||||||
next_turn = ProviderConversationStateController(
|
|
||||||
provider=fb,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
next_context = next_turn.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
assert next_context == ProviderCallContext(context_window_tokens=200_000)
|
|
||||||
|
|
||||||
recovered = await fb.chat_with_context(
|
|
||||||
messages=messages,
|
|
||||||
model="gpt-5.6",
|
|
||||||
provider_context=next_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert recovered.provider_state is recovered_state
|
|
||||||
assert primary.context_calls[-1] == next_context
|
|
||||||
assert primary.chat_calls[-1]["messages"] == messages
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("primary_error_kind", "primary_status", "primary_should_retry"),
|
|
||||||
[
|
|
||||||
("server_error", 503, True),
|
|
||||||
("authentication", 401, False),
|
|
||||||
],
|
|
||||||
ids=["transient", "authentication"],
|
|
||||||
)
|
|
||||||
async def test_final_fallback_error_uses_primary_state_disposition(
|
|
||||||
self,
|
|
||||||
primary_error_kind: str,
|
|
||||||
primary_status: int,
|
|
||||||
primary_should_retry: bool,
|
|
||||||
) -> None:
|
|
||||||
primary = _FakeProvider(
|
|
||||||
"primary",
|
|
||||||
_make_response(
|
|
||||||
"primary unavailable",
|
|
||||||
finish_reason="error",
|
|
||||||
error_kind=primary_error_kind,
|
|
||||||
error_status_code=primary_status,
|
|
||||||
error_should_retry=primary_should_retry,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
primary.resumable = True
|
|
||||||
fallback = _FakeProvider(
|
|
||||||
"fallback",
|
|
||||||
_make_response(
|
|
||||||
"fallback invalid request",
|
|
||||||
finish_reason="error",
|
|
||||||
error_kind="invalid_request",
|
|
||||||
error_status_code=400,
|
|
||||||
error_should_retry=False,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
messages = [{"role": "user", "content": "continue"}]
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": "opaque"}]},
|
|
||||||
pending_messages=list(messages),
|
|
||||||
)
|
|
||||||
provider = FallbackProvider(
|
|
||||||
primary=primary,
|
|
||||||
fallback_presets=[_fallback("fallback-a")],
|
|
||||||
provider_factory=MagicMock(return_value=fallback),
|
|
||||||
)
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
assert provider_context is not None
|
|
||||||
|
|
||||||
response = await provider.chat_with_context(
|
|
||||||
messages=messages,
|
|
||||||
model="gpt-5.6",
|
|
||||||
provider_context=provider_context,
|
|
||||||
)
|
|
||||||
controller.observe_response(response, messages)
|
|
||||||
|
|
||||||
assert response.content == "fallback invalid request"
|
|
||||||
assert response.preserve_provider_state_on_error is True
|
|
||||||
restored = controller.finish(messages)
|
|
||||||
assert restored is not None
|
|
||||||
assert restored.payload == state.payload
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||||
primary = _FakeProvider("primary", _error_response())
|
primary = _FakeProvider("primary", _error_response())
|
||||||
|
|||||||
@@ -15,11 +15,7 @@ from nanobot.agent.context_governance import (
|
|||||||
)
|
)
|
||||||
from nanobot.agent.runner import AgentRunSpec
|
from nanobot.agent.runner import AgentRunSpec
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
LLMResponse,
|
|
||||||
ProviderConversationState,
|
|
||||||
ToolCallRequest,
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
@@ -890,13 +886,6 @@ def test_drop_malformed_tool_calls_trims_response():
|
|||||||
"""LLM response tool_calls with a missing/empty name are dropped in place."""
|
"""LLM response tool_calls with a missing/empty name are dropped in place."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
candidate_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "function_call", "name": None}]},
|
|
||||||
)
|
|
||||||
response = LLMResponse(
|
response = LLMResponse(
|
||||||
content=None,
|
content=None,
|
||||||
tool_calls=[
|
tool_calls=[
|
||||||
@@ -906,11 +895,9 @@ def test_drop_malformed_tool_calls_trims_response():
|
|||||||
ToolCallRequest(id="4", name="read_file", arguments={}),
|
ToolCallRequest(id="4", name="read_file", arguments={}),
|
||||||
],
|
],
|
||||||
finish_reason="tool_calls",
|
finish_reason="tool_calls",
|
||||||
provider_state=candidate_state,
|
|
||||||
)
|
)
|
||||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
||||||
assert [tc.name for tc in response.tool_calls] == ["read_file"]
|
assert [tc.name for tc in response.tool_calls] == ["read_file"]
|
||||||
assert response.provider_state is None
|
|
||||||
assert response.finish_reason == "tool_calls"
|
assert response.finish_reason == "tool_calls"
|
||||||
assert response.should_execute_tools is True
|
assert response.should_execute_tools is True
|
||||||
assert dropped == 3
|
assert dropped == 3
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import json
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
|
|
||||||
@@ -102,137 +101,6 @@ class TestAtomicSave:
|
|||||||
for i in range(5):
|
for i in range(5):
|
||||||
assert loaded.messages[i]["content"] == f"msg{i}"
|
assert loaded.messages[i]["content"] == f"msg{i}"
|
||||||
|
|
||||||
def test_provider_state_round_trips_in_private_record_only(self, tmp_path: Path):
|
|
||||||
mgr = SessionManager(tmp_path)
|
|
||||||
secret = "encrypted-reasoning-blob"
|
|
||||||
session = Session(
|
|
||||||
key="test:provider-state",
|
|
||||||
provider_state=ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:https://api.openai.com/v1",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"encrypted_content": secret,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
pending_messages=[{"role": "user", "content": "continue"}],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
session.add_message("user", "hello")
|
|
||||||
mgr.save(session)
|
|
||||||
|
|
||||||
records = [
|
|
||||||
json.loads(line)
|
|
||||||
for line in mgr._get_session_path(session.key)
|
|
||||||
.read_text(encoding="utf-8")
|
|
||||||
.splitlines()
|
|
||||||
]
|
|
||||||
assert [record.get("_type") for record in records] == [
|
|
||||||
"metadata",
|
|
||||||
"provider_state",
|
|
||||||
None,
|
|
||||||
]
|
|
||||||
assert secret in records[1]["state"]["payload"]["items"][0]["encrypted_content"]
|
|
||||||
|
|
||||||
mgr.invalidate(session.key)
|
|
||||||
loaded = mgr.get_or_create(session.key)
|
|
||||||
assert loaded.provider_state is not None
|
|
||||||
assert loaded.provider_state.to_private_record() == session.provider_state.to_private_record()
|
|
||||||
|
|
||||||
public_payload = mgr.read_session_file(session.key)
|
|
||||||
assert public_payload is not None
|
|
||||||
assert public_payload["messages"] == [session.messages[0]]
|
|
||||||
assert secret not in json.dumps(public_payload)
|
|
||||||
assert secret not in json.dumps(mgr.list_sessions())
|
|
||||||
|
|
||||||
def test_provider_state_does_not_consume_list_preview_budget(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
import nanobot.session.manager as session_manager
|
|
||||||
|
|
||||||
monkeypatch.setattr(session_manager, "_SESSION_LIST_PREVIEW_MAX_CHARS", 100)
|
|
||||||
mgr = SessionManager(tmp_path)
|
|
||||||
session = Session(
|
|
||||||
key="test:provider-state-preview",
|
|
||||||
provider_state=ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"encrypted_content": "x" * 200}]},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
session.add_message("user", "visible preview")
|
|
||||||
mgr.save(session)
|
|
||||||
|
|
||||||
assert mgr.list_sessions()[0]["preview"] == "visible preview"
|
|
||||||
|
|
||||||
def test_clear_and_fork_discard_provider_state(self, tmp_path: Path):
|
|
||||||
mgr = SessionManager(tmp_path)
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": []},
|
|
||||||
)
|
|
||||||
source = Session(key="test:state-source", provider_state=state)
|
|
||||||
source.add_message("user", "hello")
|
|
||||||
mgr.save(source)
|
|
||||||
|
|
||||||
fork = mgr.fork_session_before_user_index(
|
|
||||||
source.key,
|
|
||||||
"test:state-fork",
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
assert fork is not None
|
|
||||||
assert fork.provider_state is None
|
|
||||||
|
|
||||||
source.clear()
|
|
||||||
assert source.provider_state is None
|
|
||||||
|
|
||||||
def test_invalid_provider_state_record_is_not_public_history(self, tmp_path: Path):
|
|
||||||
mgr = SessionManager(tmp_path)
|
|
||||||
path = mgr._get_session_path("test:bad-provider-state")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(
|
|
||||||
"\n".join(
|
|
||||||
[
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"_type": "metadata",
|
|
||||||
"key": "test:bad-provider-state",
|
|
||||||
"created_at": datetime.now().isoformat(),
|
|
||||||
"updated_at": datetime.now().isoformat(),
|
|
||||||
"metadata": {},
|
|
||||||
"last_consolidated": 0,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"_type": "provider_state",
|
|
||||||
"state": {"kind": "openai_responses"},
|
|
||||||
}
|
|
||||||
),
|
|
||||||
json.dumps({"role": "user", "content": "safe"}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
+ "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
loaded = mgr._load("test:bad-provider-state")
|
|
||||||
assert loaded is not None
|
|
||||||
assert loaded.provider_state is None
|
|
||||||
assert loaded.messages == [{"role": "user", "content": "safe"}]
|
|
||||||
|
|
||||||
|
|
||||||
class TestRepairCorruptFile:
|
class TestRepairCorruptFile:
|
||||||
def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None:
|
def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None:
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import gc
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(loop_factory):
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
return loop_factory(provider=provider)
|
|
||||||
|
|
||||||
|
|
||||||
def test_idle_agent_session_locks_are_released(loop_factory):
|
|
||||||
loop = _make_loop(loop_factory)
|
|
||||||
|
|
||||||
for index in range(1000):
|
|
||||||
lock = loop._get_session_lock(f"api:temporary-{index}")
|
|
||||||
|
|
||||||
del lock
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
assert len(loop._session_locks) == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_waiter_keeps_agent_session_lock_alive(loop_factory):
|
|
||||||
loop = _make_loop(loop_factory)
|
|
||||||
owner_lock = loop._get_session_lock("api:shared")
|
|
||||||
await owner_lock.acquire()
|
|
||||||
waiter_started = asyncio.Event()
|
|
||||||
waiter_entered = asyncio.Event()
|
|
||||||
|
|
||||||
async def wait_for_lock() -> None:
|
|
||||||
lock = loop._get_session_lock("api:shared")
|
|
||||||
waiter_started.set()
|
|
||||||
async with lock:
|
|
||||||
waiter_entered.set()
|
|
||||||
|
|
||||||
waiter = asyncio.create_task(wait_for_lock())
|
|
||||||
await waiter_started.wait()
|
|
||||||
|
|
||||||
assert loop._get_session_lock("api:shared") is owner_lock
|
|
||||||
assert not waiter_entered.is_set()
|
|
||||||
|
|
||||||
owner_lock.release()
|
|
||||||
await waiter
|
|
||||||
del owner_lock
|
|
||||||
gc.collect()
|
|
||||||
|
|
||||||
assert "api:shared" not in loop._session_locks
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_HISTORY_META,
|
RUNTIME_CONTEXT_HISTORY_META,
|
||||||
RuntimeContextBlock,
|
RuntimeContextBlock,
|
||||||
@@ -770,16 +769,7 @@ def test_get_history_extend_to_user_keeps_newer_user_inside_window():
|
|||||||
|
|
||||||
def test_retain_recent_legal_suffix_returns_dropped_messages():
|
def test_retain_recent_legal_suffix_returns_dropped_messages():
|
||||||
"""retain_recent_legal_suffix returns the actually-dropped messages."""
|
"""retain_recent_legal_suffix returns the actually-dropped messages."""
|
||||||
session = Session(
|
session = Session(key="test:return-dropped")
|
||||||
key="test:return-dropped",
|
|
||||||
provider_state=ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": []},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
|
|
||||||
@@ -789,19 +779,11 @@ def test_retain_recent_legal_suffix_returns_dropped_messages():
|
|||||||
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
|
assert [m["content"] for m in result.dropped] == [f"msg{i}" for i in range(6)]
|
||||||
assert len(session.messages) == 4
|
assert len(session.messages) == 4
|
||||||
assert result.already_consolidated_count == 0
|
assert result.already_consolidated_count == 0
|
||||||
assert session.provider_state is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||||
"""No messages dropped → empty list returned."""
|
"""No messages dropped → empty list returned."""
|
||||||
state = ProviderConversationState(
|
session = Session(key="test:no-drop")
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": []},
|
|
||||||
)
|
|
||||||
session = Session(key="test:no-drop", provider_state=state)
|
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||||
|
|
||||||
@@ -810,7 +792,6 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
|||||||
assert result.dropped == []
|
assert result.dropped == []
|
||||||
assert result.already_consolidated_count == 0
|
assert result.already_consolidated_count == 0
|
||||||
assert len(session.messages) == 3
|
assert len(session.messages) == 3
|
||||||
assert session.provider_state is state
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_recent_legal_suffix_returns_all_on_zero():
|
def test_retain_recent_legal_suffix_returns_all_on_zero():
|
||||||
|
|||||||
@@ -111,50 +111,8 @@ class TestHandleStop:
|
|||||||
assert all(e.is_set() for e in events)
|
assert all(e.is_set() for e in events)
|
||||||
assert "2 task" in out.content
|
assert "2 task" in out.content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cancel_active_turn_discards_pending_followups(self):
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
|
|
||||||
loop, _ = _make_loop()
|
|
||||||
pending = asyncio.Queue()
|
|
||||||
pending.put_nowait(
|
|
||||||
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="next")
|
|
||||||
)
|
|
||||||
loop._pending_queues["test:c1"] = pending
|
|
||||||
|
|
||||||
assert await loop.cancel_active_turn("test:c1") == 1
|
|
||||||
assert "test:c1" not in loop._pending_queues
|
|
||||||
|
|
||||||
|
|
||||||
class TestDispatch:
|
class TestDispatch:
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_drops_deactivated_transient_message(self):
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
|
|
||||||
loop, bus = _make_loop()
|
|
||||||
msg = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="u1",
|
|
||||||
chat_id="temporary-test",
|
|
||||||
content="private",
|
|
||||||
session_key_override="websocket:temporary-test",
|
|
||||||
transient_session=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def consume_once():
|
|
||||||
loop.stop()
|
|
||||||
return msg
|
|
||||||
|
|
||||||
bus.consume_inbound = AsyncMock(side_effect=consume_once)
|
|
||||||
loop.sessions.is_transient_active.return_value = False
|
|
||||||
loop._dispatch = AsyncMock()
|
|
||||||
loop.close_mcp = AsyncMock()
|
|
||||||
loop._running = True
|
|
||||||
|
|
||||||
await loop.run()
|
|
||||||
|
|
||||||
loop._dispatch.assert_not_awaited()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
|
||||||
loop, bus = _make_loop()
|
loop, bus = _make_loop()
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.bus.events import InboundMessage
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
|
||||||
from nanobot.runtime_context import RuntimeContextBlock
|
|
||||||
from nanobot.session.manager import SessionManager
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_chat_reuses_memory_only_history_without_tools(tmp_path) -> None:
|
|
||||||
(tmp_path / "AGENTS.md").write_text("private project instruction", encoding="utf-8")
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation = GenerationSettings()
|
|
||||||
provider.chat_with_retry = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
LLMResponse(content="first answer", usage={}),
|
|
||||||
LLMResponse(content="second answer", usage={}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
unified_session=True,
|
|
||||||
)
|
|
||||||
key = "websocket:temporary-test"
|
|
||||||
loop.sessions.get_or_create_transient(key)
|
|
||||||
|
|
||||||
for content in ("first question", "second question"):
|
|
||||||
response = await loop._process_message(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="temporary-test",
|
|
||||||
content=content,
|
|
||||||
session_key_override=key,
|
|
||||||
transient_session=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert response is not None
|
|
||||||
|
|
||||||
first_call, second_call = provider.chat_with_retry.await_args_list
|
|
||||||
assert first_call.kwargs["tools"] == []
|
|
||||||
assert second_call.kwargs["tools"] == []
|
|
||||||
assert all(
|
|
||||||
message["role"] != "system"
|
|
||||||
for call in (first_call, second_call)
|
|
||||||
for message in call.kwargs["messages"]
|
|
||||||
)
|
|
||||||
assert "private project instruction" not in str(first_call.kwargs["messages"])
|
|
||||||
assert str(tmp_path) not in str(first_call.kwargs["messages"])
|
|
||||||
assert "first answer" in str(second_call.kwargs["messages"])
|
|
||||||
|
|
||||||
transient = loop.sessions.get_cached(key)
|
|
||||||
assert transient is not None
|
|
||||||
assert [message["role"] for message in transient.messages] == [
|
|
||||||
"user",
|
|
||||||
"assistant",
|
|
||||||
"user",
|
|
||||||
"assistant",
|
|
||||||
]
|
|
||||||
assert loop.sessions.read_session_file(key) is None
|
|
||||||
assert SessionManager(tmp_path).read_session_file(key) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_follow_up_does_not_resolve_runtime_context(tmp_path) -> None:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation = GenerationSettings()
|
|
||||||
provider.chat_with_retry = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
LLMResponse(content="first answer", usage={}),
|
|
||||||
LLMResponse(content="second answer", usage={}),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
runtime_context_provider = AsyncMock(
|
|
||||||
return_value=RuntimeContextBlock(
|
|
||||||
source="project",
|
|
||||||
content="SECRET LOCAL PROJECT CONTEXT",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
loop.register_runtime_context_provider(runtime_context_provider)
|
|
||||||
|
|
||||||
key = "websocket:temporary-follow-up"
|
|
||||||
session = loop.sessions.get_or_create_transient(key)
|
|
||||||
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
|
|
||||||
await pending_queue.put(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="temporary-follow-up",
|
|
||||||
content="follow up",
|
|
||||||
session_key_override=key,
|
|
||||||
transient_session=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
_, _, messages, _, _ = await loop._run_agent_loop(
|
|
||||||
[{"role": "user", "content": "first question"}],
|
|
||||||
runtime=loop.llm_runtime(),
|
|
||||||
session=session,
|
|
||||||
channel="websocket",
|
|
||||||
chat_id="temporary-follow-up",
|
|
||||||
session_key=key,
|
|
||||||
pending_queue=pending_queue,
|
|
||||||
tools=ToolRegistry(),
|
|
||||||
)
|
|
||||||
|
|
||||||
runtime_context_provider.assert_not_awaited()
|
|
||||||
assert "SECRET LOCAL PROJECT CONTEXT" not in str(messages)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discarding_active_temporary_chat_does_not_create_durable_session(
|
|
||||||
tmp_path,
|
|
||||||
) -> None:
|
|
||||||
provider_started = asyncio.Event()
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation = GenerationSettings()
|
|
||||||
|
|
||||||
async def block_provider(**_kwargs):
|
|
||||||
provider_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
|
|
||||||
provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
|
||||||
loop = AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
)
|
|
||||||
key = "websocket:temporary-cancelled"
|
|
||||||
loop.sessions.get_or_create_transient(key)
|
|
||||||
message = InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id="temporary-cancelled",
|
|
||||||
content="private",
|
|
||||||
session_key_override=key,
|
|
||||||
transient_session=True,
|
|
||||||
)
|
|
||||||
task = asyncio.create_task(loop._dispatch(message))
|
|
||||||
active_tasks = loop._active_tasks.setdefault(key, set())
|
|
||||||
active_tasks.add(task)
|
|
||||||
task.add_done_callback(active_tasks.discard)
|
|
||||||
|
|
||||||
await provider_started.wait()
|
|
||||||
assert loop.sessions.discard_transient(key)
|
|
||||||
assert await loop.cancel_active_turn(key) == 1
|
|
||||||
|
|
||||||
assert loop.sessions.get_cached(key) is None
|
|
||||||
assert loop.sessions.flush_all() == 0
|
|
||||||
assert loop.sessions.read_session_file(key) is None
|
|
||||||
@@ -253,7 +253,7 @@ class TestCmdNewUnifiedSession:
|
|||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
cancel_active_turn=AsyncMock(return_value=0),
|
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||||
llm_runtime=MagicMock(return_value=MagicMock()),
|
llm_runtime=MagicMock(return_value=MagicMock()),
|
||||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
@@ -301,7 +301,7 @@ class TestCmdNewUnifiedSession:
|
|||||||
loop = SimpleNamespace(
|
loop = SimpleNamespace(
|
||||||
sessions=sessions,
|
sessions=sessions,
|
||||||
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
|
||||||
cancel_active_turn=AsyncMock(return_value=0),
|
_cancel_active_tasks=AsyncMock(return_value=0),
|
||||||
runtime_for_session=MagicMock(return_value=MagicMock()),
|
runtime_for_session=MagicMock(return_value=MagicMock()),
|
||||||
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
schedule_background=lambda coro: asyncio.ensure_future(coro),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -504,7 +504,6 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
|||||||
usage={},
|
usage={},
|
||||||
had_injections=False,
|
had_injections=False,
|
||||||
tools_used=[],
|
tools_used=[],
|
||||||
provider_state=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
@@ -590,7 +589,6 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
|||||||
usage={},
|
usage={},
|
||||||
had_injections=False,
|
had_injections=False,
|
||||||
tools_used=[],
|
tools_used=[],
|
||||||
provider_state=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
@@ -640,7 +638,6 @@ async def test_drain_pending_timeout(tmp_path):
|
|||||||
usage={},
|
usage={},
|
||||||
had_injections=False,
|
had_injections=False,
|
||||||
tools_used=[],
|
tools_used=[],
|
||||||
provider_state=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
|
|||||||
@@ -83,49 +83,6 @@ async def test_handle_message_dm_sends_pairing_code(monkeypatch) -> None:
|
|||||||
assert msg.metadata.get("_pairing_code") == "ABCD-EFGH"
|
assert msg.metadata.get("_pairing_code") == "ABCD-EFGH"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dm_during_transient_store_failure_keeps_approvals(
|
|
||||||
tmp_path, monkeypatch
|
|
||||||
) -> None:
|
|
||||||
"""An unapproved DM while pairing.json is unreadable must not wipe approvals.
|
|
||||||
|
|
||||||
The pairing store treated a transient OSError like corruption and returned
|
|
||||||
an empty store; the DM pairing path then persisted that empty view,
|
|
||||||
erasing every approved sender.
|
|
||||||
"""
|
|
||||||
import builtins
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.pairing import store
|
|
||||||
|
|
||||||
path = tmp_path / "pairing.json"
|
|
||||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
|
||||||
code = store.generate_code("dummy", "friend")
|
|
||||||
store.approve_code(code)
|
|
||||||
|
|
||||||
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
|
||||||
|
|
||||||
real_open = builtins.open
|
|
||||||
|
|
||||||
def flaky_open(file, mode="r", *args, **kwargs):
|
|
||||||
try:
|
|
||||||
same = Path(file) == path
|
|
||||||
except TypeError:
|
|
||||||
same = False
|
|
||||||
if same and "r" in mode and "+" not in mode:
|
|
||||||
raise PermissionError(13, "temporarily locked", str(path))
|
|
||||||
return real_open(file, mode, *args, **kwargs)
|
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
|
||||||
m.setattr(builtins, "open", flaky_open)
|
|
||||||
await channel._handle_message(
|
|
||||||
sender_id="stranger", chat_id="chat1", content="hello", is_dm=True
|
|
||||||
)
|
|
||||||
|
|
||||||
assert channel._sent == []
|
|
||||||
assert store.is_approved("dummy", "friend") is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_message_group_ignores_unknown() -> None:
|
async def test_handle_message_group_ignores_unknown() -> None:
|
||||||
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
channel = _DummyChannel({"allowFrom": []}, MessageBus())
|
||||||
|
|||||||
@@ -2558,7 +2558,7 @@ def test_optional_dependency_metadata_for_enable():
|
|||||||
):
|
):
|
||||||
assert not any(dep.startswith(dep_name) for dep in required)
|
assert not any(dep.startswith(dep_name) for dep in required)
|
||||||
for dependency in (
|
for dependency in (
|
||||||
"tzdata>=2025.2",
|
"tzdata>=2025.2; sys_platform == 'win32'",
|
||||||
"defusedxml>=0.7.1,<1.0.0",
|
"defusedxml>=0.7.1,<1.0.0",
|
||||||
"pypdf>=5.0.0,<6.0.0",
|
"pypdf>=5.0.0,<6.0.0",
|
||||||
"python-docx>=1.1.0,<2.0.0",
|
"python-docx>=1.1.0,<2.0.0",
|
||||||
|
|||||||
@@ -1160,63 +1160,6 @@ def test_config_falls_back_to_vllm_when_ollama_not_configured():
|
|||||||
assert config.get_api_base() == "http://localhost:8000"
|
assert config.get_api_base() == "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
def test_config_cloud_nemotron_is_not_hijacked_by_unconfigured_ollama():
|
|
||||||
"""`nvidia/nemotron-*` via a gateway must not route to Ollama when no
|
|
||||||
Ollama endpoint is configured. Ollama keeps "nemotron" in its keywords
|
|
||||||
for bare-model auto-routing (PR #1863), which previously hijacked
|
|
||||||
cloud-hosted nemotron variants and silently sent traffic to
|
|
||||||
http://localhost:11434/v1."""
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "auto",
|
|
||||||
"model": "nvidia/nemotron-3-super-120b-a12b",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"providers": {"openrouter": {"apiKey": "sk-or-test"}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert config.get_provider_name() == "openrouter"
|
|
||||||
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_bare_nemotron_still_auto_routes_to_configured_ollama():
|
|
||||||
"""Preserves PR #1863 intent: when the user has actually configured an
|
|
||||||
Ollama endpoint, a bare nemotron model still auto-routes there."""
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {"defaults": {"provider": "auto", "model": "nemotron-3-nano"}},
|
|
||||||
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert config.get_provider_name() == "ollama"
|
|
||||||
assert config.get_api_base() == "http://localhost:11434/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_cloud_nemotron_is_not_hijacked_by_configured_ollama():
|
|
||||||
"""An explicit cloud namespace takes precedence over local keywords."""
|
|
||||||
config = Config.model_validate(
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"defaults": {
|
|
||||||
"provider": "auto",
|
|
||||||
"model": "nvidia/nemotron-3-super-120b-a12b",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"providers": {
|
|
||||||
"ollama": {"apiBase": "http://localhost:11434/v1"},
|
|
||||||
"openrouter": {"apiKey": "sk-or-test"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert config.get_provider_name() == "openrouter"
|
|
||||||
assert config.get_api_base() == "https://openrouter.ai/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_openai_compat_provider_passes_model_through():
|
def test_openai_compat_provider_passes_model_through():
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class TestMidTurnCommandDispatchedDirectly:
|
|||||||
loop.sessions.save = MagicMock()
|
loop.sessions.save = MagicMock()
|
||||||
loop.sessions.invalidate = MagicMock()
|
loop.sessions.invalidate = MagicMock()
|
||||||
loop.schedule_background = MagicMock()
|
loop.schedule_background = MagicMock()
|
||||||
loop.cancel_active_turn = AsyncMock(return_value=0)
|
loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||||
return loop
|
return loop
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -13,7 +14,13 @@ from nanobot.command.router import CommandContext
|
|||||||
async def test_cmd_stop_drains_pending_queue():
|
async def test_cmd_stop_drains_pending_queue():
|
||||||
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop.cancel_active_turn = AsyncMock(return_value=3)
|
mock_loop._cancel_active_tasks = AsyncMock(return_value=1)
|
||||||
|
mock_loop._pending_queues = {}
|
||||||
|
|
||||||
|
pending = asyncio.Queue()
|
||||||
|
await pending.put("msg1")
|
||||||
|
await pending.put("msg2")
|
||||||
|
mock_loop._pending_queues["test-session"] = pending
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
@@ -27,14 +34,18 @@ async def test_cmd_stop_drains_pending_queue():
|
|||||||
|
|
||||||
assert isinstance(result, OutboundMessage)
|
assert isinstance(result, OutboundMessage)
|
||||||
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
|
||||||
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
assert "test-session" not in mock_loop._pending_queues
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cmd_stop_with_empty_pending_queue():
|
async def test_cmd_stop_with_empty_pending_queue():
|
||||||
"""cmd_stop should work correctly when pending queue is empty."""
|
"""cmd_stop should work correctly when pending queue is empty."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop.cancel_active_turn = AsyncMock(return_value=2)
|
mock_loop._cancel_active_tasks = AsyncMock(return_value=2)
|
||||||
|
mock_loop._pending_queues = {}
|
||||||
|
|
||||||
|
pending = asyncio.Queue()
|
||||||
|
mock_loop._pending_queues["test-session"] = pending
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
@@ -47,14 +58,15 @@ async def test_cmd_stop_with_empty_pending_queue():
|
|||||||
result = await cmd_stop(ctx)
|
result = await cmd_stop(ctx)
|
||||||
|
|
||||||
assert "Stopped 2 task(s)" in result.content
|
assert "Stopped 2 task(s)" in result.content
|
||||||
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
|
assert "test-session" not in mock_loop._pending_queues
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cmd_stop_no_pending_queue():
|
async def test_cmd_stop_no_pending_queue():
|
||||||
"""cmd_stop should work when no pending queue exists."""
|
"""cmd_stop should work when no pending queue exists."""
|
||||||
mock_loop = MagicMock()
|
mock_loop = MagicMock()
|
||||||
mock_loop.cancel_active_turn = AsyncMock(return_value=0)
|
mock_loop._cancel_active_tasks = AsyncMock(return_value=0)
|
||||||
|
mock_loop._pending_queues = {}
|
||||||
|
|
||||||
ctx = CommandContext(
|
ctx = CommandContext(
|
||||||
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import textwrap
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
|
|||||||
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
|
||||||
|
|
||||||
|
|
||||||
def test_agent_timezones_use_packaged_data_without_system_database() -> None:
|
|
||||||
script = textwrap.dedent(
|
|
||||||
"""\
|
|
||||||
from zoneinfo import TZPATH
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
assert not TZPATH
|
|
||||||
for name in ("UTC", "Asia/Shanghai"):
|
|
||||||
config = Config.model_validate({"agents": {"defaults": {"timezone": name}}})
|
|
||||||
serialized = config.model_dump(mode="json", by_alias=True)
|
|
||||||
restored = Config.model_validate(serialized)
|
|
||||||
assert restored.agents.defaults.timezone == name
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-c", script],
|
|
||||||
env=os.environ | {"PYTHONTZPATH": ""},
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.returncode == 0, result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_api_type_accepts_exact_values_only() -> None:
|
def test_provider_api_type_accepts_exact_values_only() -> None:
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -600,117 +600,6 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
|
|||||||
service.stop()
|
service.stop()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_manual_run_persists_completion_when_callback_lists_jobs(tmp_path) -> None:
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
|
|
||||||
async def on_job(_job) -> None:
|
|
||||||
service.list_jobs(include_disabled=True)
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
job = service.add_job(
|
|
||||||
name="manual",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
**_bound_chat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await service.run_job(job.id) is True
|
|
||||||
|
|
||||||
state = json.loads(store_path.read_text())["jobs"][0]["state"]
|
|
||||||
assert state["lastStatus"] == "ok"
|
|
||||||
assert state["lastError"] is None
|
|
||||||
assert len(state["runHistory"]) == 1
|
|
||||||
assert state["runHistory"][0]["status"] == "ok"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_overlapping_manual_runs_preserve_stopped_service_state(tmp_path) -> None:
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
entered = [asyncio.Event(), asyncio.Event()]
|
|
||||||
release = [asyncio.Event(), asyncio.Event()]
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def on_job(_job) -> None:
|
|
||||||
nonlocal call_count
|
|
||||||
call_index = call_count
|
|
||||||
call_count += 1
|
|
||||||
entered[call_index].set()
|
|
||||||
await release[call_index].wait()
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
jobs = [
|
|
||||||
service.add_job(
|
|
||||||
name=f"manual-{index}",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
**_bound_chat(str(index)),
|
|
||||||
)
|
|
||||||
for index in range(2)
|
|
||||||
]
|
|
||||||
|
|
||||||
first = asyncio.create_task(service.run_job(jobs[0].id))
|
|
||||||
await entered[0].wait()
|
|
||||||
second = asyncio.create_task(service.run_job(jobs[1].id))
|
|
||||||
try:
|
|
||||||
await entered[1].wait()
|
|
||||||
release[0].set()
|
|
||||||
assert await first is True
|
|
||||||
assert service._running is False
|
|
||||||
|
|
||||||
release[1].set()
|
|
||||||
assert await second is True
|
|
||||||
assert service._running is False
|
|
||||||
assert service._timer_task is None
|
|
||||||
|
|
||||||
states = {
|
|
||||||
item["name"]: item["state"]
|
|
||||||
for item in json.loads(store_path.read_text())["jobs"]
|
|
||||||
}
|
|
||||||
assert states["manual-0"]["lastStatus"] == "ok"
|
|
||||||
assert states["manual-1"]["lastStatus"] == "ok"
|
|
||||||
finally:
|
|
||||||
release[0].set()
|
|
||||||
release[1].set()
|
|
||||||
await asyncio.gather(first, second, return_exceptions=True)
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_manual_run_does_not_restart_service_stopped_during_execution(tmp_path) -> None:
|
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
|
||||||
entered = asyncio.Event()
|
|
||||||
release = asyncio.Event()
|
|
||||||
|
|
||||||
async def on_job(_job) -> None:
|
|
||||||
entered.set()
|
|
||||||
await release.wait()
|
|
||||||
|
|
||||||
service = CronService(store_path, on_job=on_job)
|
|
||||||
job = service.add_job(
|
|
||||||
name="manual-stop",
|
|
||||||
schedule=CronSchedule(kind="every", every_ms=60_000),
|
|
||||||
message="hello",
|
|
||||||
**_bound_chat(),
|
|
||||||
)
|
|
||||||
await service.start()
|
|
||||||
|
|
||||||
run = asyncio.create_task(service.run_job(job.id))
|
|
||||||
try:
|
|
||||||
await entered.wait()
|
|
||||||
service.stop()
|
|
||||||
release.set()
|
|
||||||
|
|
||||||
assert await run is True
|
|
||||||
assert service._running is False
|
|
||||||
assert service._timer_task is None
|
|
||||||
finally:
|
|
||||||
release.set()
|
|
||||||
await asyncio.gather(run, return_exceptions=True)
|
|
||||||
service.stop()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||||
store_path = tmp_path / "cron" / "jobs.json"
|
store_path = tmp_path / "cron" / "jobs.json"
|
||||||
|
|||||||
@@ -323,66 +323,3 @@ def test_pending_gc_drops_malformed_entries(tmp_path, monkeypatch):
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr(store, "_store_path", lambda: path)
|
monkeypatch.setattr(store, "_store_path", lambda: path)
|
||||||
assert store.list_pending() == []
|
assert store.list_pending() == []
|
||||||
|
|
||||||
|
|
||||||
def _fail_reads_of(monkeypatch, path):
|
|
||||||
"""Make reads of *path* raise like a transiently locked/busy file."""
|
|
||||||
import builtins
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
real_open = builtins.open
|
|
||||||
|
|
||||||
def flaky_open(file, mode="r", *args, **kwargs):
|
|
||||||
try:
|
|
||||||
same = Path(file) == path
|
|
||||||
except TypeError:
|
|
||||||
same = False
|
|
||||||
if same and "r" in mode and "+" not in mode:
|
|
||||||
raise PermissionError(13, "temporarily locked", str(path))
|
|
||||||
return real_open(file, mode, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(builtins, "open", flaky_open)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTransientReadFailure:
|
|
||||||
"""A transient I/O failure is not corruption and must never wipe the store."""
|
|
||||||
|
|
||||||
def test_generate_code_does_not_wipe_approvals(self, tmp_path, monkeypatch):
|
|
||||||
"""An unapproved DM during a read blip previously erased every approval.
|
|
||||||
|
|
||||||
_load treated OSError like corruption and returned an empty store;
|
|
||||||
generate_code then unconditionally saved it, overwriting pairing.json
|
|
||||||
with no approved senders.
|
|
||||||
"""
|
|
||||||
code = store.generate_code("telegram", "123")
|
|
||||||
store.approve_code(code)
|
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
|
||||||
_fail_reads_of(m, store._store_path())
|
|
||||||
with pytest.raises(OSError):
|
|
||||||
store.generate_code("telegram", "stranger")
|
|
||||||
|
|
||||||
assert store.is_approved("telegram", "123") is True
|
|
||||||
|
|
||||||
def test_reads_fail_closed_without_crashing(self, tmp_path, monkeypatch):
|
|
||||||
code = store.generate_code("telegram", "123")
|
|
||||||
store.approve_code(code)
|
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
|
||||||
_fail_reads_of(m, store._store_path())
|
|
||||||
assert store.is_approved("telegram", "123") is False
|
|
||||||
assert store.list_pending() == []
|
|
||||||
assert store.get_approved("telegram") == []
|
|
||||||
|
|
||||||
assert store.is_approved("telegram", "123") is True
|
|
||||||
|
|
||||||
def test_approve_command_reports_store_unavailable(self, tmp_path, monkeypatch):
|
|
||||||
"""/pairing approve must fail loudly instead of claiming the code is invalid."""
|
|
||||||
code = store.generate_code("telegram", "123")
|
|
||||||
|
|
||||||
with monkeypatch.context() as m:
|
|
||||||
_fail_reads_of(m, store._store_path())
|
|
||||||
reply = store.handle_pairing_command("telegram", f"approve {code}")
|
|
||||||
|
|
||||||
assert "unavailable" in reply.lower()
|
|
||||||
assert store.approve_code(code) == ("telegram", "123")
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from nanobot.providers.azure_openai_provider import (
|
|||||||
AzureOpenAIProvider,
|
AzureOpenAIProvider,
|
||||||
_AzureTokenProvider,
|
_AzureTokenProvider,
|
||||||
)
|
)
|
||||||
from nanobot.providers.base import LLMResponse, ProviderCallContext
|
from nanobot.providers.base import LLMResponse
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Init & validation
|
# Init & validation
|
||||||
@@ -234,7 +234,6 @@ def test_build_body_basic():
|
|||||||
assert body["max_output_tokens"] == 4096
|
assert body["max_output_tokens"] == 4096
|
||||||
assert body["store"] is False
|
assert body["store"] is False
|
||||||
assert "reasoning" not in body
|
assert "reasoning" not in body
|
||||||
assert "include" not in body
|
|
||||||
# input should contain the converted user message only (system extracted)
|
# input should contain the converted user message only (system extracted)
|
||||||
assert any(
|
assert any(
|
||||||
item.get("role") == "user"
|
item.get("role") == "user"
|
||||||
@@ -242,30 +241,6 @@ def test_build_body_basic():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_build_body_enables_server_compaction():
|
|
||||||
provider = AzureOpenAIProvider(
|
|
||||||
api_key="k",
|
|
||||||
api_base="https://res.openai.azure.com",
|
|
||||||
default_model="gpt-5.6",
|
|
||||||
)
|
|
||||||
|
|
||||||
body = provider._build_body(
|
|
||||||
[{"role": "user", "content": "hello"}],
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
10_000,
|
|
||||||
0.1,
|
|
||||||
"high",
|
|
||||||
None,
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=200_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert body["context_management"] == [{
|
|
||||||
"type": "compaction",
|
|
||||||
"compact_threshold": 180_000,
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_body_max_tokens_minimum():
|
def test_build_body_max_tokens_minimum():
|
||||||
"""max_output_tokens should never be less than 1."""
|
"""max_output_tokens should never be less than 1."""
|
||||||
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
provider = AzureOpenAIProvider(api_key="k", api_base="https://r.com", default_model="gpt-4o")
|
||||||
@@ -383,38 +358,6 @@ async def test_chat_success():
|
|||||||
assert result.usage["prompt_tokens"] == 10
|
assert result.usage["prompt_tokens"] == 10
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_chat_retries_without_unsupported_server_compaction():
|
|
||||||
provider = AzureOpenAIProvider(
|
|
||||||
api_key="test-key",
|
|
||||||
api_base="https://test.openai.azure.com",
|
|
||||||
default_model="gpt-5.6",
|
|
||||||
)
|
|
||||||
|
|
||||||
class UnsupportedCompactionError(Exception):
|
|
||||||
status_code = 400
|
|
||||||
body = {"error": {"message": "Unknown parameter: context_management"}}
|
|
||||||
|
|
||||||
provider._client.responses = MagicMock()
|
|
||||||
provider._client.responses.create = AsyncMock(side_effect=[
|
|
||||||
UnsupportedCompactionError(),
|
|
||||||
_make_sdk_response(content="compaction fallback"),
|
|
||||||
])
|
|
||||||
|
|
||||||
result = await provider.chat(
|
|
||||||
[{"role": "user", "content": "Hi"}],
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=200_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
create = provider._client.responses.create
|
|
||||||
assert result.content == "compaction fallback"
|
|
||||||
assert result.provider_state is not None
|
|
||||||
assert create.await_count == 2
|
|
||||||
assert "context_management" in create.call_args_list[0].kwargs
|
|
||||||
assert "context_management" not in create.call_args_list[1].kwargs
|
|
||||||
assert provider.supports_native_compaction() is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_chat_uses_default_model():
|
async def test_chat_uses_default_model():
|
||||||
provider = AzureOpenAIProvider(
|
provider = AzureOpenAIProvider(
|
||||||
@@ -468,7 +411,6 @@ async def test_chat_with_tool_calls():
|
|||||||
assert len(result.tool_calls) == 1
|
assert len(result.tool_calls) == 1
|
||||||
assert result.tool_calls[0].name == "get_weather"
|
assert result.tool_calls[0].name == "get_weather"
|
||||||
assert result.tool_calls[0].arguments == {"location": "SF"}
|
assert result.tool_calls[0].arguments == {"location": "SF"}
|
||||||
assert result.provider_state is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -568,7 +510,6 @@ async def test_chat_stream_with_tool_calls():
|
|||||||
item_done.name = "get_weather"
|
item_done.name = "get_weather"
|
||||||
ev_item_done = MagicMock(type="response.output_item.done", item=item_done)
|
ev_item_done = MagicMock(type="response.output_item.done", item=item_done)
|
||||||
resp_obj = MagicMock(status="completed")
|
resp_obj = MagicMock(status="completed")
|
||||||
resp_obj.model_dump.return_value = {"status": "completed", "output": []}
|
|
||||||
ev_completed = MagicMock(type="response.completed", response=resp_obj)
|
ev_completed = MagicMock(type="response.completed", response=resp_obj)
|
||||||
|
|
||||||
async def mock_stream():
|
async def mock_stream():
|
||||||
@@ -586,7 +527,6 @@ async def test_chat_stream_with_tool_calls():
|
|||||||
assert len(result.tool_calls) == 1
|
assert len(result.tool_calls) == 1
|
||||||
assert result.tool_calls[0].name == "get_weather"
|
assert result.tool_calls[0].name == "get_weather"
|
||||||
assert result.tool_calls[0].arguments == {"location": "SF"}
|
assert result.tool_calls[0].arguments == {"location": "SF"}
|
||||||
assert result.provider_state is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
"""Tests for provider-owned conversation-state lifecycle coordination."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderConversationState,
|
|
||||||
ToolCallRequest,
|
|
||||||
)
|
|
||||||
from nanobot.providers.conversation_state import (
|
|
||||||
ProviderConversationStateController,
|
|
||||||
allows_conversation_message_merge,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _provider(*, resumable: bool = True, compact: bool = False) -> MagicMock:
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
|
||||||
provider.can_resume_conversation_state.return_value = resumable
|
|
||||||
provider.supports_native_compaction.return_value = compact
|
|
||||||
return provider
|
|
||||||
|
|
||||||
|
|
||||||
def _state(label: str, *, pending: list[dict] | None = None) -> ProviderConversationState:
|
|
||||||
return ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"type": "reasoning", "encrypted_content": label}]},
|
|
||||||
pending_messages=pending or [],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_controller_replays_only_messages_after_provider_output() -> None:
|
|
||||||
provider = _provider()
|
|
||||||
messages = [
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "run a tool"},
|
|
||||||
]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
state = _state("first")
|
|
||||||
|
|
||||||
controller.prepare_request(messages, context_window_tokens=200_000)
|
|
||||||
response = LLMResponse(content=None, provider_state=state)
|
|
||||||
controller.observe_response(response, messages)
|
|
||||||
assert allows_conversation_message_merge(messages[-1]) is False
|
|
||||||
|
|
||||||
messages.append(controller.project_response_message(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [{"id": "call_1", "type": "function"}],
|
|
||||||
},
|
|
||||||
response,
|
|
||||||
))
|
|
||||||
tool_message = {
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1",
|
|
||||||
"content": "tool result",
|
|
||||||
}
|
|
||||||
messages.append(tool_message)
|
|
||||||
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert provider_context is not None
|
|
||||||
assert provider_context.conversation_state is not None
|
|
||||||
assert provider_context.conversation_state.payload == state.payload
|
|
||||||
assert provider_context.conversation_state.pending_messages == [tool_message]
|
|
||||||
assert controller.checkpoint(messages).pending_messages == [tool_message]
|
|
||||||
|
|
||||||
|
|
||||||
def test_controller_uses_governed_messages_for_provider_state_delta() -> None:
|
|
||||||
provider = _provider()
|
|
||||||
messages = [
|
|
||||||
{"role": "user", "content": "run a tool"},
|
|
||||||
]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
)
|
|
||||||
state = _state("first")
|
|
||||||
|
|
||||||
controller.prepare_request(messages, context_window_tokens=200_000)
|
|
||||||
response = LLMResponse(content=None, provider_state=state)
|
|
||||||
controller.observe_response(response, messages)
|
|
||||||
messages.extend([
|
|
||||||
controller.project_response_message(
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [{"id": "call_1", "type": "function"}],
|
|
||||||
},
|
|
||||||
response,
|
|
||||||
),
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1",
|
|
||||||
"content": "raw oversized result",
|
|
||||||
},
|
|
||||||
])
|
|
||||||
governed_messages = [
|
|
||||||
messages[0],
|
|
||||||
messages[1],
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1",
|
|
||||||
"content": "compacted result",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
model_messages=governed_messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert provider_context is not None
|
|
||||||
assert provider_context.conversation_state is not None
|
|
||||||
assert provider_context.conversation_state.pending_messages == [{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1",
|
|
||||||
"content": "compacted result",
|
|
||||||
}]
|
|
||||||
assert controller.checkpoint(messages).pending_messages[-1]["content"] == (
|
|
||||||
"raw oversized result"
|
|
||||||
)
|
|
||||||
governed_checkpoint = controller.checkpoint(
|
|
||||||
messages,
|
|
||||||
model_messages=governed_messages,
|
|
||||||
)
|
|
||||||
assert governed_checkpoint is not None
|
|
||||||
assert governed_checkpoint.pending_messages[-1]["content"] == "compacted result"
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_response_preserves_only_durable_request_messages() -> None:
|
|
||||||
provider = _provider()
|
|
||||||
current_message = {"role": "user", "content": "continue"}
|
|
||||||
supplemental = {"role": "user", "content": "internal finalization retry"}
|
|
||||||
messages = [{"role": "system", "content": "system"}, current_message]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=_state("saved", pending=[
|
|
||||||
{"role": "tool", "content": "prior"},
|
|
||||||
current_message,
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_context = controller.prepare_request(
|
|
||||||
messages,
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
supplemental_messages=[supplemental],
|
|
||||||
)
|
|
||||||
assert provider_context is not None
|
|
||||||
assert provider_context.conversation_state is not None
|
|
||||||
assert provider_context.conversation_state.pending_messages == [
|
|
||||||
{"role": "tool", "content": "prior"},
|
|
||||||
current_message,
|
|
||||||
supplemental,
|
|
||||||
]
|
|
||||||
|
|
||||||
controller.observe_response(
|
|
||||||
LLMResponse(
|
|
||||||
content="temporary failure",
|
|
||||||
finish_reason="error",
|
|
||||||
error_kind="timeout",
|
|
||||||
),
|
|
||||||
messages,
|
|
||||||
)
|
|
||||||
placeholder = {"role": "assistant", "content": "model error"}
|
|
||||||
messages.append(placeholder)
|
|
||||||
|
|
||||||
state = controller.finish(messages)
|
|
||||||
assert state is not None
|
|
||||||
assert state.pending_messages == [
|
|
||||||
{"role": "tool", "content": "prior"},
|
|
||||||
current_message,
|
|
||||||
placeholder,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_non_retryable_response_discards_saved_state() -> None:
|
|
||||||
provider = _provider()
|
|
||||||
messages = [{"role": "user", "content": "continue"}]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=_state("saved"),
|
|
||||||
)
|
|
||||||
|
|
||||||
controller.prepare_request(messages, context_window_tokens=200_000)
|
|
||||||
controller.observe_response(
|
|
||||||
LLMResponse(
|
|
||||||
content="invalid request",
|
|
||||||
finish_reason="error",
|
|
||||||
error_status_code=400,
|
|
||||||
error_should_retry=False,
|
|
||||||
),
|
|
||||||
messages,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert controller.finish(messages) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("finish_reason", "exposes_tool_call"),
|
|
||||||
[
|
|
||||||
("length", False),
|
|
||||||
("length", True),
|
|
||||||
("refusal", True),
|
|
||||||
("content_filter", True),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_terminal_response_discards_candidate_state(
|
|
||||||
finish_reason: str,
|
|
||||||
exposes_tool_call: bool,
|
|
||||||
) -> None:
|
|
||||||
provider = _provider()
|
|
||||||
messages = [{"role": "user", "content": "continue"}]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=_state("saved"),
|
|
||||||
)
|
|
||||||
|
|
||||||
controller.prepare_request(messages, context_window_tokens=200_000)
|
|
||||||
candidate = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload={
|
|
||||||
"items": [{
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": "call_1",
|
|
||||||
"name": "exec",
|
|
||||||
"arguments": "{}",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response = LLMResponse(
|
|
||||||
content="terminal response",
|
|
||||||
tool_calls=(
|
|
||||||
[ToolCallRequest(id="call_1", name="exec", arguments={})]
|
|
||||||
if exposes_tool_call
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
finish_reason=finish_reason,
|
|
||||||
provider_state=candidate,
|
|
||||||
)
|
|
||||||
assert response.has_tool_calls is exposes_tool_call
|
|
||||||
assert response.should_execute_tools is False
|
|
||||||
|
|
||||||
controller.observe_response(response, messages)
|
|
||||||
|
|
||||||
assert controller.finish(messages) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_independent_request_exposes_context_without_capability_check() -> None:
|
|
||||||
provider = _provider(compact=False)
|
|
||||||
messages = [{"role": "user", "content": "hello"}]
|
|
||||||
controller = ProviderConversationStateController(
|
|
||||||
provider=provider,
|
|
||||||
model="gpt-5.6",
|
|
||||||
messages=messages,
|
|
||||||
state=_state("saved"),
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_context = controller.independent_request_context(
|
|
||||||
context_window_tokens=200_000,
|
|
||||||
)
|
|
||||||
assert provider_context is not None
|
|
||||||
assert provider_context.conversation_state is None
|
|
||||||
assert provider_context.context_window_tokens == 200_000
|
|
||||||
provider.supports_native_compaction.assert_not_called()
|
|
||||||
@@ -10,7 +10,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderCallContext
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
@@ -45,10 +44,8 @@ def test_build_responses_body_strips_github_copilot_prefix():
|
|||||||
temperature=0.1,
|
temperature=0.1,
|
||||||
reasoning_effort=None,
|
reasoning_effort=None,
|
||||||
tool_choice=None,
|
tool_choice=None,
|
||||||
provider_context=ProviderCallContext(context_window_tokens=128_000),
|
|
||||||
)
|
)
|
||||||
assert body["model"] == "gpt-5.4-mini"
|
assert body["model"] == "gpt-5.4-mini"
|
||||||
assert "context_management" not in body
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderCallContext
|
|
||||||
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
@@ -680,7 +679,6 @@ async def test_direct_openai_gpt5_uses_responses_api() -> None:
|
|||||||
assert call_kwargs["max_output_tokens"] == 4096
|
assert call_kwargs["max_output_tokens"] == 4096
|
||||||
assert "input" in call_kwargs
|
assert "input" in call_kwargs
|
||||||
assert "messages" not in call_kwargs
|
assert "messages" not in call_kwargs
|
||||||
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -712,40 +710,6 @@ async def test_direct_openai_reasoning_prefers_responses_api() -> None:
|
|||||||
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
|
assert call_kwargs["include"] == ["reasoning.encrypted_content"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_direct_openai_retries_without_unsupported_server_compaction() -> None:
|
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
|
||||||
mock_responses = AsyncMock(side_effect=[
|
|
||||||
_FakeResponsesError(400, "Unknown parameter: context_management"),
|
|
||||||
_fake_responses_response("compaction fallback"),
|
|
||||||
])
|
|
||||||
spec = find_by_name("openai")
|
|
||||||
|
|
||||||
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
|
|
||||||
client_instance = mock_client_class.return_value
|
|
||||||
client_instance.chat.completions.create = mock_chat
|
|
||||||
client_instance.responses.create = mock_responses
|
|
||||||
provider = OpenAICompatProvider(
|
|
||||||
api_key="sk-test-key",
|
|
||||||
default_model="gpt-5.6",
|
|
||||||
spec=spec,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await provider.chat_with_context(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
model="gpt-5.6",
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=200_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == "compaction fallback"
|
|
||||||
assert result.provider_state is not None
|
|
||||||
assert mock_responses.await_count == 2
|
|
||||||
assert "context_management" in mock_responses.call_args_list[0].kwargs
|
|
||||||
assert "context_management" not in mock_responses.call_args_list[1].kwargs
|
|
||||||
assert provider.supports_native_compaction("gpt-5.6") is False
|
|
||||||
mock_chat.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
|
async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
|
||||||
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
mock_chat = AsyncMock(return_value=_fake_chat_response())
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from nanobot.providers.openai_codex_provider import (
|
|||||||
_request_codex,
|
_request_codex,
|
||||||
_should_retry_status,
|
_should_retry_status,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses import build_responses_state
|
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
|
|
||||||
|
|
||||||
@@ -116,48 +115,6 @@ async def test_codex_request_non_200_populates_http_metadata(monkeypatch) -> Non
|
|||||||
assert error.should_retry is True
|
assert error.should_retry is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_codex_request_marks_rejected_compaction_without_retaining_raw_body(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
original_client = httpx.AsyncClient
|
|
||||||
secret = "PRIVATE PROMPT MUST NOT BE RETAINED"
|
|
||||||
|
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
|
||||||
return httpx.Response(
|
|
||||||
400,
|
|
||||||
json={
|
|
||||||
"error": {
|
|
||||||
"message": f"Unknown input type compaction_trigger; {secret}",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
request=request,
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_client(
|
|
||||||
*,
|
|
||||||
timeout: int,
|
|
||||||
verify: bool,
|
|
||||||
**_kwargs: object,
|
|
||||||
) -> httpx.AsyncClient:
|
|
||||||
return original_client(transport=httpx.MockTransport(handler), timeout=timeout)
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider.httpx.AsyncClient", fake_client)
|
|
||||||
|
|
||||||
with pytest.raises(_CodexHTTPError) as caught:
|
|
||||||
await _request_codex(
|
|
||||||
"https://codex.example/responses",
|
|
||||||
{},
|
|
||||||
{"input": [{"type": "compaction_trigger"}]},
|
|
||||||
verify=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
error = caught.value
|
|
||||||
assert error.compaction_unsupported is True
|
|
||||||
assert secret not in str(error)
|
|
||||||
assert not hasattr(error, "body")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_codex_request_honors_stream_idle_timeout_env(monkeypatch) -> None:
|
async def test_codex_request_honors_stream_idle_timeout_env(monkeypatch) -> None:
|
||||||
"""NANOBOT_STREAM_IDLE_TIMEOUT_S overrides the default Codex stream timeout."""
|
"""NANOBOT_STREAM_IDLE_TIMEOUT_S overrides the default Codex stream timeout."""
|
||||||
@@ -235,7 +192,7 @@ async def test_codex_prompt_cache_key_uses_stable_conversation_prefix(monkeypatc
|
|||||||
):
|
):
|
||||||
_ = proxy, on_thinking_delta, on_tool_call_delta
|
_ = proxy, on_thinking_delta, on_tool_call_delta
|
||||||
bodies.append(body)
|
bodies.append(body)
|
||||||
return provider_base.LLMResponse(content="ok")
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
|
|
||||||
@@ -275,7 +232,7 @@ async def test_codex_provider_applies_extra_body_from_config(monkeypatch) -> Non
|
|||||||
|
|
||||||
async def fake_request(_url, _headers, body, **_kwargs):
|
async def fake_request(_url, _headers, body, **_kwargs):
|
||||||
bodies.append(body)
|
bodies.append(body)
|
||||||
return provider_base.LLMResponse(content="ok")
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
config = Config.model_validate({
|
config = Config.model_validate({
|
||||||
@@ -340,7 +297,7 @@ async def test_codex_provider_passes_proxy_to_oauth_and_response_request(monkeyp
|
|||||||
):
|
):
|
||||||
_ = url, headers, body, verify, on_content_delta, on_thinking_delta, on_tool_call_delta
|
_ = url, headers, body, verify, on_content_delta, on_thinking_delta, on_tool_call_delta
|
||||||
seen["request_proxy"] = proxy
|
seen["request_proxy"] = proxy
|
||||||
return provider_base.LLMResponse(content="ok")
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider.get_codex_token", fake_token)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider.get_codex_token", fake_token)
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
@@ -427,7 +384,7 @@ async def test_codex_retry_uses_structured_timeout_metadata(monkeypatch) -> None
|
|||||||
calls += 1
|
calls += 1
|
||||||
if calls == 1:
|
if calls == 1:
|
||||||
raise httpx.ReadTimeout("")
|
raise httpx.ReadTimeout("")
|
||||||
return provider_base.LLMResponse(content="ok")
|
return "ok", [], "stop", {}, None
|
||||||
|
|
||||||
async def fake_sleep(delay: float) -> None:
|
async def fake_sleep(delay: float) -> None:
|
||||||
delays.append(delay)
|
delays.append(delay)
|
||||||
@@ -576,254 +533,6 @@ def test_codex_reasoning_options_request_summary_without_forcing_effort() -> Non
|
|||||||
assert _build_reasoning_options("none") == {"effort": "none"}
|
assert _build_reasoning_options("none") == {"effort": "none"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_codex_replayed_tool_turn_omits_server_item_ids(monkeypatch) -> None:
|
|
||||||
_mock_codex_token(monkeypatch)
|
|
||||||
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
|
|
||||||
state = build_responses_state(
|
|
||||||
provider=provider._responses_state_provider(),
|
|
||||||
model="gpt-5.6-sol",
|
|
||||||
input_items=[{
|
|
||||||
"id": "msg_user",
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": "Check the weather"}],
|
|
||||||
}],
|
|
||||||
output_items=[
|
|
||||||
{
|
|
||||||
"id": "rs_reasoning",
|
|
||||||
"type": "reasoning",
|
|
||||||
"encrypted_content": "opaque reasoning",
|
|
||||||
"summary": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "fc_read",
|
|
||||||
"type": "function_call",
|
|
||||||
"call_id": "call_read",
|
|
||||||
"name": "read_file",
|
|
||||||
"arguments": '{"path":"weather/SKILL.md"}',
|
|
||||||
"status": "completed",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
bodies: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def fake_request(
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
verify,
|
|
||||||
proxy=None,
|
|
||||||
on_content_delta=None,
|
|
||||||
on_thinking_delta=None,
|
|
||||||
on_tool_call_delta=None,
|
|
||||||
):
|
|
||||||
bodies.append(body)
|
|
||||||
return provider_base.LLMResponse(content="done")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.providers.openai_codex_provider._request_codex",
|
|
||||||
fake_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await provider.chat(
|
|
||||||
[{"role": "user", "content": "Check the weather"}],
|
|
||||||
provider_context=provider_base.ProviderCallContext(
|
|
||||||
conversation_state=state.with_pending_messages([{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_read|fc_read",
|
|
||||||
"content": "weather skill contents",
|
|
||||||
}]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.content == "done"
|
|
||||||
assert len(bodies) == 1
|
|
||||||
input_items = bodies[0]["input"]
|
|
||||||
assert [item.get("type") for item in input_items] == [
|
|
||||||
"message",
|
|
||||||
"reasoning",
|
|
||||||
"function_call",
|
|
||||||
"function_call_output",
|
|
||||||
]
|
|
||||||
assert all("id" not in item for item in input_items)
|
|
||||||
assert input_items[1]["encrypted_content"] == "opaque reasoning"
|
|
||||||
assert input_items[2]["call_id"] == "call_read"
|
|
||||||
assert input_items[3]["call_id"] == "call_read"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_codex_compacts_state_at_ninety_percent_before_next_request(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
_mock_codex_token(monkeypatch)
|
|
||||||
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
|
|
||||||
state_provider = provider._responses_state_provider()
|
|
||||||
state = build_responses_state(
|
|
||||||
provider=state_provider,
|
|
||||||
model="gpt-5.6-sol",
|
|
||||||
input_items=[{"type": "message", "role": "user", "content": "old question"}],
|
|
||||||
output_items=[
|
|
||||||
{"type": "reasoning", "encrypted_content": "old opaque reasoning"},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "old answer"}],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
usage={
|
|
||||||
"prompt_tokens": 90,
|
|
||||||
"completion_tokens": 5,
|
|
||||||
"total_tokens": 95,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
bodies: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def fake_request(
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
verify,
|
|
||||||
proxy=None,
|
|
||||||
on_content_delta=None,
|
|
||||||
on_thinking_delta=None,
|
|
||||||
on_tool_call_delta=None,
|
|
||||||
):
|
|
||||||
_ = (
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
verify,
|
|
||||||
proxy,
|
|
||||||
on_content_delta,
|
|
||||||
on_thinking_delta,
|
|
||||||
on_tool_call_delta,
|
|
||||||
)
|
|
||||||
bodies.append(body)
|
|
||||||
if body["input"][-1].get("type") == "compaction_trigger":
|
|
||||||
compact_item = {
|
|
||||||
"type": "compaction",
|
|
||||||
"encrypted_content": "compacted opaque state",
|
|
||||||
}
|
|
||||||
return provider_base.LLMResponse(
|
|
||||||
content=None,
|
|
||||||
provider_state=build_responses_state(
|
|
||||||
provider=state_provider,
|
|
||||||
model="gpt-5.6-sol",
|
|
||||||
input_items=body["input"],
|
|
||||||
output_items=[compact_item],
|
|
||||||
usage={
|
|
||||||
"prompt_tokens": 95,
|
|
||||||
"completion_tokens": 2,
|
|
||||||
"total_tokens": 97,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return provider_base.LLMResponse(content="done")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.providers.openai_codex_provider._request_codex",
|
|
||||||
fake_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await provider.chat_with_retry(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "new question"},
|
|
||||||
],
|
|
||||||
max_tokens=5,
|
|
||||||
provider_context=provider_base.ProviderCallContext(
|
|
||||||
conversation_state=state.with_pending_messages([
|
|
||||||
{"role": "user", "content": "new question"},
|
|
||||||
]),
|
|
||||||
context_window_tokens=100,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.content == "done"
|
|
||||||
assert len(bodies) == 2
|
|
||||||
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
|
|
||||||
assert bodies[1]["input"][-1] == {
|
|
||||||
"type": "compaction",
|
|
||||||
"encrypted_content": "compacted opaque state",
|
|
||||||
}
|
|
||||||
assert not any(
|
|
||||||
item.get("type") == "reasoning"
|
|
||||||
for item in bodies[1]["input"]
|
|
||||||
)
|
|
||||||
assert any(
|
|
||||||
item.get("role") == "user"
|
|
||||||
and "new question" in str(item.get("content"))
|
|
||||||
for item in bodies[1]["input"]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_codex_disables_unsupported_native_compaction_and_continues(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
_mock_codex_token(monkeypatch)
|
|
||||||
provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.6-sol")
|
|
||||||
state_provider = provider._responses_state_provider()
|
|
||||||
state = build_responses_state(
|
|
||||||
provider=state_provider,
|
|
||||||
model="gpt-5.6-sol",
|
|
||||||
input_items=[{"type": "message", "role": "user", "content": "old"}],
|
|
||||||
output_items=[{"type": "reasoning", "encrypted_content": "opaque"}],
|
|
||||||
usage={"prompt_tokens": 90, "completion_tokens": 5, "total_tokens": 95},
|
|
||||||
)
|
|
||||||
bodies: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
async def fake_request(
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
verify,
|
|
||||||
proxy=None,
|
|
||||||
on_content_delta=None,
|
|
||||||
on_thinking_delta=None,
|
|
||||||
on_tool_call_delta=None,
|
|
||||||
):
|
|
||||||
_ = (
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
verify,
|
|
||||||
proxy,
|
|
||||||
on_content_delta,
|
|
||||||
on_thinking_delta,
|
|
||||||
on_tool_call_delta,
|
|
||||||
)
|
|
||||||
bodies.append(body)
|
|
||||||
if body["input"][-1].get("type") == "compaction_trigger":
|
|
||||||
raise _CodexHTTPError(
|
|
||||||
"HTTP 400: Codex API request failed",
|
|
||||||
status_code=400,
|
|
||||||
compaction_unsupported=True,
|
|
||||||
)
|
|
||||||
return provider_base.LLMResponse(content="done")
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"nanobot.providers.openai_codex_provider._request_codex",
|
|
||||||
fake_request,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await provider.chat(
|
|
||||||
[{"role": "user", "content": "new"}],
|
|
||||||
max_tokens=5,
|
|
||||||
provider_context=provider_base.ProviderCallContext(
|
|
||||||
conversation_state=state.with_pending_messages([
|
|
||||||
{"role": "user", "content": "new"},
|
|
||||||
]),
|
|
||||||
context_window_tokens=100,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.content == "done"
|
|
||||||
assert len(bodies) == 2
|
|
||||||
assert bodies[0]["input"][-1] == {"type": "compaction_trigger"}
|
|
||||||
assert bodies[1]["input"][-1] != {"type": "compaction_trigger"}
|
|
||||||
assert provider.supports_native_compaction() is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
||||||
def fake_token(**_kwargs):
|
def fake_token(**_kwargs):
|
||||||
@@ -850,12 +559,7 @@ async def test_codex_stream_surfaces_reasoning_summary(monkeypatch) -> None:
|
|||||||
await on_content_delta("answer")
|
await on_content_delta("answer")
|
||||||
if on_thinking_delta:
|
if on_thinking_delta:
|
||||||
await on_thinking_delta("summary")
|
await on_thinking_delta("summary")
|
||||||
return provider_base.LLMResponse(
|
return "answer", [], "stop", {"prompt_tokens": 10, "completion_tokens": 5}, "summary"
|
||||||
content="answer",
|
|
||||||
finish_reason="stop",
|
|
||||||
usage={"prompt_tokens": 10, "completion_tokens": 5},
|
|
||||||
reasoning_content="summary",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
monkeypatch.setattr("nanobot.providers.openai_codex_provider._request_codex", fake_request)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
"""Tests for the shared openai_responses converters and parsers."""
|
"""Tests for the shared openai_responses converters and parsers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from io import StringIO
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.providers.openai_responses.converters import (
|
from nanobot.providers.openai_responses.converters import (
|
||||||
convert_messages,
|
convert_messages,
|
||||||
@@ -14,22 +12,12 @@ from nanobot.providers.openai_responses.converters import (
|
|||||||
split_tool_call_id,
|
split_tool_call_id,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.parsing import (
|
from nanobot.providers.openai_responses.parsing import (
|
||||||
ResponsesStreamCapture,
|
|
||||||
consume_sdk_stream,
|
consume_sdk_stream,
|
||||||
consume_sse,
|
consume_sse,
|
||||||
consume_sse_with_reasoning,
|
consume_sse_with_reasoning,
|
||||||
is_replayable_finish_reason,
|
|
||||||
map_finish_reason,
|
map_finish_reason,
|
||||||
parse_response_output,
|
parse_response_output,
|
||||||
)
|
)
|
||||||
from nanobot.providers.openai_responses.state import (
|
|
||||||
build_responses_state,
|
|
||||||
is_compaction_compatibility_error,
|
|
||||||
prepare_responses_input,
|
|
||||||
resolve_compact_threshold,
|
|
||||||
responses_state_context_tokens,
|
|
||||||
responses_state_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# converters - split_tool_call_id
|
# converters - split_tool_call_id
|
||||||
@@ -150,22 +138,6 @@ class TestConvertMessages:
|
|||||||
assert items[0]["content"][0]["type"] == "output_text"
|
assert items[0]["content"][0]["type"] == "output_text"
|
||||||
assert items[0]["content"][0]["text"] == "I'll help"
|
assert items[0]["content"][0]["text"] == "I'll help"
|
||||||
|
|
||||||
def test_preserves_deepseek_reasoning_content(self):
|
|
||||||
_, items = convert_messages([
|
|
||||||
{"role": "assistant", "reasoning_content": "think first", "content": "answer"},
|
|
||||||
], preserve_reasoning=True)
|
|
||||||
|
|
||||||
assert items == [
|
|
||||||
{"type": "reasoning", "content": "think first"},
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "output_text", "text": "answer"}],
|
|
||||||
"status": "completed",
|
|
||||||
"id": "msg_0",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_assistant_empty_content_skipped(self):
|
def test_assistant_empty_content_skipped(self):
|
||||||
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
_, items = convert_messages([{"role": "assistant", "content": ""}])
|
||||||
assert len(items) == 0
|
assert len(items) == 0
|
||||||
@@ -426,17 +398,6 @@ class TestMapFinishReason:
|
|||||||
def test_unknown_defaults_to_stop(self):
|
def test_unknown_defaults_to_stop(self):
|
||||||
assert map_finish_reason("some_new_status") == "stop"
|
assert map_finish_reason("some_new_status") == "stop"
|
||||||
|
|
||||||
@pytest.mark.parametrize("finish_reason", ["stop", "tool_calls", "function_call"])
|
|
||||||
def test_replayable_finish_reasons(self, finish_reason):
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is True
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"finish_reason",
|
|
||||||
["length", "refusal", "content_filter", "error"],
|
|
||||||
)
|
|
||||||
def test_non_replayable_finish_reasons(self, finish_reason):
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is False
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# parsing - parse_response_output
|
# parsing - parse_response_output
|
||||||
@@ -457,29 +418,6 @@ class TestParseResponseOutput:
|
|||||||
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
assert result.tool_calls == []
|
assert result.tool_calls == []
|
||||||
|
|
||||||
def test_refusal_response_surfaces_text_without_advancing_state(self):
|
|
||||||
refusal = "I can’t help with that request."
|
|
||||||
resp = {
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": refusal}],
|
|
||||||
}],
|
|
||||||
"status": "completed",
|
|
||||||
"usage": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = parse_response_output(
|
|
||||||
resp,
|
|
||||||
state_provider="openai:test",
|
|
||||||
state_model="gpt-5.6",
|
|
||||||
state_input_items=[{"role": "user", "content": "request"}],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.content == refusal
|
|
||||||
assert result.finish_reason == "refusal"
|
|
||||||
assert result.provider_state is None
|
|
||||||
|
|
||||||
def test_tool_call_response(self):
|
def test_tool_call_response(self):
|
||||||
resp = {
|
resp = {
|
||||||
"output": [{
|
"output": [{
|
||||||
@@ -491,18 +429,12 @@ class TestParseResponseOutput:
|
|||||||
"status": "completed",
|
"status": "completed",
|
||||||
"usage": {},
|
"usage": {},
|
||||||
}
|
}
|
||||||
result = parse_response_output(
|
result = parse_response_output(resp)
|
||||||
resp,
|
|
||||||
state_provider="openai:test",
|
|
||||||
state_model="gpt-5.6",
|
|
||||||
state_input_items=[{"role": "user", "content": "weather?"}],
|
|
||||||
)
|
|
||||||
assert result.content is None
|
assert result.content is None
|
||||||
assert len(result.tool_calls) == 1
|
assert len(result.tool_calls) == 1
|
||||||
assert result.tool_calls[0].name == "get_weather"
|
assert result.tool_calls[0].name == "get_weather"
|
||||||
assert result.tool_calls[0].arguments == {"city": "SF"}
|
assert result.tool_calls[0].arguments == {"city": "SF"}
|
||||||
assert result.tool_calls[0].id == "call_1|fc_1"
|
assert result.tool_calls[0].id == "call_1|fc_1"
|
||||||
assert result.provider_state is not None
|
|
||||||
|
|
||||||
def test_malformed_tool_arguments_logged(self):
|
def test_malformed_tool_arguments_logged(self):
|
||||||
"""Malformed JSON arguments should log a warning and remain non-object."""
|
"""Malformed JSON arguments should log a warning and remain non-object."""
|
||||||
@@ -555,61 +487,16 @@ class TestParseResponseOutput:
|
|||||||
assert result.content == "42"
|
assert result.content == "42"
|
||||||
assert result.reasoning_content == "I think therefore I am."
|
assert result.reasoning_content == "I think therefore I am."
|
||||||
|
|
||||||
def test_deepseek_reasoning_content_extracted(self):
|
|
||||||
resp = {
|
|
||||||
"output": [
|
|
||||||
{"type": "reasoning", "content": "think first"},
|
|
||||||
{"type": "message", "content": [
|
|
||||||
{"type": "output_text", "text": "answer"},
|
|
||||||
]},
|
|
||||||
],
|
|
||||||
"status": "completed", "usage": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = parse_response_output(resp)
|
|
||||||
|
|
||||||
assert result.content == "answer"
|
|
||||||
assert result.reasoning_content == "think first"
|
|
||||||
|
|
||||||
def test_empty_output(self):
|
def test_empty_output(self):
|
||||||
resp = {"output": [], "status": "completed", "usage": {}}
|
resp = {"output": [], "status": "completed", "usage": {}}
|
||||||
result = parse_response_output(resp)
|
result = parse_response_output(resp)
|
||||||
assert result.content is None
|
assert result.content is None
|
||||||
assert result.tool_calls == []
|
assert result.tool_calls == []
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_incomplete_status(self):
|
||||||
("reason", "expected_finish_reason"),
|
resp = {"output": [], "status": "incomplete", "usage": {}}
|
||||||
[
|
result = parse_response_output(resp)
|
||||||
("max_output_tokens", "length"),
|
assert result.finish_reason == "length"
|
||||||
("content_filter", "content_filter"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_incomplete_status(self, reason, expected_finish_reason):
|
|
||||||
resp = {
|
|
||||||
"output": [],
|
|
||||||
"status": "incomplete",
|
|
||||||
"incomplete_details": {"reason": reason},
|
|
||||||
"usage": {},
|
|
||||||
}
|
|
||||||
result = parse_response_output(
|
|
||||||
resp,
|
|
||||||
state_provider="openai:test",
|
|
||||||
state_model="gpt-5.6",
|
|
||||||
state_input_items=[{"role": "user", "content": "prompt"}],
|
|
||||||
)
|
|
||||||
assert result.finish_reason == expected_finish_reason
|
|
||||||
assert result.provider_state is None
|
|
||||||
|
|
||||||
def test_unknown_status_does_not_advance_provider_state(self):
|
|
||||||
result = parse_response_output(
|
|
||||||
{"output": [], "status": "future_terminal_status", "usage": {}},
|
|
||||||
state_provider="openai:test",
|
|
||||||
state_model="gpt-5.6",
|
|
||||||
state_input_items=[{"role": "user", "content": "prompt"}],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.finish_reason == "stop"
|
|
||||||
assert result.provider_state is None
|
|
||||||
|
|
||||||
def test_sdk_model_object(self):
|
def test_sdk_model_object(self):
|
||||||
"""parse_response_output should handle SDK objects with model_dump()."""
|
"""parse_response_output should handle SDK objects with model_dump()."""
|
||||||
@@ -636,194 +523,6 @@ class TestParseResponseOutput:
|
|||||||
assert result.usage["completion_tokens"] == 50
|
assert result.usage["completion_tokens"] == 50
|
||||||
assert result.usage["total_tokens"] == 150
|
assert result.usage["total_tokens"] == 150
|
||||||
|
|
||||||
def test_preserves_every_output_item_as_opaque_state(self):
|
|
||||||
input_items = [{"role": "user", "content": "inspect the repo"}]
|
|
||||||
output = [
|
|
||||||
{
|
|
||||||
"id": "rs_1",
|
|
||||||
"type": "reasoning",
|
|
||||||
"encrypted_content": "opaque-secret",
|
|
||||||
"summary": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "future_1",
|
|
||||||
"type": "future_item_type",
|
|
||||||
"provider_field": {"nested": True},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "msg_1",
|
|
||||||
"type": "message",
|
|
||||||
"role": "assistant",
|
|
||||||
"status": "completed",
|
|
||||||
"content": [{"type": "output_text", "text": "done"}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
result = parse_response_output(
|
|
||||||
{"output": output, "status": "completed", "usage": {}},
|
|
||||||
state_provider="openai:test",
|
|
||||||
state_model="gpt-5.6",
|
|
||||||
state_input_items=input_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.provider_state is not None
|
|
||||||
assert responses_state_items(result.provider_state) == [*input_items, *output]
|
|
||||||
|
|
||||||
|
|
||||||
class TestResponsesConversationState:
|
|
||||||
def test_server_compaction_prunes_superseded_prefix(self):
|
|
||||||
state = build_responses_state(
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
input_items=[
|
|
||||||
{"type": "message", "role": "user", "content": "old"},
|
|
||||||
{"type": "reasoning", "encrypted_content": "old-reasoning"},
|
|
||||||
],
|
|
||||||
output_items=[
|
|
||||||
{"type": "compaction", "encrypted_content": "compact"},
|
|
||||||
{"type": "message", "role": "assistant", "content": "new"},
|
|
||||||
],
|
|
||||||
usage={
|
|
||||||
"prompt_tokens": 90,
|
|
||||||
"completion_tokens": 10,
|
|
||||||
"total_tokens": 100,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert responses_state_items(state) == [
|
|
||||||
{"type": "compaction", "encrypted_content": "compact"},
|
|
||||||
{"type": "message", "role": "assistant", "content": "new"},
|
|
||||||
]
|
|
||||||
assert responses_state_context_tokens(state) == 100
|
|
||||||
|
|
||||||
def test_existing_compaction_keeps_canonical_retained_prefix(self):
|
|
||||||
canonical_input = [
|
|
||||||
{"type": "message", "role": "user", "content": "retained"},
|
|
||||||
{"type": "compaction", "encrypted_content": "compact"},
|
|
||||||
]
|
|
||||||
output = [{"type": "message", "role": "assistant", "content": "new"}]
|
|
||||||
|
|
||||||
state = build_responses_state(
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
input_items=canonical_input,
|
|
||||||
output_items=output,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert responses_state_items(state) == [*canonical_input, *output]
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("context_window", "max_output", "expected"),
|
|
||||||
[
|
|
||||||
(200_000, 20_000, 180_000),
|
|
||||||
(100_000, 30_000, 70_000),
|
|
||||||
(0, 4_096, None),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_compact_threshold_reserves_codex_style_headroom(
|
|
||||||
self,
|
|
||||||
context_window,
|
|
||||||
max_output,
|
|
||||||
expected,
|
|
||||||
):
|
|
||||||
assert resolve_compact_threshold(context_window, max_output) == expected
|
|
||||||
|
|
||||||
def test_compaction_compatibility_recognizes_old_sdk_signature_error(self):
|
|
||||||
error = TypeError("create() got an unexpected keyword argument 'context_management'")
|
|
||||||
assert is_compaction_compatibility_error(error) is True
|
|
||||||
assert is_compaction_compatibility_error(TypeError("unrelated argument")) is False
|
|
||||||
|
|
||||||
def test_state_observability_logs_counts_without_opaque_content(self):
|
|
||||||
secret = "opaque-secret-that-must-not-be-logged"
|
|
||||||
state = build_responses_state(
|
|
||||||
provider=f"openai:https://example.test/?key={secret}",
|
|
||||||
model=f"secret-model-{secret}",
|
|
||||||
input_items=[{"role": "user", "content": secret}],
|
|
||||||
output_items=[{"type": "reasoning", "encrypted_content": secret}],
|
|
||||||
).with_pending_messages([{"role": "user", "content": secret}])
|
|
||||||
sink = StringIO()
|
|
||||||
sink_id = logger.add(sink, level="DEBUG", format="{message}")
|
|
||||||
try:
|
|
||||||
prepare_responses_input(
|
|
||||||
[{"role": "user", "content": secret}],
|
|
||||||
state=state,
|
|
||||||
provider=state.provider,
|
|
||||||
model=state.model,
|
|
||||||
)
|
|
||||||
build_responses_state(
|
|
||||||
provider=state.provider,
|
|
||||||
model=state.model,
|
|
||||||
input_items=[
|
|
||||||
{"role": "user", "content": secret},
|
|
||||||
{"type": "reasoning", "encrypted_content": secret},
|
|
||||||
],
|
|
||||||
output_items=[
|
|
||||||
{"type": "compaction", "encrypted_content": secret},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
logger.remove(sink_id)
|
|
||||||
|
|
||||||
log_text = sink.getvalue()
|
|
||||||
assert "prior_items=2" in log_text
|
|
||||||
assert "pending_messages=1" in log_text
|
|
||||||
assert "dropped_items=2" in log_text
|
|
||||||
assert secret not in log_text
|
|
||||||
|
|
||||||
def test_replays_exact_items_then_only_pending_and_new_messages(self):
|
|
||||||
prior_items = [
|
|
||||||
{"role": "user", "content": "first"},
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"encrypted_content": "opaque-secret",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function_call",
|
|
||||||
"id": "fc_1",
|
|
||||||
"call_id": "call_1",
|
|
||||||
"name": "read_file",
|
|
||||||
"arguments": '{"path":"a.py"}',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
state = build_responses_state(
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
input_items=prior_items[:1],
|
|
||||||
output_items=prior_items[1:],
|
|
||||||
).with_pending_messages([
|
|
||||||
{
|
|
||||||
"role": "tool",
|
|
||||||
"tool_call_id": "call_1|fc_1",
|
|
||||||
"content": "file contents",
|
|
||||||
},
|
|
||||||
{"role": "user", "content": "continue"},
|
|
||||||
])
|
|
||||||
|
|
||||||
instructions, items, replayed = prepare_responses_input(
|
|
||||||
[
|
|
||||||
{"role": "system", "content": "current instructions"},
|
|
||||||
{"role": "user", "content": "a lossy public transcript"},
|
|
||||||
],
|
|
||||||
state=state,
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert instructions == "current instructions"
|
|
||||||
assert replayed is True
|
|
||||||
assert items[:3] == prior_items
|
|
||||||
assert items[3] == {
|
|
||||||
"type": "function_call_output",
|
|
||||||
"call_id": "call_1",
|
|
||||||
"output": "file contents",
|
|
||||||
}
|
|
||||||
assert items[4] == {
|
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": "continue"}],
|
|
||||||
}
|
|
||||||
assert "lossy public transcript" not in str(items)
|
|
||||||
|
|
||||||
|
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
# parsing - consume_sse
|
# parsing - consume_sse
|
||||||
@@ -854,122 +553,6 @@ class TestConsumeSse:
|
|||||||
assert tool_calls == []
|
assert tool_calls == []
|
||||||
assert finish_reason == "stop"
|
assert finish_reason == "stop"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
|
||||||
refusal = "First and second sentence. Done-only. Terminal suffix."
|
|
||||||
terminal_response = {
|
|
||||||
"status": "completed",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_2",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": refusal}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
response = _SseResponse([
|
|
||||||
{
|
|
||||||
"type": "response.refusal.delta",
|
|
||||||
"item_id": "msg_1",
|
|
||||||
"content_index": 0,
|
|
||||||
"delta": "First",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.refusal.delta",
|
|
||||||
"item_id": "msg_1",
|
|
||||||
"content_index": 1,
|
|
||||||
"delta": " and second",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.refusal.done",
|
|
||||||
"item_id": "msg_1",
|
|
||||||
"content_index": 0,
|
|
||||||
"refusal": "First",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.refusal.done",
|
|
||||||
"item_id": "msg_1",
|
|
||||||
"content_index": 1,
|
|
||||||
"refusal": " and second sentence.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.refusal.done",
|
|
||||||
"item_id": "msg_2",
|
|
||||||
"content_index": 0,
|
|
||||||
"refusal": " Done-only.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.refusal.delta",
|
|
||||||
"item_id": "msg_2",
|
|
||||||
"content_index": 1,
|
|
||||||
"delta": " Terminal",
|
|
||||||
},
|
|
||||||
{"type": "response.completed", "response": terminal_response},
|
|
||||||
])
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
deltas: list[str] = []
|
|
||||||
|
|
||||||
async def on_content(delta: str) -> None:
|
|
||||||
deltas.append(delta)
|
|
||||||
|
|
||||||
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
|
|
||||||
response,
|
|
||||||
on_content_delta=on_content,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == refusal
|
|
||||||
assert deltas == [
|
|
||||||
"First",
|
|
||||||
" and second",
|
|
||||||
" sentence.",
|
|
||||||
" Done-only.",
|
|
||||||
" Terminal",
|
|
||||||
" suffix.",
|
|
||||||
]
|
|
||||||
assert finish_reason == "refusal"
|
|
||||||
assert capture.completed is True
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("source", ["events", "terminal"])
|
|
||||||
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
|
|
||||||
refusal = "I can’t help with that request."
|
|
||||||
terminal_response = {
|
|
||||||
"status": "completed",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_1",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": refusal}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
events = (
|
|
||||||
[
|
|
||||||
{"type": "response.refusal.done", "refusal": refusal},
|
|
||||||
{"type": "response.completed", "response": {"status": "completed"}},
|
|
||||||
]
|
|
||||||
if source == "events"
|
|
||||||
else [{"type": "response.completed", "response": terminal_response}]
|
|
||||||
)
|
|
||||||
response = _SseResponse(events)
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
deltas: list[str] = []
|
|
||||||
|
|
||||||
async def on_content(delta: str) -> None:
|
|
||||||
deltas.append(delta)
|
|
||||||
|
|
||||||
content, _, finish_reason, _, _ = await consume_sse_with_reasoning(
|
|
||||||
response,
|
|
||||||
on_content_delta=on_content,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == refusal
|
|
||||||
assert deltas == [refusal]
|
|
||||||
assert finish_reason == "refusal"
|
|
||||||
assert capture.completed is True
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_summary_delta_extracted(self):
|
async def test_reasoning_summary_delta_extracted(self):
|
||||||
response = _SseResponse([
|
response = _SseResponse([
|
||||||
@@ -1016,139 +599,6 @@ class TestConsumeSse:
|
|||||||
|
|
||||||
assert reasoning == "cached summary"
|
assert reasoning == "cached summary"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_capture_commits_exact_items_only_after_completed_event(self):
|
|
||||||
output = [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"encrypted_content": "opaque-secret",
|
|
||||||
},
|
|
||||||
{"type": "future_item_type", "id": "future_1", "value": 7},
|
|
||||||
]
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
response = _SseResponse([
|
|
||||||
{
|
|
||||||
"type": "response.output_item.done",
|
|
||||||
"output_index": 0,
|
|
||||||
"item": output[0],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.output_item.done",
|
|
||||||
"output_index": 1,
|
|
||||||
"item": output[1],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "response.completed",
|
|
||||||
"response": {"status": "completed", "output": output},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
await consume_sse_with_reasoning(response, capture=capture)
|
|
||||||
|
|
||||||
assert capture.completed is True
|
|
||||||
assert capture.output_items == output
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_capture_keeps_done_items_when_completed_output_is_empty(self):
|
|
||||||
output = [
|
|
||||||
{
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"encrypted_content": "opaque-secret",
|
|
||||||
"summary": [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "function_call",
|
|
||||||
"id": "fc_1",
|
|
||||||
"call_id": "call_1",
|
|
||||||
"name": "read_file",
|
|
||||||
"arguments": '{"path":"weather/SKILL.md"}',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
response = _SseResponse([
|
|
||||||
{
|
|
||||||
"type": "response.output_item.done",
|
|
||||||
"output_index": index,
|
|
||||||
"item": item,
|
|
||||||
}
|
|
||||||
for index, item in enumerate(output)
|
|
||||||
] + [{
|
|
||||||
"type": "response.completed",
|
|
||||||
"response": {"status": "completed", "output": []},
|
|
||||||
}])
|
|
||||||
|
|
||||||
await consume_sse_with_reasoning(response, capture=capture)
|
|
||||||
|
|
||||||
assert capture.completed is True
|
|
||||||
assert capture.output_items == output
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("reason", "expected_finish_reason"),
|
|
||||||
[
|
|
||||||
("max_output_tokens", "length"),
|
|
||||||
("content_filter", "content_filter"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_incomplete_event_commits_capture_usage(
|
|
||||||
self,
|
|
||||||
reason,
|
|
||||||
expected_finish_reason,
|
|
||||||
):
|
|
||||||
output = [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_1",
|
|
||||||
"status": "incomplete",
|
|
||||||
"content": [{"type": "output_text", "text": "partial"}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
terminal_response = {
|
|
||||||
"id": "resp_1",
|
|
||||||
"status": "incomplete",
|
|
||||||
"incomplete_details": {"reason": reason},
|
|
||||||
"output": output,
|
|
||||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
||||||
}
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
response = _SseResponse([
|
|
||||||
{"type": "response.output_text.delta", "delta": "partial"},
|
|
||||||
{"type": "response.incomplete", "response": terminal_response},
|
|
||||||
])
|
|
||||||
|
|
||||||
content, _, finish_reason, usage, _ = await consume_sse_with_reasoning(
|
|
||||||
response,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == "partial"
|
|
||||||
assert finish_reason == expected_finish_reason
|
|
||||||
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
||||||
assert capture.completed is True
|
|
||||||
assert capture.response == terminal_response
|
|
||||||
assert capture.output_items == output
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_capture_does_not_commit_interrupted_stream(self):
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
response = _SseResponse([
|
|
||||||
{
|
|
||||||
"type": "response.output_item.done",
|
|
||||||
"output_index": 0,
|
|
||||||
"item": {
|
|
||||||
"type": "reasoning",
|
|
||||||
"id": "rs_1",
|
|
||||||
"encrypted_content": "opaque-secret",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
await consume_sse_with_reasoning(response, capture=capture)
|
|
||||||
|
|
||||||
assert capture.completed is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_summary_from_done_item(self):
|
async def test_reasoning_summary_from_done_item(self):
|
||||||
response = _SseResponse([
|
response = _SseResponse([
|
||||||
@@ -1305,131 +755,6 @@ class TestConsumeSdkStream:
|
|||||||
assert tool_calls == []
|
assert tool_calls == []
|
||||||
assert finish_reason == "stop"
|
assert finish_reason == "stop"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_refusal_events_reconcile_parts_and_terminal_output(self):
|
|
||||||
refusal = "First and second sentence. Done-only. Terminal suffix."
|
|
||||||
terminal_response = {
|
|
||||||
"status": "completed",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_2",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": refusal}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
||||||
resp_obj.model_dump.return_value = terminal_response
|
|
||||||
events = [
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.delta",
|
|
||||||
item_id="msg_1",
|
|
||||||
content_index=0,
|
|
||||||
delta="First",
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.delta",
|
|
||||||
item_id="msg_1",
|
|
||||||
content_index=1,
|
|
||||||
delta=" and second",
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.done",
|
|
||||||
item_id="msg_1",
|
|
||||||
content_index=0,
|
|
||||||
refusal="First",
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.done",
|
|
||||||
item_id="msg_1",
|
|
||||||
content_index=1,
|
|
||||||
refusal=" and second sentence.",
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.done",
|
|
||||||
item_id="msg_2",
|
|
||||||
content_index=0,
|
|
||||||
refusal=" Done-only.",
|
|
||||||
),
|
|
||||||
MagicMock(
|
|
||||||
type="response.refusal.delta",
|
|
||||||
item_id="msg_2",
|
|
||||||
content_index=1,
|
|
||||||
delta=" Terminal",
|
|
||||||
),
|
|
||||||
MagicMock(type="response.completed", response=resp_obj),
|
|
||||||
]
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
deltas: list[str] = []
|
|
||||||
|
|
||||||
async def on_content(delta: str) -> None:
|
|
||||||
deltas.append(delta)
|
|
||||||
|
|
||||||
async def stream():
|
|
||||||
for event in events:
|
|
||||||
yield event
|
|
||||||
|
|
||||||
content, _, finish_reason, _, _ = await consume_sdk_stream(
|
|
||||||
stream(),
|
|
||||||
on_content_delta=on_content,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == refusal
|
|
||||||
assert deltas == [
|
|
||||||
"First",
|
|
||||||
" and second",
|
|
||||||
" sentence.",
|
|
||||||
" Done-only.",
|
|
||||||
" Terminal",
|
|
||||||
" suffix.",
|
|
||||||
]
|
|
||||||
assert finish_reason == "refusal"
|
|
||||||
assert capture.completed is True
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("source", ["events", "terminal"])
|
|
||||||
async def test_refusal_without_deltas_has_non_replayable_finish(self, source: str):
|
|
||||||
refusal = "I can’t help with that request."
|
|
||||||
terminal_response = {
|
|
||||||
"status": "completed",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_1",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{"type": "refusal", "refusal": refusal}],
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
resp_obj = MagicMock(status="completed", usage=None, output=[])
|
|
||||||
resp_obj.model_dump.return_value = terminal_response
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
deltas: list[str] = []
|
|
||||||
|
|
||||||
async def on_content(delta: str) -> None:
|
|
||||||
deltas.append(delta)
|
|
||||||
|
|
||||||
async def stream():
|
|
||||||
if source == "events":
|
|
||||||
yield MagicMock(type="response.refusal.done", refusal=refusal)
|
|
||||||
yield MagicMock(
|
|
||||||
type="response.completed",
|
|
||||||
response={"status": "completed"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
yield MagicMock(type="response.completed", response=resp_obj)
|
|
||||||
|
|
||||||
content, _, finish_reason, _, _ = await consume_sdk_stream(
|
|
||||||
stream(),
|
|
||||||
on_content_delta=on_content,
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == refusal
|
|
||||||
assert deltas == [refusal]
|
|
||||||
assert finish_reason == "refusal"
|
|
||||||
assert capture.completed is True
|
|
||||||
assert is_replayable_finish_reason(finish_reason) is False
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_content_delta_called(self):
|
async def test_on_content_delta_called(self):
|
||||||
ev1 = MagicMock(type="response.output_text.delta", delta="hi")
|
ev1 = MagicMock(type="response.output_text.delta", delta="hi")
|
||||||
@@ -1594,64 +919,6 @@ class TestConsumeSdkStream:
|
|||||||
_, _, _, usage, _ = await consume_sdk_stream(stream())
|
_, _, _, usage, _ = await consume_sdk_stream(stream())
|
||||||
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("reason", "expected_finish_reason"),
|
|
||||||
[
|
|
||||||
("max_output_tokens", "length"),
|
|
||||||
("content_filter", "content_filter"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_incomplete_event_commits_capture_usage(
|
|
||||||
self,
|
|
||||||
reason,
|
|
||||||
expected_finish_reason,
|
|
||||||
):
|
|
||||||
output = [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_1",
|
|
||||||
"status": "incomplete",
|
|
||||||
"content": [{"type": "output_text", "text": "partial"}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
usage_obj = MagicMock(input_tokens=10, output_tokens=5, total_tokens=15)
|
|
||||||
output_item = MagicMock(type="message")
|
|
||||||
terminal_response = {
|
|
||||||
"id": "resp_1",
|
|
||||||
"status": "incomplete",
|
|
||||||
"incomplete_details": {"reason": reason},
|
|
||||||
"output": output,
|
|
||||||
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
|
|
||||||
}
|
|
||||||
resp_obj = MagicMock(
|
|
||||||
status="incomplete",
|
|
||||||
usage=usage_obj,
|
|
||||||
output=[output_item],
|
|
||||||
)
|
|
||||||
resp_obj.model_dump.return_value = terminal_response
|
|
||||||
events = [
|
|
||||||
MagicMock(type="response.output_text.delta", delta="partial"),
|
|
||||||
MagicMock(type="response.incomplete", response=resp_obj),
|
|
||||||
]
|
|
||||||
capture = ResponsesStreamCapture()
|
|
||||||
|
|
||||||
async def stream():
|
|
||||||
for event in events:
|
|
||||||
yield event
|
|
||||||
|
|
||||||
content, _, finish_reason, usage, _ = await consume_sdk_stream(
|
|
||||||
stream(),
|
|
||||||
capture=capture,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert content == "partial"
|
|
||||||
assert finish_reason == expected_finish_reason
|
|
||||||
assert usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
|
||||||
assert capture.completed is True
|
|
||||||
assert capture.response == terminal_response
|
|
||||||
assert capture.output_items == output
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reasoning_extracted(self):
|
async def test_reasoning_extracted(self):
|
||||||
summary_item = MagicMock(type="summary_text", text="thinking...")
|
summary_item = MagicMock(type="summary_text", text="thinking...")
|
||||||
@@ -1665,30 +932,6 @@ class TestConsumeSdkStream:
|
|||||||
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
_, _, _, _, reasoning = await consume_sdk_stream(stream())
|
||||||
assert reasoning == "thinking..."
|
assert reasoning == "thinking..."
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_deepseek_reasoning_text_streamed(self):
|
|
||||||
events = [
|
|
||||||
MagicMock(type="response.reasoning_text.delta", delta="step 1 "),
|
|
||||||
MagicMock(type="response.reasoning_text.delta", delta="step 2"),
|
|
||||||
MagicMock(type="response.reasoning_text.done", text="step 1 step 2"),
|
|
||||||
]
|
|
||||||
emitted: list[str] = []
|
|
||||||
|
|
||||||
async def stream():
|
|
||||||
for event in events:
|
|
||||||
yield event
|
|
||||||
|
|
||||||
async def on_reasoning_delta(delta: str) -> None:
|
|
||||||
emitted.append(delta)
|
|
||||||
|
|
||||||
_, _, _, _, reasoning = await consume_sdk_stream(
|
|
||||||
stream(),
|
|
||||||
on_reasoning_delta=on_reasoning_delta,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert reasoning == "step 1 step 2"
|
|
||||||
assert emitted == ["step 1 ", "step 2"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_error_event_raises(self):
|
async def test_error_event_raises(self):
|
||||||
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
ev = MagicMock(type="error", error="rate_limit_exceeded")
|
||||||
|
|||||||
@@ -3,14 +3,7 @@ import copy
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.base import (
|
from nanobot.providers.base import RETRY_AFTER_BUFFER, GenerationSettings, LLMProvider, LLMResponse
|
||||||
RETRY_AFTER_BUFFER,
|
|
||||||
GenerationSettings,
|
|
||||||
LLMProvider,
|
|
||||||
LLMResponse,
|
|
||||||
ProviderCallContext,
|
|
||||||
ProviderConversationState,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScriptedProvider(LLMProvider):
|
class ScriptedProvider(LLMProvider):
|
||||||
@@ -337,79 +330,6 @@ async def test_successful_image_retry_mutates_original_messages_in_place() -> No
|
|||||||
assert any("not delivered" in (block.get("text") or "").lower() for block in content)
|
assert any("not delivered" in (block.get("text") or "").lower() for block in content)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("messages", "payload", "pending_messages"),
|
|
||||||
[
|
|
||||||
(_IMAGE_MSG, {}, _IMAGE_MSG),
|
|
||||||
(
|
|
||||||
[{"role": "user", "content": "continue"}],
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"role": "user",
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "input_image",
|
|
||||||
"image_url": "data:image/png;base64,abc",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
ids=["pending-image", "opaque-payload-image"],
|
|
||||||
)
|
|
||||||
async def test_image_retry_discards_provider_state_with_images(
|
|
||||||
messages,
|
|
||||||
payload,
|
|
||||||
pending_messages,
|
|
||||||
) -> None:
|
|
||||||
class ContextScriptedProvider(ScriptedProvider):
|
|
||||||
def __init__(self, responses):
|
|
||||||
super().__init__(responses)
|
|
||||||
self.contexts: list[ProviderCallContext] = []
|
|
||||||
|
|
||||||
async def chat_with_context(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
provider_context: ProviderCallContext,
|
|
||||||
**kwargs,
|
|
||||||
) -> LLMResponse:
|
|
||||||
self.contexts.append(provider_context)
|
|
||||||
return await self.chat(**kwargs)
|
|
||||||
|
|
||||||
provider = ContextScriptedProvider([
|
|
||||||
LLMResponse(content="model does not support images", finish_reason="error"),
|
|
||||||
LLMResponse(content="ok, no image"),
|
|
||||||
])
|
|
||||||
messages = copy.deepcopy(messages)
|
|
||||||
state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="gpt-5.6",
|
|
||||||
version=1,
|
|
||||||
payload=copy.deepcopy(payload),
|
|
||||||
pending_messages=copy.deepcopy(pending_messages),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = await provider.chat_with_retry(
|
|
||||||
messages=messages,
|
|
||||||
provider_context=ProviderCallContext(conversation_state=state),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.content == "ok, no image"
|
|
||||||
retry_context = provider.contexts[-1]
|
|
||||||
assert isinstance(retry_context, ProviderCallContext)
|
|
||||||
assert retry_context.conversation_state is None
|
|
||||||
public_content = messages[0]["content"]
|
|
||||||
if isinstance(public_content, list):
|
|
||||||
assert all(block.get("type") != "image_url" for block in public_content)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_transient_error_without_images_no_retry() -> None:
|
async def test_non_transient_error_without_images_no_retry() -> None:
|
||||||
"""Non-transient errors without image content are returned immediately."""
|
"""Non-transient errors without image content are returned immediately."""
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import time
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.providers.base import ProviderCallContext
|
|
||||||
from nanobot.providers.openai_compat_provider import (
|
from nanobot.providers.openai_compat_provider import (
|
||||||
_RESPONSES_FAILURE_THRESHOLD,
|
_RESPONSES_FAILURE_THRESHOLD,
|
||||||
_RESPONSES_PROBE_INTERVAL_S,
|
_RESPONSES_PROBE_INTERVAL_S,
|
||||||
@@ -29,52 +28,6 @@ def test_responses_api_available_by_default(provider):
|
|||||||
assert provider._should_use_responses_api("gpt-5", None) is True
|
assert provider._should_use_responses_api("gpt-5", None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_uses_responses_by_model(provider):
|
|
||||||
provider._spec = type("Spec", (), {
|
|
||||||
"name": "deepseek",
|
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
|
||||||
"strip_model_prefix": False,
|
|
||||||
"strip_model_prefixes": (),
|
|
||||||
})()
|
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
|
||||||
provider.default_model = "deepseek-v4-flash"
|
|
||||||
|
|
||||||
assert provider._should_use_responses_api("deepseek-v4-flash", None) is True
|
|
||||||
assert provider._should_use_responses_api("deepseek-v4-pro", None) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_deepseek_v4_flash_matches_provider_prefixed_model(provider):
|
|
||||||
provider._spec = type("Spec", (), {
|
|
||||||
"name": "deepseek",
|
|
||||||
"responses_models": ("deepseek-v4-flash",),
|
|
||||||
"strip_model_prefix": False,
|
|
||||||
"strip_model_prefixes": (),
|
|
||||||
})()
|
|
||||||
provider._effective_base = "https://api.deepseek.com"
|
|
||||||
|
|
||||||
assert provider._should_use_responses_api("deepseek/deepseek-v4-flash", None) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_direct_openai_enables_server_compaction(provider):
|
|
||||||
provider._extra_body = {}
|
|
||||||
|
|
||||||
body = provider._build_responses_body(
|
|
||||||
messages=[{"role": "user", "content": "hello"}],
|
|
||||||
tools=None,
|
|
||||||
model="gpt-5.6",
|
|
||||||
max_tokens=30_000,
|
|
||||||
temperature=0.1,
|
|
||||||
reasoning_effort="high",
|
|
||||||
tool_choice=None,
|
|
||||||
provider_context=ProviderCallContext(context_window_tokens=100_000),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert body["context_management"] == [{
|
|
||||||
"type": "compaction",
|
|
||||||
"compact_threshold": 70_000,
|
|
||||||
}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_type_chat_completions_disables_responses(provider):
|
def test_api_type_chat_completions_disables_responses(provider):
|
||||||
provider._api_type = "chat_completions"
|
provider._api_type = "chat_completions"
|
||||||
assert provider._should_use_responses_api("gpt-5", None) is False
|
assert provider._should_use_responses_api("gpt-5", None) is False
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.security.network import (
|
from nanobot.security.network import (
|
||||||
|
Httpx2PinnedDNSAsyncTransport,
|
||||||
|
PinnedDNSAsyncTransport,
|
||||||
configure_ssrf_whitelist,
|
configure_ssrf_whitelist,
|
||||||
contains_internal_url,
|
contains_internal_url,
|
||||||
env_proxy_applies_to_url,
|
env_proxy_applies_to_url,
|
||||||
|
httpx2_env_proxy_mounts,
|
||||||
httpx_env_proxy_mounts,
|
httpx_env_proxy_mounts,
|
||||||
is_loopback_host,
|
is_loopback_host,
|
||||||
pin_resolved_url_dns,
|
pin_resolved_url_dns,
|
||||||
@@ -264,6 +267,17 @@ def test_env_proxy_helpers_respect_no_proxy(monkeypatch):
|
|||||||
assert any(transport is None for transport in mounts.values())
|
assert any(transport is None for transport in mounts.values())
|
||||||
assert any(transport is not None for transport in mounts.values())
|
assert any(transport is not None for transport in mounts.values())
|
||||||
|
|
||||||
|
httpx2_mounts = httpx2_env_proxy_mounts()
|
||||||
|
assert any(transport is None for transport in httpx2_mounts.values())
|
||||||
|
assert any(transport is not None for transport in httpx2_mounts.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_httpx_transports_share_global_dns_pin_lock():
|
||||||
|
assert (
|
||||||
|
Httpx2PinnedDNSAsyncTransport._resolver_lock
|
||||||
|
is PinnedDNSAsyncTransport._resolver_lock
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# contains_internal_url — shell command scanning
|
# contains_internal_url — shell command scanning
|
||||||
|
|||||||
@@ -73,23 +73,3 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
|
|||||||
|
|
||||||
assert manager.flush_all() == 2
|
assert manager.flush_all() == 2
|
||||||
assert set(saved) == {("test:active", True), ("test:other", True)}
|
assert set(saved) == {("test:active", True), ("test:other", True)}
|
||||||
|
|
||||||
|
|
||||||
def test_transient_session_never_reaches_store(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
|
||||||
session.add_message("user", "private")
|
|
||||||
|
|
||||||
manager.save(session, fsync=True)
|
|
||||||
|
|
||||||
assert manager.get_cached(session.key) is session
|
|
||||||
assert manager.read_session_file(session.key) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_transient_session_becomes_inactive_when_discarded(tmp_path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
|
||||||
|
|
||||||
assert manager.discard_transient(session.key) is True
|
|
||||||
assert manager.is_transient_active(session.key) is False
|
|
||||||
assert manager.get_cached(session.key) is None
|
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ from nanobot.agent import context as agent_context
|
|||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
|
||||||
from nanobot.agent.tools.exec_session import (
|
from nanobot.agent.tools.exec_session import (
|
||||||
MAX_OUTPUT_CHARS,
|
|
||||||
ExecSessionManager,
|
ExecSessionManager,
|
||||||
ListExecSessionsTool,
|
ListExecSessionsTool,
|
||||||
WriteStdinTool,
|
WriteStdinTool,
|
||||||
_BoundedOutputBuffer,
|
|
||||||
_SessionPoll,
|
|
||||||
_truncate_output,
|
|
||||||
)
|
)
|
||||||
from nanobot.agent.tools.registry import is_tool_error_result
|
from nanobot.agent.tools.registry import is_tool_error_result
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
@@ -147,134 +143,6 @@ def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
|
|||||||
assert "Exit code: 0" in result
|
assert "Exit code: 0" in result
|
||||||
|
|
||||||
|
|
||||||
def test_bounded_output_buffer_keeps_head_tail_and_exact_drop_count():
|
|
||||||
buffer = _BoundedOutputBuffer(10)
|
|
||||||
|
|
||||||
buffer.append("012345")
|
|
||||||
buffer.append("6789ABCDEF")
|
|
||||||
|
|
||||||
assert buffer.retained_chars == 10
|
|
||||||
assert buffer.drain() == ("01234BCDEF", 6)
|
|
||||||
assert buffer.retained_chars == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_session_bounds_unpolled_stdout_and_stderr(tmp_path):
|
|
||||||
async def run() -> tuple[int, int, str, int]:
|
|
||||||
manager = ExecSessionManager()
|
|
||||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
|
|
||||||
command = _python_command(
|
|
||||||
"import sys,time; time.sleep(0.05); "
|
|
||||||
"sys.stdout.write('OUT_HEAD' + 'o' * 200000 + 'OUT_TAIL'); "
|
|
||||||
"sys.stderr.write('ERR_HEAD' + 'e' * 200000 + 'ERR_TAIL')"
|
|
||||||
)
|
|
||||||
|
|
||||||
initial = await tool.execute(
|
|
||||||
command=command,
|
|
||||||
yield_time_ms=0,
|
|
||||||
max_output_chars=1000,
|
|
||||||
)
|
|
||||||
sid = _session_id(initial)
|
|
||||||
session = manager._sessions[sid]
|
|
||||||
await asyncio.wait_for(session.process.wait(), timeout=5)
|
|
||||||
await asyncio.wait_for(
|
|
||||||
asyncio.gather(session._stdout_task, session._stderr_task),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
retained_stdout = session._stdout.retained_chars
|
|
||||||
retained_stderr = session._stderr.retained_chars
|
|
||||||
poll = await manager.write(
|
|
||||||
session_id=sid,
|
|
||||||
chars=None,
|
|
||||||
close_stdin=False,
|
|
||||||
terminate=False,
|
|
||||||
yield_time_ms=0,
|
|
||||||
max_output_chars=1000,
|
|
||||||
)
|
|
||||||
return retained_stdout, retained_stderr, poll.output, poll.truncated_chars
|
|
||||||
|
|
||||||
retained_stdout, retained_stderr, output, truncated_chars = asyncio.run(run())
|
|
||||||
|
|
||||||
assert retained_stdout == 50000
|
|
||||||
assert retained_stderr == 50000
|
|
||||||
assert output.startswith("OUT_HEAD")
|
|
||||||
assert output.endswith("ERR_TAIL")
|
|
||||||
assert truncated_chars > 390000
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_stdin_wait_for_keeps_aggregate_within_output_budget():
|
|
||||||
async def run() -> str:
|
|
||||||
manager = SimpleNamespace(
|
|
||||||
write=AsyncMock(side_effect=[
|
|
||||||
_SessionPoll(output="HEAD" + "a" * 596, done=False, exit_code=None),
|
|
||||||
_SessionPoll(output="b" * 600, done=False, exit_code=None),
|
|
||||||
_SessionPoll(output="c" * 590 + "TARGET", done=False, exit_code=None),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
tool = WriteStdinTool(manager=manager)
|
|
||||||
return await tool._wait_for_output(
|
|
||||||
session_id="session",
|
|
||||||
chars=None,
|
|
||||||
close_stdin=False,
|
|
||||||
terminate=False,
|
|
||||||
wait_for="TARGET",
|
|
||||||
wait_timeout_ms=1000,
|
|
||||||
max_output_chars=1000,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = asyncio.run(run())
|
|
||||||
|
|
||||||
assert result.startswith("HEAD")
|
|
||||||
assert "TARGET" in result
|
|
||||||
assert "(796 chars truncated from output)" in result
|
|
||||||
assert len(result) < 1100
|
|
||||||
|
|
||||||
|
|
||||||
def test_write_stdin_wait_for_searches_before_response_truncation():
|
|
||||||
async def run() -> tuple[str, list[int]]:
|
|
||||||
output = "A" * 1500 + "TARGET" + "B" * 1500
|
|
||||||
observed_limits: list[int] = []
|
|
||||||
|
|
||||||
async def write(
|
|
||||||
*,
|
|
||||||
session_id: str,
|
|
||||||
chars: str | None,
|
|
||||||
close_stdin: bool,
|
|
||||||
terminate: bool,
|
|
||||||
yield_time_ms: int,
|
|
||||||
max_output_chars: int,
|
|
||||||
owner_session_key: str | None,
|
|
||||||
) -> _SessionPoll:
|
|
||||||
del session_id, chars, close_stdin, terminate, yield_time_ms, owner_session_key
|
|
||||||
observed_limits.append(max_output_chars)
|
|
||||||
visible, truncated = _truncate_output(output, max_output_chars)
|
|
||||||
return _SessionPoll(
|
|
||||||
output=visible,
|
|
||||||
done=True,
|
|
||||||
exit_code=0,
|
|
||||||
truncated_chars=truncated,
|
|
||||||
)
|
|
||||||
|
|
||||||
manager = SimpleNamespace(write=AsyncMock(side_effect=write))
|
|
||||||
tool = WriteStdinTool(manager=manager)
|
|
||||||
result = await tool._wait_for_output(
|
|
||||||
session_id="session",
|
|
||||||
chars=None,
|
|
||||||
close_stdin=False,
|
|
||||||
terminate=False,
|
|
||||||
wait_for="TARGET",
|
|
||||||
wait_timeout_ms=1000,
|
|
||||||
max_output_chars=1000,
|
|
||||||
)
|
|
||||||
return result, observed_limits
|
|
||||||
|
|
||||||
result, observed_limits = asyncio.run(run())
|
|
||||||
|
|
||||||
assert observed_limits == [MAX_OUTPUT_CHARS]
|
|
||||||
assert "Wait target not observed" not in result
|
|
||||||
assert "(2,006 chars truncated from output)" in result
|
|
||||||
assert len(result) < 1100
|
|
||||||
|
|
||||||
|
|
||||||
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
|
||||||
async def run() -> str:
|
async def run() -> str:
|
||||||
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx2 as httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import nanobot.agent.tools.mcp as mcp_mod
|
import nanobot.agent.tools.mcp as mcp_mod
|
||||||
@@ -52,7 +52,7 @@ class _FakeBlobResourceContents:
|
|||||||
class _FakeImageContent:
|
class _FakeImageContent:
|
||||||
def __init__(self, data: str, mime_type: str = "image/png") -> None:
|
def __init__(self, data: str, mime_type: str = "image/png") -> None:
|
||||||
self.data = data
|
self.data = data
|
||||||
self.mimeType = mime_type
|
self.mime_type = mime_type
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -111,7 +111,7 @@ def _fake_mcp_module(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _fake_streamable_http_client(_url: str, http_client=None):
|
async def _fake_streamable_http_client(_url: str, http_client=None):
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
mod.ClientSession = _FakeClientSession
|
mod.ClientSession = _FakeClientSession
|
||||||
mod.StdioServerParameters = _FakeStdioServerParameters
|
mod.StdioServerParameters = _FakeStdioServerParameters
|
||||||
@@ -133,12 +133,13 @@ def _fake_mcp_module(
|
|||||||
shared_mod = ModuleType("mcp.shared")
|
shared_mod = ModuleType("mcp.shared")
|
||||||
exc_mod = ModuleType("mcp.shared.exceptions")
|
exc_mod = ModuleType("mcp.shared.exceptions")
|
||||||
|
|
||||||
class _FakeMcpError(Exception):
|
class _FakeMCPError(Exception):
|
||||||
def __init__(self, code: int = -1, message: str = "error"):
|
def __init__(self, code: int = -1, message: str = "error"):
|
||||||
self.error = SimpleNamespace(code=code, message=message)
|
self.error = SimpleNamespace(code=code, message=message)
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
|
|
||||||
exc_mod.McpError = _FakeMcpError
|
mod.MCPError = _FakeMCPError
|
||||||
|
exc_mod.MCPError = _FakeMCPError
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
monkeypatch.setitem(sys.modules, "mcp.shared", shared_mod)
|
||||||
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
monkeypatch.setitem(sys.modules, "mcp.shared.exceptions", exc_mod)
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
|
return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout)
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ def test_wrapper_preserves_non_nullable_unions() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"value": {
|
"value": {
|
||||||
@@ -207,7 +208,7 @@ def test_wrapper_normalizes_nullable_property_type_union() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"type": ["string", "null"]},
|
"name": {"type": ["string", "null"]},
|
||||||
@@ -224,7 +225,7 @@ def test_wrapper_normalizes_nullable_property_anyof() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {
|
"name": {
|
||||||
@@ -249,7 +250,7 @@ def test_wrapper_hoists_recursive_local_refs_into_defs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="search_dataset",
|
name="search_dataset",
|
||||||
description="search tool",
|
description="search tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -282,7 +283,7 @@ def test_wrapper_hoists_root_self_ref_into_defs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="tree",
|
name="tree",
|
||||||
description="tree tool",
|
description="tree tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"children": {"type": "array", "items": {"$ref": "#"}},
|
"children": {"type": "array", "items": {"$ref": "#"}},
|
||||||
@@ -304,7 +305,7 @@ def test_wrapper_preserves_existing_defs_refs() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"$defs": {"value": {"type": "string"}},
|
"$defs": {"value": {"type": "string"}},
|
||||||
"properties": {"value": {"$ref": "#/$defs/value"}},
|
"properties": {"value": {"$ref": "#/$defs/value"}},
|
||||||
@@ -321,7 +322,7 @@ def test_wrapper_resolves_uri_encoded_json_pointer() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="demo tool",
|
description="demo tool",
|
||||||
inputSchema={
|
input_schema={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"space name/value": {"type": "string"},
|
"space name/value": {"type": "string"},
|
||||||
@@ -449,7 +450,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
|
|||||||
async def call_tool(_name: str, arguments: dict) -> object:
|
async def call_tool(_name: str, arguments: dict) -> object:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[_FakeTextContent("Error: server-side MCP failure")],
|
content=[_FakeTextContent("Error: server-side MCP failure")],
|
||||||
isError=True,
|
is_error=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
||||||
@@ -494,7 +495,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
|
|||||||
async def call_tool(_name: str, arguments: dict) -> object:
|
async def call_tool(_name: str, arguments: dict) -> object:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
content=[_FakeTextContent("Error: generated report successfully")],
|
content=[_FakeTextContent("Error: generated report successfully")],
|
||||||
isError=False,
|
is_error=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool))
|
||||||
@@ -622,7 +623,7 @@ def _make_tool_def(name: str) -> SimpleNamespace:
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
name=name,
|
name=name,
|
||||||
description=f"{name} tool",
|
description=f"{name} tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -936,7 +937,7 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||||
assert http_client is not None
|
assert http_client is not None
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||||
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||||
@@ -944,11 +945,11 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
|
|||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
"nanobot.security.network.httpx2.AsyncHTTPTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(
|
lambda **_kwargs: httpx.MockTransport(
|
||||||
lambda request: httpx.Response(200, request=request)
|
lambda request: httpx.Response(200, request=request)
|
||||||
),
|
),
|
||||||
@@ -976,11 +977,11 @@ def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
|
|||||||
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
|
monkeypatch.setenv("NO_PROXY", "mcp.example.com")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.security.network.httpx.AsyncHTTPTransport",
|
"nanobot.security.network.httpx2.AsyncHTTPTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(
|
lambda **_kwargs: httpx.MockTransport(
|
||||||
lambda request: httpx.Response(200, request=request)
|
lambda request: httpx.Response(200, request=request)
|
||||||
),
|
),
|
||||||
@@ -1050,13 +1051,15 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
assert http_client is not None
|
assert http_client is not None
|
||||||
used_transports.append("streamableHttp")
|
used_transports.append("streamableHttp")
|
||||||
await http_client.get("https://example.com/start")
|
await http_client.get("https://example.com/start")
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
|
# Keep the redirect exercise isolated from host-level proxy settings.
|
||||||
|
monkeypatch.setattr(mcp_mod, "httpx2_env_proxy_mounts", lambda: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda **_kwargs: httpx.MockTransport(_handler),
|
lambda **_kwargs: httpx.MockTransport(_handler),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
|
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", _async_client_with_mock_transport)
|
||||||
@@ -1138,13 +1141,13 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
async def _capturing_streamable_http_client(_url: str, http_client=None):
|
||||||
captured["timeout"] = http_client.timeout
|
captured["timeout"] = http_client.timeout
|
||||||
yield object(), object(), object()
|
yield object(), object()
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
|
||||||
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
mcp_mod,
|
mcp_mod,
|
||||||
"PinnedDNSAsyncTransport",
|
"Httpx2PinnedDNSAsyncTransport",
|
||||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -1385,10 +1388,10 @@ async def test_prompt_wrapper_execute_handles_timeout() -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
async def test_prompt_wrapper_execute_handles_mcp_error() -> None:
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp import MCPError
|
||||||
|
|
||||||
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
async def get_prompt(name: str, arguments: dict | None = None) -> object:
|
||||||
raise McpError(code=42, message="invalid argument")
|
raise MCPError(code=42, message="invalid argument")
|
||||||
|
|
||||||
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
wrapper = _make_prompt_wrapper(SimpleNamespace(get_prompt=get_prompt))
|
||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
@@ -1510,7 +1513,7 @@ def test_tool_wrapper_sanitizes_name() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="My Tool",
|
name="My Tool",
|
||||||
description="tool with spaces",
|
description="tool with spaces",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||||
assert wrapper.name == "mcp_srv_My_Tool"
|
assert wrapper.name == "mcp_srv_My_Tool"
|
||||||
@@ -1541,7 +1544,7 @@ def test_tool_wrapper_preserves_original_name_for_mcp_call() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="My Tool",
|
name="My Tool",
|
||||||
description="tool with spaces",
|
description="tool with spaces",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "srv", tool_def)
|
||||||
# The sanitized API-facing name differs from the original MCP name
|
# The sanitized API-facing name differs from the original MCP name
|
||||||
@@ -1619,12 +1622,12 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
|
|||||||
tool_def = SimpleNamespace(
|
tool_def = SimpleNamespace(
|
||||||
name="search",
|
name="search",
|
||||||
description="search tool",
|
description="search tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
other_tool_def = SimpleNamespace(
|
other_tool_def = SimpleNamespace(
|
||||||
name="search",
|
name="search",
|
||||||
description="other search tool",
|
description="other search tool",
|
||||||
inputSchema={"type": "object", "properties": {}},
|
input_schema={"type": "object", "properties": {}},
|
||||||
)
|
)
|
||||||
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
|
wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), server_name, tool_def)
|
||||||
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)
|
other_wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "other", other_tool_def)
|
||||||
|
|||||||
@@ -310,25 +310,3 @@ class TestNestedRepoProtection:
|
|||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
assert not (workspace / ".git").exists()
|
assert not (workspace / ".git").exists()
|
||||||
|
|
||||||
|
|
||||||
class TestCommitIdEncoding:
|
|
||||||
"""Commit ids must be usable with git, not hex-of-hex."""
|
|
||||||
|
|
||||||
def test_auto_commit_returns_the_real_short_sha(self, git, tmp_path):
|
|
||||||
(tmp_path / "MEMORY.md").write_text("- a fact\n", encoding="utf-8")
|
|
||||||
sha = git.auto_commit("memory update")
|
|
||||||
expected = subprocess.run(
|
|
||||||
["git", "-C", str(tmp_path), "log", "-1", "--format=%h", "--abbrev=8"],
|
|
||||||
capture_output=True, text=True, check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
assert sha == expected
|
|
||||||
|
|
||||||
def test_a_real_git_sha_resolves(self, git, tmp_path):
|
|
||||||
(tmp_path / "MEMORY.md").write_text("- a fact\n", encoding="utf-8")
|
|
||||||
git.auto_commit("memory update")
|
|
||||||
real = subprocess.run(
|
|
||||||
["git", "-C", str(tmp_path), "log", "-1", "--format=%h", "--abbrev=8"],
|
|
||||||
capture_output=True, text=True, check=True,
|
|
||||||
).stdout.strip()
|
|
||||||
assert git._resolve_sha(real) is not None
|
|
||||||
|
|||||||
@@ -696,42 +696,6 @@ def test_replay_preserves_local_trigger_source_metadata(tmp_path, monkeypatch) -
|
|||||||
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
|
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
|
||||||
|
|
||||||
|
|
||||||
def test_replay_preserves_automation_source_metadata_on_streamed_reply(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
key = "websocket:t-streamed-cron-source"
|
|
||||||
source = {"kind": "cron", "label": "Repo check"}
|
|
||||||
|
|
||||||
for record in (
|
|
||||||
{
|
|
||||||
"event": "delta",
|
|
||||||
"chat_id": "t-streamed-cron-source",
|
|
||||||
"text": "Repo ",
|
|
||||||
"source": source,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "delta",
|
|
||||||
"chat_id": "t-streamed-cron-source",
|
|
||||||
"text": "clean.",
|
|
||||||
"source": source,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"event": "stream_end",
|
|
||||||
"chat_id": "t-streamed-cron-source",
|
|
||||||
"source": source,
|
|
||||||
},
|
|
||||||
{"event": "turn_end", "chat_id": "t-streamed-cron-source"},
|
|
||||||
):
|
|
||||||
append_transcript_object(key, record)
|
|
||||||
|
|
||||||
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
|
||||||
|
|
||||||
assert msgs[0]["content"] == "Repo clean."
|
|
||||||
assert msgs[0]["source"] == source
|
|
||||||
|
|
||||||
|
|
||||||
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
|
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t-trigger-source"
|
key = "websocket:t-trigger-source"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import pytest
|
|||||||
|
|
||||||
import nanobot.webui.session_list_index as session_list_index
|
import nanobot.webui.session_list_index as session_list_index
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
from nanobot.providers.base import ProviderConversationState
|
|
||||||
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -86,26 +85,6 @@ def test_webui_session_list_rescans_only_changed_file(tmp_path: Path, monkeypatc
|
|||||||
assert {row["preview"] for row in rows} == {"first", "second after"}
|
assert {row["preview"] for row in rows} == {"first", "second after"}
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_skips_provider_state_before_preview_budget(
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr(session_list_index, "_SESSION_LIST_PREVIEW_MAX_CHARS", 100)
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
session = manager.get_or_create("websocket:private-state")
|
|
||||||
session.provider_state = ProviderConversationState(
|
|
||||||
kind="openai_responses",
|
|
||||||
provider="openai:test",
|
|
||||||
model="test-model",
|
|
||||||
version=1,
|
|
||||||
payload={"items": [{"encrypted_content": "x" * 200}]},
|
|
||||||
)
|
|
||||||
session.add_message("user", "visible preview")
|
|
||||||
manager.save(session)
|
|
||||||
|
|
||||||
assert list_webui_sessions(manager)[0]["preview"] == "visible preview"
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:deleted")
|
session = manager.get_or_create("websocket:deleted")
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@@ -15,60 +14,6 @@ from nanobot.webui.token_usage import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_state(tmp_path, days: dict) -> None:
|
|
||||||
state_dir = tmp_path / "webui"
|
|
||||||
state_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
(state_dir / "token-usage.json").write_text(
|
|
||||||
json.dumps({"days": days}), encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_payload_tolerates_malformed_persisted_day_keys(tmp_path, monkeypatch) -> None:
|
|
||||||
"""Day keys that are not real dates must not break settings payloads.
|
|
||||||
|
|
||||||
normalize_token_usage_state only length-checks day keys, so a hand-edited
|
|
||||||
10-char key survives reads and atomic rewrites; token_usage_payload then
|
|
||||||
parsed it with an unguarded fromisoformat, failing every /api/settings and
|
|
||||||
/api/settings/usage request until the file was fixed by hand.
|
|
||||||
"""
|
|
||||||
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
|
|
||||||
_write_state(tmp_path, {
|
|
||||||
"not-a-dat3": {"total_tokens": 7, "requests": 1},
|
|
||||||
"2026-13-01": {"total_tokens": 9, "requests": 1},
|
|
||||||
"2026-06-02": {"total_tokens": 5, "requests": 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
payload = token_usage_payload(
|
|
||||||
timezone_name="UTC",
|
|
||||||
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["total_tokens"] == 5
|
|
||||||
assert payload["total_tokens_30d"] == 5
|
|
||||||
assert payload["requests_30d"] == 1
|
|
||||||
assert payload["active_days_30d"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_record_scrubs_malformed_day_keys(tmp_path, monkeypatch) -> None:
|
|
||||||
"""Rewrites drop malformed day keys instead of persisting them forever."""
|
|
||||||
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
|
|
||||||
_write_state(tmp_path, {
|
|
||||||
"not-a-dat3": {"total_tokens": 7, "requests": 1},
|
|
||||||
"2026-06-02": {"total_tokens": 5, "requests": 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
record_token_usage(
|
|
||||||
{"prompt_tokens": 1, "completion_tokens": 1},
|
|
||||||
timezone_name="UTC",
|
|
||||||
now=datetime(2026, 6, 3, 12, 0, tzinfo=timezone.utc),
|
|
||||||
)
|
|
||||||
|
|
||||||
raw = json.loads((tmp_path / "webui" / "token-usage.json").read_text(encoding="utf-8"))
|
|
||||||
assert "not-a-dat3" not in raw["days"]
|
|
||||||
assert "2026-06-02" in raw["days"]
|
|
||||||
assert "2026-06-03" in raw["days"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None:
|
def test_record_token_usage_aggregates_by_local_day(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
|
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
|
|
||||||
|
|||||||
+15
-150
@@ -8,7 +8,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Ghost, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { Sidebar } from "@/components/Sidebar";
|
import { Sidebar } from "@/components/Sidebar";
|
||||||
@@ -37,13 +37,6 @@ import {
|
|||||||
import { displayTitle } from "@/lib/chat-groups";
|
import { displayTitle } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import {
|
|
||||||
createTemporaryChatSession,
|
|
||||||
isQuickChatKey,
|
|
||||||
QUICK_CHAT_ID,
|
|
||||||
QUICK_CHAT_KEY,
|
|
||||||
quickChatSession,
|
|
||||||
} from "@/lib/quick-chat";
|
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
BootstrapResponse,
|
BootstrapResponse,
|
||||||
@@ -232,9 +225,6 @@ function readShellRoute(): ShellRoute {
|
|||||||
if (path === "/skills") {
|
if (path === "/skills") {
|
||||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||||
}
|
}
|
||||||
if (path === "/quick-chat") {
|
|
||||||
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
|
|
||||||
}
|
|
||||||
if (path.startsWith("/chat/")) {
|
if (path.startsWith("/chat/")) {
|
||||||
const encoded = path.slice("/chat/".length);
|
const encoded = path.slice("/chat/".length);
|
||||||
try {
|
try {
|
||||||
@@ -251,7 +241,6 @@ function readShellRoute(): ShellRoute {
|
|||||||
|
|
||||||
function shellRouteHash(route: ShellRoute): string {
|
function shellRouteHash(route: ShellRoute): string {
|
||||||
if (route.view === "chat") {
|
if (route.view === "chat") {
|
||||||
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
|
|
||||||
return route.activeKey
|
return route.activeKey
|
||||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||||
: "#/new";
|
: "#/new";
|
||||||
@@ -958,24 +947,14 @@ function Shell({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
getSessionAutomations,
|
getSessionAutomations,
|
||||||
} = useSessions();
|
} = useSessions();
|
||||||
const regularSessions = useMemo(
|
|
||||||
() => sessions.filter((session) => !isQuickChatKey(session.key)),
|
|
||||||
[sessions],
|
|
||||||
);
|
|
||||||
const quickSession = useMemo(
|
|
||||||
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
|
|
||||||
[sessions],
|
|
||||||
);
|
|
||||||
const { state: sidebarState, update: updateSidebarState } =
|
const { state: sidebarState, update: updateSidebarState } =
|
||||||
useSidebarState(regularSessions, !loading);
|
useSidebarState(sessions, !loading);
|
||||||
const initialRouteRef = useRef<ShellRoute | null>(null);
|
const initialRouteRef = useRef<ShellRoute | null>(null);
|
||||||
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
|
||||||
const [activeKey, setActiveKey] = useState<string | null>(
|
const [activeKey, setActiveKey] = useState<string | null>(
|
||||||
initialRouteRef.current.activeKey,
|
initialRouteRef.current.activeKey,
|
||||||
);
|
);
|
||||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||||
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
|
|
||||||
const temporarySessionRef = useRef<ChatSummary | null>(null);
|
|
||||||
const [settingsInitialSection, setSettingsInitialSection] =
|
const [settingsInitialSection, setSettingsInitialSection] =
|
||||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||||
@@ -1025,33 +1004,19 @@ function Shell({
|
|||||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||||
const showMainSidebar = view !== "settings";
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
const discardTemporaryChat = useCallback(() => {
|
|
||||||
const current = temporarySessionRef.current;
|
|
||||||
if (!current) return;
|
|
||||||
temporarySessionRef.current = null;
|
|
||||||
client.discardTemporaryChat(current.chatId);
|
|
||||||
setTemporarySession(null);
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||||
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
|
||||||
discardTemporaryChat();
|
|
||||||
}
|
|
||||||
setActiveKey(route.activeKey);
|
setActiveKey(route.activeKey);
|
||||||
setView(route.view);
|
setView(route.view);
|
||||||
setSettingsInitialSection(route.settingsSection);
|
setSettingsInitialSection(route.settingsSection);
|
||||||
writeShellRoute(route, options?.replace);
|
writeShellRoute(route, options?.replace);
|
||||||
},
|
},
|
||||||
[discardTemporaryChat],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyRoute = () => {
|
const applyRoute = () => {
|
||||||
const route = readShellRoute();
|
const route = readShellRoute();
|
||||||
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
|
|
||||||
discardTemporaryChat();
|
|
||||||
}
|
|
||||||
setActiveKey(route.activeKey);
|
setActiveKey(route.activeKey);
|
||||||
setView(route.view);
|
setView(route.view);
|
||||||
setSettingsInitialSection(route.settingsSection);
|
setSettingsInitialSection(route.settingsSection);
|
||||||
@@ -1062,15 +1027,7 @@ function Shell({
|
|||||||
};
|
};
|
||||||
window.addEventListener("hashchange", applyRoute);
|
window.addEventListener("hashchange", applyRoute);
|
||||||
return () => window.removeEventListener("hashchange", applyRoute);
|
return () => window.removeEventListener("hashchange", applyRoute);
|
||||||
}, [discardTemporaryChat]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return client.onStatus((status) => {
|
|
||||||
if (status !== "open") discardTemporaryChat();
|
|
||||||
});
|
|
||||||
}, [client, discardTemporaryChat]);
|
|
||||||
|
|
||||||
useEffect(() => () => discardTemporaryChat(), [discardTemporaryChat]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1157,11 +1114,8 @@ function Shell({
|
|||||||
|
|
||||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (isQuickChatKey(activeKey)) return temporarySession ?? quickSession;
|
|
||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey, quickSession, temporarySession]);
|
}, [sessions, activeKey]);
|
||||||
const quickChatActive = isQuickChatKey(activeKey);
|
|
||||||
const temporaryChatActive = quickChatActive && temporarySession !== null;
|
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
@@ -1176,12 +1130,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [activeChatId]);
|
}, [activeChatId]);
|
||||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||||
if (temporaryChatActive) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (quickChatActive) {
|
|
||||||
return workspaces?.default_scope ?? null;
|
|
||||||
}
|
|
||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
}
|
}
|
||||||
@@ -1193,8 +1141,6 @@ function Shell({
|
|||||||
activeChatId,
|
activeChatId,
|
||||||
activeSession?.workspaceScope,
|
activeSession?.workspaceScope,
|
||||||
draftWorkspaceScope,
|
draftWorkspaceScope,
|
||||||
quickChatActive,
|
|
||||||
temporaryChatActive,
|
|
||||||
workspaceOverrides,
|
workspaceOverrides,
|
||||||
workspaces?.default_scope,
|
workspaces?.default_scope,
|
||||||
]);
|
]);
|
||||||
@@ -1215,10 +1161,7 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const knownChatIds = new Set([
|
const knownChatIds = new Set(sessions.map((session) => session.chatId));
|
||||||
QUICK_CHAT_ID,
|
|
||||||
...sessions.map((session) => session.chatId),
|
|
||||||
]);
|
|
||||||
setUpdatedChatIds((current) => {
|
setUpdatedChatIds((current) => {
|
||||||
const next = new Set(
|
const next = new Set(
|
||||||
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
|
||||||
@@ -1233,7 +1176,6 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !activeKey) return;
|
if (loading || !activeKey) return;
|
||||||
if (isQuickChatKey(activeKey)) return;
|
|
||||||
if (sessions.some((session) => session.key === activeKey)) return;
|
if (sessions.some((session) => session.key === activeKey)) return;
|
||||||
const currentRoute = readShellRoute();
|
const currentRoute = readShellRoute();
|
||||||
navigate(
|
navigate(
|
||||||
@@ -1475,28 +1417,6 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const onOpenQuickChat = useCallback(() => {
|
|
||||||
setDraftWorkspaceScope(null);
|
|
||||||
setWorkspaceError(null);
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
navigate({
|
|
||||||
view: "chat",
|
|
||||||
activeKey: QUICK_CHAT_KEY,
|
|
||||||
settingsSection: "overview",
|
|
||||||
});
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}, [navigate]);
|
|
||||||
|
|
||||||
const onToggleTemporaryChat = useCallback(() => {
|
|
||||||
if (temporarySessionRef.current) {
|
|
||||||
discardTemporaryChat();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const session = createTemporaryChatSession();
|
|
||||||
temporarySessionRef.current = session;
|
|
||||||
setTemporarySession(session);
|
|
||||||
}, [discardTemporaryChat]);
|
|
||||||
|
|
||||||
const onNewChatInProject = useCallback(
|
const onNewChatInProject = useCallback(
|
||||||
(projectPath: string, projectName: string) => {
|
(projectPath: string, projectName: string) => {
|
||||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||||
@@ -1762,7 +1682,6 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (isQuickChatKey(activeKey)) return activeKey;
|
|
||||||
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
if (sessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
return sessions[0]?.key ?? null;
|
return sessions[0]?.key ?? null;
|
||||||
})();
|
})();
|
||||||
@@ -1854,10 +1773,7 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useDeferredTitleRefresh(
|
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||||
quickChatActive ? null : activeSession,
|
|
||||||
refresh,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@@ -1947,39 +1863,11 @@ function Shell({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const headerTitle = temporaryChatActive
|
const headerTitle = activeSession
|
||||||
? t("quickChat.temporary.title")
|
|
||||||
: quickChatActive
|
|
||||||
? t("sidebar.quickChat")
|
|
||||||
: activeSession
|
|
||||||
? sidebarState.title_overrides[activeSession.key] ||
|
? sidebarState.title_overrides[activeSession.key] ||
|
||||||
activeSession.title ||
|
activeSession.title ||
|
||||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||||
: t("app.brand");
|
: t("app.brand");
|
||||||
|
|
||||||
const temporaryChatAction = quickChatActive ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-pressed={temporaryChatActive}
|
|
||||||
aria-label={
|
|
||||||
temporaryChatActive
|
|
||||||
? t("quickChat.temporary.exit")
|
|
||||||
: t("quickChat.temporary.enter")
|
|
||||||
}
|
|
||||||
onClick={onToggleTemporaryChat}
|
|
||||||
className={cn(
|
|
||||||
"host-no-drag h-8 rounded-full px-2.5 text-xs text-muted-foreground",
|
|
||||||
temporaryChatActive && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Ghost className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
{temporaryChatActive
|
|
||||||
? t("quickChat.temporary.active")
|
|
||||||
: t("quickChat.temporary.enter")}
|
|
||||||
</Button>
|
|
||||||
) : undefined;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view === "settings") {
|
if (view === "settings") {
|
||||||
@@ -2012,12 +1900,9 @@ function Shell({
|
|||||||
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
|
||||||
|
|
||||||
const sidebarProps = {
|
const sidebarProps = {
|
||||||
sessions: regularSessions,
|
sessions,
|
||||||
activeKey: view === "chat" ? activeKey : null,
|
activeKey,
|
||||||
loading,
|
loading,
|
||||||
quickChatActive: view === "chat" && quickChatActive,
|
|
||||||
newChatActive: view === "chat" && activeKey === null,
|
|
||||||
onOpenQuickChat,
|
|
||||||
onNewChat,
|
onNewChat,
|
||||||
onSelect: onSelectChat,
|
onSelect: onSelectChat,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
@@ -2180,7 +2065,7 @@ function Shell({
|
|||||||
<SessionSearchDialog
|
<SessionSearchDialog
|
||||||
open
|
open
|
||||||
onOpenChange={setSessionSearchOpen}
|
onOpenChange={setSessionSearchOpen}
|
||||||
sessions={regularSessions}
|
sessions={sessions}
|
||||||
activeKey={activeKey}
|
activeKey={activeKey}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
titleOverrides={sidebarState.title_overrides}
|
titleOverrides={sidebarState.title_overrides}
|
||||||
@@ -2205,7 +2090,7 @@ function Shell({
|
|||||||
onToggleSidebar={toggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onCreateChat={onCreateChat}
|
onCreateChat={onCreateChat}
|
||||||
onForkChat={quickChatActive ? undefined : onForkChat}
|
onForkChat={onForkChat}
|
||||||
onTurnEnd={onTurnEnd}
|
onTurnEnd={onTurnEnd}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
@@ -2213,34 +2098,14 @@ function Shell({
|
|||||||
hostChromeTitleInset={hostSidebarCollapsed}
|
hostChromeTitleInset={hostSidebarCollapsed}
|
||||||
hideHeader={false}
|
hideHeader={false}
|
||||||
workspaceScope={activeWorkspaceScope}
|
workspaceScope={activeWorkspaceScope}
|
||||||
workspaceDefaultScope={
|
workspaceDefaultScope={workspaces?.default_scope ?? null}
|
||||||
temporaryChatActive ? null : workspaces?.default_scope ?? null
|
workspaceControls={workspaces?.controls ?? null}
|
||||||
}
|
|
||||||
workspaceControls={
|
|
||||||
quickChatActive ? null : (workspaces?.controls ?? null)
|
|
||||||
}
|
|
||||||
workspaceScopeDisabled={activeChatRunning}
|
workspaceScopeDisabled={activeChatRunning}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
settingsSnapshot={settingsSnapshot}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
allowConversationReset={!quickChatActive}
|
|
||||||
showSessionInfo={!quickChatActive}
|
|
||||||
emptyStateGreeting={
|
|
||||||
temporaryChatActive
|
|
||||||
? t("quickChat.temporary.greeting")
|
|
||||||
: quickChatActive
|
|
||||||
? t("quickChat.greeting")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
emptyStateDescription={
|
|
||||||
temporaryChatActive
|
|
||||||
? t("quickChat.temporary.description")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
temporary={temporaryChatActive}
|
|
||||||
headerAction={temporaryChatAction}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@@ -24,10 +25,6 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
SidebarSelectionHighlight,
|
|
||||||
} from "@/components/SidebarSelectionHighlight";
|
|
||||||
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
COLLAPSED_CHATS_VISIBLE_COUNT,
|
COLLAPSED_CHATS_VISIBLE_COUNT,
|
||||||
@@ -107,7 +104,11 @@ export const ChatList = memo(function ChatList({
|
|||||||
}: ChatListProps) {
|
}: ChatListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
|
||||||
|
const listContentRef = useRef<HTMLDivElement>(null);
|
||||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeHighlightRef = useRef<HTMLDivElement>(null);
|
||||||
|
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const highlightVisibleRef = useRef(false);
|
||||||
const labels = useMemo<ChatGroupLabels>(() => ({
|
const labels = useMemo<ChatGroupLabels>(() => ({
|
||||||
pinned: t("chat.groups.pinned"),
|
pinned: t("chat.groups.pinned"),
|
||||||
all: t("chat.groups.all"),
|
all: t("chat.groups.all"),
|
||||||
@@ -162,6 +163,74 @@ export const ChatList = memo(function ChatList({
|
|||||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||||
}, [showArchived, sort]);
|
}, [showArchived, sort]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let resetTransitionFrame: number | null = null;
|
||||||
|
|
||||||
|
const updateHighlight = () => {
|
||||||
|
const content = listContentRef.current;
|
||||||
|
const row = activeRowRef.current;
|
||||||
|
const highlight = activeHighlightRef.current;
|
||||||
|
const surface = activeHighlightSurfaceRef.current;
|
||||||
|
|
||||||
|
if (!highlight || !surface) return;
|
||||||
|
if (!content || !row) {
|
||||||
|
surface.style.opacity = "0";
|
||||||
|
surface.style.transform = "scale(0.97)";
|
||||||
|
highlightVisibleRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldFloatIn = !highlightVisibleRef.current;
|
||||||
|
if (shouldFloatIn) {
|
||||||
|
highlight.style.transitionProperty = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentRect = content.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
highlight.style.width = `${rowRect.width}px`;
|
||||||
|
highlight.style.height = `${rowRect.height}px`;
|
||||||
|
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
|
||||||
|
rowRect.top - contentRect.top
|
||||||
|
}px, 0)`;
|
||||||
|
|
||||||
|
if (shouldFloatIn) {
|
||||||
|
void highlight.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
surface.style.opacity = "1";
|
||||||
|
surface.style.transform = "scale(1)";
|
||||||
|
highlightVisibleRef.current = true;
|
||||||
|
|
||||||
|
if (shouldFloatIn) {
|
||||||
|
resetTransitionFrame = window.requestAnimationFrame(() => {
|
||||||
|
highlight.style.removeProperty("transition-property");
|
||||||
|
resetTransitionFrame = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateHighlight();
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === "undefined"
|
||||||
|
? null
|
||||||
|
: new ResizeObserver(updateHighlight);
|
||||||
|
if (resizeObserver) {
|
||||||
|
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
|
||||||
|
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", updateHighlight);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (resetTransitionFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(resetTransitionFrame);
|
||||||
|
}
|
||||||
|
activeHighlightRef.current?.style.removeProperty("transition-property");
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
window.removeEventListener("resize", updateHighlight);
|
||||||
|
};
|
||||||
|
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
|
||||||
|
|
||||||
if (loading && sessions.length === 0) {
|
if (loading && sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||||
@@ -187,10 +256,8 @@ export const ChatList = memo(function ChatList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
|
||||||
<SidebarSelectionHighlight
|
<div
|
||||||
targetRef={activeRowRef}
|
ref={listContentRef}
|
||||||
activeId={activeKey}
|
|
||||||
scope="sessions"
|
|
||||||
data-chat-list-content
|
data-chat-list-content
|
||||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||||
>
|
>
|
||||||
@@ -266,8 +333,7 @@ export const ChatList = memo(function ChatList({
|
|||||||
ref={active ? activeRowRef : undefined}
|
ref={active ? activeRowRef : undefined}
|
||||||
data-chat-row={s.key}
|
data-chat-row={s.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
compact ? "min-h-7" : "min-h-8",
|
compact ? "min-h-7" : "min-h-8",
|
||||||
active
|
active
|
||||||
? "text-sidebar-accent-foreground"
|
? "text-sidebar-accent-foreground"
|
||||||
@@ -409,7 +475,19 @@ export const ChatList = memo(function ChatList({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarSelectionHighlight>
|
<div
|
||||||
|
ref={activeHighlightRef}
|
||||||
|
data-testid="active-chat-highlight"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={activeHighlightSurfaceRef}
|
||||||
|
data-testid="active-chat-highlight-surface"
|
||||||
|
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import {
|
import { useState, type ReactNode } from "react";
|
||||||
type ReactNode,
|
|
||||||
type RefObject,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
Brain,
|
Brain,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
MessageCircle,
|
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -19,10 +13,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ChatList } from "@/components/ChatList";
|
import { ChatList } from "@/components/ChatList";
|
||||||
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
import { ConnectionBadge } from "@/components/ConnectionBadge";
|
||||||
import {
|
|
||||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
|
||||||
SidebarSelectionHighlight,
|
|
||||||
} from "@/components/SidebarSelectionHighlight";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@@ -34,9 +24,6 @@ interface SidebarProps {
|
|||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
quickChatActive: boolean;
|
|
||||||
newChatActive: boolean;
|
|
||||||
onOpenQuickChat: () => void;
|
|
||||||
onNewChat: () => void;
|
onNewChat: () => void;
|
||||||
onSelect: (key: string) => void;
|
onSelect: (key: string) => void;
|
||||||
onRequestDelete: (key: string, label: string) => void;
|
onRequestDelete: (key: string, label: string) => void;
|
||||||
@@ -95,14 +82,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
const collapsed = Boolean(props.collapsed);
|
const collapsed = Boolean(props.collapsed);
|
||||||
const toggleLabel = t("thread.header.toggleSidebar");
|
const toggleLabel = t("thread.header.toggleSidebar");
|
||||||
const newChatShortcut = newChatShortcutLabel();
|
const newChatShortcut = newChatShortcutLabel();
|
||||||
const activeActionRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const activeActionId = props.quickChatActive
|
|
||||||
? "quick-chat"
|
|
||||||
: props.newChatActive
|
|
||||||
? "new-chat"
|
|
||||||
: props.activeUtility
|
|
||||||
? `utility:${props.activeUtility}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -154,29 +133,16 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarSelectionHighlight
|
<div
|
||||||
targetRef={activeActionRef}
|
|
||||||
activeId={activeActionId}
|
|
||||||
scope="actions"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative space-y-1.5 px-2 pb-2",
|
"space-y-1.5 px-2 pb-2",
|
||||||
collapsed && "flex w-14 flex-col items-center px-0",
|
collapsed && "flex w-14 flex-col items-center px-0",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarActionButton
|
|
||||||
collapsed={collapsed}
|
|
||||||
label={t("sidebar.quickChat")}
|
|
||||||
onClick={props.onOpenQuickChat}
|
|
||||||
active={props.quickChatActive}
|
|
||||||
selectionRef={activeActionRef}
|
|
||||||
icon={<MessageCircle className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label={t("sidebar.newChat")}
|
label={t("sidebar.newChat")}
|
||||||
onClick={props.onNewChat}
|
onClick={props.onNewChat}
|
||||||
active={props.newChatActive}
|
|
||||||
selectionRef={activeActionRef}
|
|
||||||
icon={<SquarePen className="h-4 w-4" />}
|
icon={<SquarePen className="h-4 w-4" />}
|
||||||
shortcut={newChatShortcut}
|
shortcut={newChatShortcut}
|
||||||
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
|
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
|
||||||
@@ -193,7 +159,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenApps}
|
onClick={props.onOpenApps}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "apps"}
|
active={props.activeUtility === "apps"}
|
||||||
selectionRef={activeActionRef}
|
|
||||||
icon={<Blocks className="h-4 w-4" />}
|
icon={<Blocks className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
@@ -202,7 +167,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenSkills}
|
onClick={props.onOpenSkills}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "skills"}
|
active={props.activeUtility === "skills"}
|
||||||
selectionRef={activeActionRef}
|
|
||||||
icon={<Brain className="h-4 w-4" />}
|
icon={<Brain className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
@@ -211,7 +175,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
onClick={props.onOpenAutomations}
|
onClick={props.onOpenAutomations}
|
||||||
onIntent={props.onSettingsIntent}
|
onIntent={props.onSettingsIntent}
|
||||||
active={props.activeUtility === "automations"}
|
active={props.activeUtility === "automations"}
|
||||||
selectionRef={activeActionRef}
|
|
||||||
icon={<CalendarClock className="h-4 w-4" />}
|
icon={<CalendarClock className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
{props.archivedCount ? (
|
{props.archivedCount ? (
|
||||||
@@ -222,7 +185,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
icon={<Archive className="h-4 w-4" />}
|
icon={<Archive className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SidebarSelectionHighlight>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
|
||||||
@@ -292,7 +255,6 @@ function SidebarActionButton({
|
|||||||
shortcut,
|
shortcut,
|
||||||
ariaKeyShortcuts,
|
ariaKeyShortcuts,
|
||||||
onIntent,
|
onIntent,
|
||||||
selectionRef,
|
|
||||||
}: {
|
}: {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -303,15 +265,13 @@ function SidebarActionButton({
|
|||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
ariaKeyShortcuts?: string;
|
ariaKeyShortcuts?: string;
|
||||||
onIntent?: () => void;
|
onIntent?: () => void;
|
||||||
selectionRef?: RefObject<HTMLButtonElement>;
|
|
||||||
}) {
|
}) {
|
||||||
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
ref={active ? selectionRef : undefined}
|
|
||||||
type="button"
|
type="button"
|
||||||
variant={null}
|
variant="ghost"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
aria-keyshortcuts={ariaKeyShortcuts}
|
aria-keyshortcuts={ariaKeyShortcuts}
|
||||||
@@ -320,14 +280,12 @@ function SidebarActionButton({
|
|||||||
onFocus={onIntent}
|
onFocus={onIntent}
|
||||||
onPointerEnter={onIntent}
|
onPointerEnter={onIntent}
|
||||||
className={cn(
|
className={cn(
|
||||||
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
|
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||||
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
|
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||||
collapsed
|
collapsed
|
||||||
? "w-9 justify-center gap-0 px-0"
|
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||||
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
||||||
active
|
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
||||||
? "text-sidebar-accent-foreground"
|
|
||||||
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import {
|
|
||||||
type HTMLAttributes,
|
|
||||||
type RefObject,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> {
|
|
||||||
targetRef: RefObject<HTMLElement>;
|
|
||||||
activeId: string | null;
|
|
||||||
scope: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SIDEBAR_SELECTION_ITEM_CLASS =
|
|
||||||
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
|
|
||||||
|
|
||||||
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
|
|
||||||
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
|
|
||||||
|
|
||||||
export function SidebarSelectionHighlight({
|
|
||||||
targetRef,
|
|
||||||
activeId,
|
|
||||||
scope,
|
|
||||||
children,
|
|
||||||
...containerProps
|
|
||||||
}: SidebarSelectionHighlightProps) {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const highlightRef = useRef<HTMLDivElement>(null);
|
|
||||||
const positionedRef = useRef(false);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const highlight = highlightRef.current;
|
|
||||||
const container = containerRef.current;
|
|
||||||
const target = targetRef.current;
|
|
||||||
if (!highlight) return;
|
|
||||||
if (!activeId || !container || !target) {
|
|
||||||
highlight.style.opacity = "0";
|
|
||||||
positionedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let restoreTransitionFrame: number | null = null;
|
|
||||||
|
|
||||||
const position = () => {
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
|
||||||
if (targetRect.width === 0 || targetRect.height === 0) {
|
|
||||||
highlight.style.opacity = "0";
|
|
||||||
positionedRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstPosition = !positionedRef.current;
|
|
||||||
if (firstPosition) highlight.style.transitionProperty = "none";
|
|
||||||
|
|
||||||
highlight.style.width = `${targetRect.width}px`;
|
|
||||||
highlight.style.height = `${targetRect.height}px`;
|
|
||||||
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
|
|
||||||
targetRect.top - containerRect.top
|
|
||||||
}px, 0)`;
|
|
||||||
highlight.style.opacity = "1";
|
|
||||||
positionedRef.current = true;
|
|
||||||
|
|
||||||
if (firstPosition) {
|
|
||||||
restoreTransitionFrame = window.requestAnimationFrame(() => {
|
|
||||||
highlight.style.removeProperty("transition-property");
|
|
||||||
restoreTransitionFrame = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
position();
|
|
||||||
const resizeObserver =
|
|
||||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
|
|
||||||
resizeObserver?.observe(container);
|
|
||||||
resizeObserver?.observe(target);
|
|
||||||
window.addEventListener("resize", position);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (restoreTransitionFrame !== null) {
|
|
||||||
window.cancelAnimationFrame(restoreTransitionFrame);
|
|
||||||
}
|
|
||||||
highlight?.style.removeProperty("transition-property");
|
|
||||||
resizeObserver?.disconnect();
|
|
||||||
window.removeEventListener("resize", position);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...containerProps} ref={containerRef}>
|
|
||||||
{children}
|
|
||||||
<div
|
|
||||||
ref={highlightRef}
|
|
||||||
data-testid={`${scope}-selection-highlight`}
|
|
||||||
data-active-id={activeId ?? undefined}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -65,10 +65,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { channelUiPresentation } from "@/channel-plugins/registry";
|
import { channelUiPresentation } from "@/channel-plugins/registry";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||||
import {
|
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
SidebarSelectionHighlight,
|
|
||||||
} from "@/components/SidebarSelectionHighlight";
|
|
||||||
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
|
||||||
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
|
||||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
import { ToggleButton } from "@/components/settings/ToggleButton";
|
||||||
@@ -2501,7 +2497,6 @@ function SettingsSidebar({
|
|||||||
hostChromeInset?: boolean;
|
hostChromeInset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const activeNavItemRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
|
||||||
?? SETTINGS_NAV_ITEMS[0];
|
?? SETTINGS_NAV_ITEMS[0];
|
||||||
const ActiveIcon = activeItem.icon;
|
const ActiveIcon = activeItem.icon;
|
||||||
@@ -2574,26 +2569,19 @@ function SettingsSidebar({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<SidebarSelectionHighlight
|
<div className="hidden space-y-1 lg:block">
|
||||||
targetRef={activeNavItemRef}
|
|
||||||
activeId={activeSection}
|
|
||||||
scope="settings"
|
|
||||||
className="relative hidden space-y-1 lg:block"
|
|
||||||
>
|
|
||||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||||
const active = key === activeSection;
|
const active = key === activeSection;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
ref={active ? activeNavItemRef : undefined}
|
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
onClick={() => onSelectSection(key)}
|
onClick={() => onSelectSection(key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
|
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
|
||||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
|
||||||
active
|
active
|
||||||
? "text-sidebar-accent-foreground"
|
? "bg-sidebar-accent text-foreground"
|
||||||
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -2604,7 +2592,7 @@ function SettingsSidebar({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SidebarSelectionHighlight>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="hidden lg:mt-auto lg:block lg:pt-4">
|
<div className="hidden lg:mt-auto lg:block lg:pt-4">
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ interface ThreadComposerProps {
|
|||||||
quotedContext?: string | null;
|
quotedContext?: string | null;
|
||||||
focusRequest?: number;
|
focusRequest?: number;
|
||||||
onQuotedContextChange?: (text: string | null) => void;
|
onQuotedContextChange?: (text: string | null) => void;
|
||||||
allowAttachments?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||||
@@ -851,7 +850,6 @@ export function ThreadComposer({
|
|||||||
quotedContext = null,
|
quotedContext = null,
|
||||||
focusRequest = 0,
|
focusRequest = 0,
|
||||||
onQuotedContextChange,
|
onQuotedContextChange,
|
||||||
allowAttachments = true,
|
|
||||||
}: ThreadComposerProps) {
|
}: ThreadComposerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
@@ -915,10 +913,6 @@ export function ThreadComposer({
|
|||||||
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
|
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
|
||||||
useAttachedImages({ ingressLimits });
|
useAttachedImages({ ingressLimits });
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!allowAttachments) clear();
|
|
||||||
}, [allowAttachments, clear]);
|
|
||||||
|
|
||||||
const formatRejection = useCallback(
|
const formatRejection = useCallback(
|
||||||
(reason: AttachmentError): string => {
|
(reason: AttachmentError): string => {
|
||||||
const key = `thread.composer.imageRejected.${reason}`;
|
const key = `thread.composer.imageRejected.${reason}`;
|
||||||
@@ -948,7 +942,6 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
const addFiles = useCallback(
|
const addFiles = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
if (!allowAttachments) return;
|
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
secondEnterPromptIdRef.current = null;
|
secondEnterPromptIdRef.current = null;
|
||||||
const { rejected } = enqueue(files);
|
const { rejected } = enqueue(files);
|
||||||
@@ -958,7 +951,7 @@ export function ThreadComposer({
|
|||||||
setInlineError(null);
|
setInlineError(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[allowAttachments, enqueue, formatRejection],
|
[enqueue, formatRejection],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -1881,10 +1874,10 @@ export function ThreadComposer({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}}
|
}}
|
||||||
onDragEnter={allowAttachments ? onDragEnter : undefined}
|
onDragEnter={onDragEnter}
|
||||||
onDragOver={allowAttachments ? onDragOver : undefined}
|
onDragOver={onDragOver}
|
||||||
onDragLeave={allowAttachments ? onDragLeave : undefined}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={allowAttachments ? onDrop : undefined}
|
onDrop={onDrop}
|
||||||
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
|
||||||
>
|
>
|
||||||
{showSlashMenu ? (
|
{showSlashMenu ? (
|
||||||
@@ -1914,9 +1907,7 @@ export function ThreadComposer({
|
|||||||
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
|
||||||
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
|
||||||
disabled && "opacity-60",
|
disabled && "opacity-60",
|
||||||
allowAttachments
|
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
||||||
&& isDragging
|
|
||||||
&& "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
|
|
||||||
goalState?.active &&
|
goalState?.active &&
|
||||||
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
|
||||||
)}
|
)}
|
||||||
@@ -2023,7 +2014,7 @@ export function ThreadComposer({
|
|||||||
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
|
||||||
onPaste={allowAttachments ? onPaste : undefined}
|
onPaste={onPaste}
|
||||||
rows={1}
|
rows={1}
|
||||||
placeholder={resolvedPlaceholder}
|
placeholder={resolvedPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -2066,34 +2057,30 @@ export function ThreadComposer({
|
|||||||
isHero ? "gap-1.5" : "gap-2",
|
isHero ? "gap-1.5" : "gap-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{allowAttachments ? (
|
<input
|
||||||
<>
|
ref={fileInputRef}
|
||||||
<input
|
type="file"
|
||||||
ref={fileInputRef}
|
accept={ACCEPT_ATTR}
|
||||||
type="file"
|
multiple
|
||||||
accept={ACCEPT_ATTR}
|
hidden
|
||||||
multiple
|
onChange={onFilePick}
|
||||||
hidden
|
/>
|
||||||
onChange={onFilePick}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
<Button
|
size="icon"
|
||||||
type="button"
|
variant="ghost"
|
||||||
size="icon"
|
disabled={attachButtonDisabled}
|
||||||
variant="ghost"
|
aria-label={t("thread.composer.attachImage")}
|
||||||
disabled={attachButtonDisabled}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
aria-label={t("thread.composer.attachImage")}
|
className={cn(
|
||||||
onClick={() => fileInputRef.current?.click()}
|
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
|
||||||
className={cn(
|
isHero
|
||||||
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
|
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
||||||
isHero
|
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||||
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
)}
|
||||||
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
>
|
||||||
)}
|
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
||||||
>
|
</Button>
|
||||||
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{voiceRecorder.isRecording ? (
|
{voiceRecorder.isRecording ? (
|
||||||
<VoiceRecordingMeter
|
<VoiceRecordingMeter
|
||||||
ariaLabel={voiceRecordingStatusLabel}
|
ariaLabel={voiceRecordingStatusLabel}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ interface ThreadHeaderProps {
|
|||||||
minimal?: boolean;
|
minimal?: boolean;
|
||||||
promptNavigatorAction?: ReactNode;
|
promptNavigatorAction?: ReactNode;
|
||||||
sessionInfoAction?: ReactNode;
|
sessionInfoAction?: ReactNode;
|
||||||
headerAction?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadHeader({
|
export function ThreadHeader({
|
||||||
@@ -30,7 +29,6 @@ export function ThreadHeader({
|
|||||||
minimal = false,
|
minimal = false,
|
||||||
promptNavigatorAction,
|
promptNavigatorAction,
|
||||||
sessionInfoAction,
|
sessionInfoAction,
|
||||||
headerAction,
|
|
||||||
}: ThreadHeaderProps) {
|
}: ThreadHeaderProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -63,7 +61,6 @@ export function ThreadHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
{headerAction}
|
|
||||||
{sessionInfoAction}
|
{sessionInfoAction}
|
||||||
{promptNavigatorAction}
|
{promptNavigatorAction}
|
||||||
{!hideThemeButton ? (
|
{!hideThemeButton ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
} from "@/lib/mcp-preset-events";
|
} from "@/lib/mcp-preset-events";
|
||||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
import { TEMPORARY_CHAT_ID_PREFIX } from "@/lib/quick-chat";
|
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
@@ -316,12 +315,6 @@ interface ThreadShellProps {
|
|||||||
settingsSnapshot?: SettingsPayload | null;
|
settingsSnapshot?: SettingsPayload | null;
|
||||||
onOpenModelSettings?: () => void;
|
onOpenModelSettings?: () => void;
|
||||||
skills?: SkillSummary[];
|
skills?: SkillSummary[];
|
||||||
allowConversationReset?: boolean;
|
|
||||||
showSessionInfo?: boolean;
|
|
||||||
emptyStateGreeting?: string;
|
|
||||||
emptyStateDescription?: string;
|
|
||||||
temporary?: boolean;
|
|
||||||
headerAction?: ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toModelBadgeLabel(modelName: string | null): string | null {
|
function toModelBadgeLabel(modelName: string | null): string | null {
|
||||||
@@ -604,16 +597,10 @@ export function ThreadShell({
|
|||||||
settingsSnapshot = null,
|
settingsSnapshot = null,
|
||||||
onOpenModelSettings,
|
onOpenModelSettings,
|
||||||
skills = [],
|
skills = [],
|
||||||
allowConversationReset = true,
|
|
||||||
showSessionInfo = true,
|
|
||||||
emptyStateGreeting,
|
|
||||||
emptyStateDescription,
|
|
||||||
temporary = false,
|
|
||||||
headerAction,
|
|
||||||
}: ThreadShellProps) {
|
}: ThreadShellProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const chatId = session?.chatId ?? null;
|
const chatId = session?.chatId ?? null;
|
||||||
const historyKey = temporary ? null : session?.key ?? null;
|
const historyKey = session?.key ?? null;
|
||||||
const {
|
const {
|
||||||
messages: historical,
|
messages: historical,
|
||||||
loading,
|
loading,
|
||||||
@@ -635,16 +622,6 @@ export function ThreadShell({
|
|||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const availableSlashCommands = useMemo(
|
|
||||||
() => temporary
|
|
||||||
? slashCommands.filter((command) =>
|
|
||||||
command.command === "/model" || command.command === "/stop",
|
|
||||||
)
|
|
||||||
: allowConversationReset
|
|
||||||
? slashCommands
|
|
||||||
: slashCommands.filter((command) => command.command !== "/new"),
|
|
||||||
[allowConversationReset, slashCommands, temporary],
|
|
||||||
);
|
|
||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
getToken,
|
getToken,
|
||||||
eventName: CLI_APPS_CHANGED_EVENT,
|
eventName: CLI_APPS_CHANGED_EVENT,
|
||||||
@@ -692,9 +669,8 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
if (temporary) return historical;
|
|
||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical, temporary]);
|
}, [chatId, historical]);
|
||||||
const handleTurnEnd = useCallback(() => {
|
const handleTurnEnd = useCallback(() => {
|
||||||
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
|
||||||
setSubmittedViewportTurnId(null);
|
setSubmittedViewportTurnId(null);
|
||||||
@@ -714,13 +690,7 @@ export function ThreadShell({
|
|||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
} = useNanobotStream(
|
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||||
chatId,
|
|
||||||
initial,
|
|
||||||
hasPendingToolCalls,
|
|
||||||
handleTurnEnd,
|
|
||||||
{ temporary },
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (currentUiMessagesRef.current === messages) return;
|
if (currentUiMessagesRef.current === messages) return;
|
||||||
@@ -849,12 +819,9 @@ export function ThreadShell({
|
|||||||
const handleModelPresetChange = useCallback((name: string) => {
|
const handleModelPresetChange = useCallback((name: string) => {
|
||||||
setLocalModelPreset(name);
|
setLocalModelPreset(name);
|
||||||
if (chatId) {
|
if (chatId) {
|
||||||
const request = temporary
|
void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {});
|
||||||
? client.sendSystemCommand(chatId, `/model ${name}`, 5_000, { temporary: true })
|
|
||||||
: client.sendSystemCommand(chatId, `/model ${name}`);
|
|
||||||
void request.catch(() => {});
|
|
||||||
}
|
}
|
||||||
}, [chatId, client, temporary]);
|
}, [chatId, client]);
|
||||||
const modelPresetOptions = useMemo(
|
const modelPresetOptions = useMemo(
|
||||||
() => modelPresetOptionsFromSettings(settings),
|
() => modelPresetOptionsFromSettings(settings),
|
||||||
[settings],
|
[settings],
|
||||||
@@ -875,16 +842,13 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const withWorkspaceScope = useCallback(
|
const withWorkspaceScope = useCallback(
|
||||||
(options?: SendOptions): SendOptions | undefined => {
|
(options?: SendOptions): SendOptions | undefined => {
|
||||||
if (temporary) {
|
|
||||||
return { ...(options ?? {}), temporary: true };
|
|
||||||
}
|
|
||||||
if (!workspaceScope) return options;
|
if (!workspaceScope) return options;
|
||||||
return {
|
return {
|
||||||
...(options ?? {}),
|
...(options ?? {}),
|
||||||
workspaceScope,
|
workspaceScope,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[temporary, workspaceScope],
|
[workspaceScope],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshModelSettings = useCallback(async () => {
|
const refreshModelSettings = useCallback(async () => {
|
||||||
@@ -918,11 +882,11 @@ export function ThreadShell({
|
|||||||
return client.onChat(chatId, (event) => {
|
return client.onChat(chatId, (event) => {
|
||||||
if (event.event !== "turn_model_updated") return;
|
if (event.event !== "turn_model_updated") return;
|
||||||
setFallbackModelName(event.model_name);
|
setFallbackModelName(event.model_name);
|
||||||
}, { temporary });
|
});
|
||||||
}, [chatId, client, temporary]);
|
}, [chatId, client]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading || temporary) return;
|
if (!chatId || loading) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
const cached = messageCacheRef.current.get(chatId);
|
||||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||||
const hasNewCanonicalHistory = (
|
const hasNewCanonicalHistory = (
|
||||||
@@ -1052,7 +1016,6 @@ export function ThreadShell({
|
|||||||
historyLineage,
|
historyLineage,
|
||||||
historyActiveTurnId,
|
historyActiveTurnId,
|
||||||
hasPendingToolCalls,
|
hasPendingToolCalls,
|
||||||
temporary,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1104,7 +1067,7 @@ export function ThreadShell({
|
|||||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||||
|
|
||||||
const refreshCanonicalHistory = useCallback(() => {
|
const refreshCanonicalHistory = useCallback(() => {
|
||||||
if (!chatId || temporary) return;
|
if (!chatId) return;
|
||||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||||
historyLineage,
|
historyLineage,
|
||||||
historyVersion,
|
historyVersion,
|
||||||
@@ -1114,7 +1077,7 @@ export function ThreadShell({
|
|||||||
uiRevision: uiRevisionRef.current,
|
uiRevision: uiRevisionRef.current,
|
||||||
});
|
});
|
||||||
refreshHistory();
|
refreshHistory();
|
||||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory, temporary]);
|
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
@@ -1181,22 +1144,16 @@ export function ThreadShell({
|
|||||||
if (chatId) {
|
if (chatId) {
|
||||||
const prev = prevChatIdForCacheRef.current;
|
const prev = prevChatIdForCacheRef.current;
|
||||||
if (prev && prev !== chatId) {
|
if (prev && prev !== chatId) {
|
||||||
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
messageCacheRef.current.set(prev, displayMessages);
|
||||||
messageCacheRef.current.delete(prev);
|
|
||||||
} else {
|
|
||||||
messageCacheRef.current.set(prev, displayMessages);
|
|
||||||
}
|
|
||||||
skipLayoutCacheRef.current = true;
|
skipLayoutCacheRef.current = true;
|
||||||
}
|
}
|
||||||
prevChatIdForCacheRef.current = chatId;
|
prevChatIdForCacheRef.current = chatId;
|
||||||
} else {
|
} else {
|
||||||
if (prevChatIdForCacheRef.current) {
|
if (prevChatIdForCacheRef.current) {
|
||||||
const prev = prevChatIdForCacheRef.current;
|
messageCacheRef.current.set(
|
||||||
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
|
prevChatIdForCacheRef.current,
|
||||||
messageCacheRef.current.delete(prev);
|
displayMessages,
|
||||||
} else {
|
);
|
||||||
messageCacheRef.current.set(prev, displayMessages);
|
|
||||||
}
|
|
||||||
skipLayoutCacheRef.current = true;
|
skipLayoutCacheRef.current = true;
|
||||||
}
|
}
|
||||||
prevChatIdForCacheRef.current = null;
|
prevChatIdForCacheRef.current = null;
|
||||||
@@ -1207,7 +1164,7 @@ export function ThreadShell({
|
|||||||
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
|
||||||
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || temporary) {
|
if (!chatId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (skipLayoutCacheRef.current) {
|
if (skipLayoutCacheRef.current) {
|
||||||
@@ -1218,7 +1175,7 @@ export function ThreadShell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
messageCacheRef.current.set(chatId, displayMessages);
|
messageCacheRef.current.set(chatId, displayMessages);
|
||||||
}, [chatId, displayMessages, loading, temporary]);
|
}, [chatId, displayMessages, loading]);
|
||||||
|
|
||||||
// The landing composer queues the first message while `new_chat` is in flight.
|
// The landing composer queues the first message while `new_chat` is in flight.
|
||||||
// Only the chat created for that send may consume it; selecting another chat
|
// Only the chat created for that send may consume it; selecting another chat
|
||||||
@@ -1417,12 +1374,12 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant={showHeroComposer ? "hero" : "thread"}
|
variant={showHeroComposer ? "hero" : "thread"}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={temporary ? [] : cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={temporary ? [] : mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
skills={temporary ? [] : skills}
|
skills={skills}
|
||||||
onStop={stop}
|
onStop={stop}
|
||||||
onTranscribeAudio={temporary ? undefined : transcribeAudio}
|
onTranscribeAudio={transcribeAudio}
|
||||||
runStartedAt={currentRunStartedAt}
|
runStartedAt={currentRunStartedAt}
|
||||||
goalState={currentGoalState}
|
goalState={currentGoalState}
|
||||||
workspaceScope={workspaceScope}
|
workspaceScope={workspaceScope}
|
||||||
@@ -1437,7 +1394,6 @@ export function ThreadShell({
|
|||||||
quotedContext={quotedContext}
|
quotedContext={quotedContext}
|
||||||
focusRequest={composerFocusSignal}
|
focusRequest={composerFocusSignal}
|
||||||
onQuotedContextChange={setQuotedContext}
|
onQuotedContextChange={setQuotedContext}
|
||||||
allowAttachments={!temporary}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
@@ -1460,7 +1416,7 @@ export function ThreadShell({
|
|||||||
fallbackModelName={fallbackModelName}
|
fallbackModelName={fallbackModelName}
|
||||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
cliApps={cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
@@ -1486,15 +1442,10 @@ export function ThreadShell({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||||
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
|
<HeroGreeting text={t(heroGreetingKey)} />
|
||||||
{emptyStateDescription ? (
|
|
||||||
<p className="mt-3 max-w-xl text-sm text-muted-foreground">
|
|
||||||
{emptyStateDescription}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const sessionInfoAction = historyKey && showSessionInfo ? (
|
const sessionInfoAction = historyKey ? (
|
||||||
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
|
||||||
) : undefined;
|
) : undefined;
|
||||||
const promptNavigatorAction = historyKey ? (
|
const promptNavigatorAction = historyKey ? (
|
||||||
@@ -1519,7 +1470,6 @@ export function ThreadShell({
|
|||||||
minimal={!session && !loading}
|
minimal={!session && !loading}
|
||||||
promptNavigatorAction={promptNavigatorAction}
|
promptNavigatorAction={promptNavigatorAction}
|
||||||
sessionInfoAction={sessionInfoAction}
|
sessionInfoAction={sessionInfoAction}
|
||||||
headerAction={headerAction}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<FilePreviewAvailabilityProvider
|
<FilePreviewAvailabilityProvider
|
||||||
@@ -1536,17 +1486,17 @@ export function ThreadShell({
|
|||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
conversationReady={messagesReady}
|
conversationReady={messagesReady}
|
||||||
showScrollToBottomButton={!!session}
|
showScrollToBottomButton={!!session}
|
||||||
cliApps={temporary ? [] : cliApps}
|
cliApps={cliApps}
|
||||||
mcpPresets={temporary ? [] : mcpPresets}
|
mcpPresets={mcpPresets}
|
||||||
slashCommands={availableSlashCommands}
|
slashCommands={slashCommands}
|
||||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||||
hasMoreBefore={hasMoreBefore}
|
hasMoreBefore={hasMoreBefore}
|
||||||
loadingOlder={loadingOlder}
|
loadingOlder={loadingOlder}
|
||||||
userMessageOffset={userMessageOffset}
|
userMessageOffset={userMessageOffset}
|
||||||
onLoadOlder={loadOlder}
|
onLoadOlder={loadOlder}
|
||||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||||
onForkFromMessage={!temporary && onForkChat ? handleForkFromMessage : undefined}
|
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||||
onQuoteSelection={session && !temporary ? handleQuoteSelection : undefined}
|
onQuoteSelection={session ? handleQuoteSelection : undefined}
|
||||||
/>
|
/>
|
||||||
</FilePreviewAvailabilityProvider>
|
</FilePreviewAvailabilityProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
const near = distance < NEAR_BOTTOM_PX;
|
const near = distance < NEAR_BOTTOM_PX;
|
||||||
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
|
||||||
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near);
|
const logicallyAtBottom = owner === "automatic" || near;
|
||||||
setAtBottom((current) =>
|
setAtBottom((current) =>
|
||||||
current === logicallyAtBottom ? current : logicallyAtBottom,
|
current === logicallyAtBottom ? current : logicallyAtBottom,
|
||||||
);
|
);
|
||||||
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
if (!direction) return;
|
if (!direction) return;
|
||||||
threadMotionRef.current?.handleUserScrollIntent(
|
threadMotionRef.current?.handleUserScrollIntent(
|
||||||
canScrollInDirection(el, direction),
|
canScrollInDirection(el, direction),
|
||||||
direction === "forward",
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
const handleWheel = (event: WheelEvent) => {
|
const handleWheel = (event: WheelEvent) => {
|
||||||
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
|||||||
const handlePointerDown = (event: PointerEvent) => {
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
if (event.button === 0 && event.target === el) yieldCameraToUser();
|
||||||
};
|
};
|
||||||
let lastTouchY: number | null = null;
|
let touchStartY: number | null = null;
|
||||||
const handleTouchStart = (event: TouchEvent) => {
|
const handleTouchStart = (event: TouchEvent) => {
|
||||||
lastTouchY = event.touches[0]?.clientY ?? null;
|
touchStartY = event.touches[0]?.clientY ?? null;
|
||||||
};
|
};
|
||||||
const handleTouchMove = (event: TouchEvent) => {
|
const handleTouchMove = (event: TouchEvent) => {
|
||||||
const currentY = event.touches[0]?.clientY;
|
const currentY = event.touches[0]?.clientY;
|
||||||
const scrollDeltaY =
|
const scrollDeltaY =
|
||||||
lastTouchY !== null && currentY !== undefined
|
touchStartY !== null && currentY !== undefined
|
||||||
? lastTouchY - currentY
|
? touchStartY - currentY
|
||||||
: 0;
|
: 0;
|
||||||
lastTouchY = currentY ?? null;
|
|
||||||
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
handleDirectionalInput(directionFromDelta(scrollDeltaY));
|
||||||
};
|
};
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = () => {
|
||||||
lastTouchY = null;
|
touchStartY = null;
|
||||||
};
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user