mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
feat: preserve Responses reasoning state and compact context (#5172)
This commit is contained in:
@@ -10,7 +10,11 @@ from nanobot.agent.memory import (
|
||||
Consolidator,
|
||||
MemoryStore,
|
||||
)
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.providers.base import (
|
||||
GenerationSettings,
|
||||
LLMResponse,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
RuntimeContextBlock,
|
||||
@@ -74,6 +78,16 @@ 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:
|
||||
async def test_archive_prompt_includes_media_breadcrumb(
|
||||
self, consolidator, mock_provider, store, runtime
|
||||
@@ -385,6 +399,7 @@ class TestConsolidatorTokenBudget:
|
||||
"""Old messages that cannot be replayed should be materialized first."""
|
||||
consolidator._SAFETY_BUFFER = 0
|
||||
session = Session(key="test:replay-overflow")
|
||||
session.provider_state = _provider_state()
|
||||
for i in range(10):
|
||||
session.add_message("user", f"u{i}")
|
||||
session.add_message("assistant", f"a{i}")
|
||||
@@ -404,6 +419,7 @@ class TestConsolidatorTokenBudget:
|
||||
assert archived_chunk[-1]["content"] == "a6"
|
||||
assert session.last_consolidated == 14
|
||||
assert session.metadata["_last_summary"]["text"] == "old conversation summary"
|
||||
assert session.provider_state is None
|
||||
consolidator.sessions.save.assert_called()
|
||||
|
||||
async def test_replay_window_overflow_extends_to_long_recent_user_turn(
|
||||
@@ -479,6 +495,7 @@ class TestConsolidatorTokenBudget:
|
||||
session = MagicMock()
|
||||
session.last_consolidated = 0
|
||||
session.key = "test:key"
|
||||
session.provider_state = _provider_state()
|
||||
session.messages = [
|
||||
{
|
||||
"role": "user" if i in {0, 50, 61} else "assistant",
|
||||
@@ -500,6 +517,7 @@ class TestConsolidatorTokenBudget:
|
||||
# pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
|
||||
assert archived_chunk[0]["content"] == "m0"
|
||||
assert session.last_consolidated > 0
|
||||
assert session.provider_state is None
|
||||
|
||||
async def test_raw_archive_fallback_advances_last_consolidated(
|
||||
self, consolidator, runtime
|
||||
@@ -610,6 +628,7 @@ class TestCompactIdleSession:
|
||||
)
|
||||
sessions = real_consolidator.sessions
|
||||
session = sessions.get_or_create("cli:test")
|
||||
session.provider_state = _provider_state()
|
||||
old_ts = session.updated_at
|
||||
for i in range(20):
|
||||
session.add_message("user", f"user msg {i}")
|
||||
@@ -627,6 +646,7 @@ class TestCompactIdleSession:
|
||||
assert len(reloaded.messages) == 40
|
||||
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||
assert reloaded.last_consolidated == 32
|
||||
assert reloaded.provider_state is None
|
||||
visible = reloaded.get_history(max_messages=40)
|
||||
assert len(visible) == 8
|
||||
assert visible[0]["content"] == "user msg 16"
|
||||
|
||||
@@ -452,6 +452,20 @@ class TestBuildMessages:
|
||||
assert "previous user 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):
|
||||
builder = _builder(tmp_path)
|
||||
history = [{"role": "assistant", "content": "previous response"}]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
@@ -19,7 +20,7 @@ from nanobot.bus.outbound_events import (
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ProviderConversationState
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
@@ -59,6 +60,16 @@ def _mk_loop() -> AgentLoop:
|
||||
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:
|
||||
merged, marker = append_runtime_context(content, blocks)
|
||||
assert marker is not None
|
||||
@@ -494,6 +505,7 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
|
||||
loop = _mk_loop()
|
||||
session = Session(
|
||||
key="test:checkpoint",
|
||||
provider_state=_provider_state(),
|
||||
metadata={
|
||||
AgentLoop._RUNTIME_CHECKPOINT_KEY: {
|
||||
"assistant_message": {
|
||||
@@ -539,6 +551,104 @@ def test_restore_runtime_checkpoint_rehydrates_completed_and_pending_tools() ->
|
||||
assert session.messages[1]["tool_call_id"] == "call_done"
|
||||
assert session.messages[2]["tool_call_id"] == "call_pending"
|
||||
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:
|
||||
@@ -616,6 +726,55 @@ def test_restore_runtime_checkpoint_dedupes_overlapping_tail() -> None:
|
||||
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
|
||||
async def test_process_message_persists_user_message_before_turn_completes(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
@@ -634,6 +793,150 @@ async def test_process_message_persists_user_message_before_turn_completes(tmp_p
|
||||
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
|
||||
async def test_process_message_persists_unified_session_delivery_route(tmp_path: Path) -> None:
|
||||
loop = _make_full_loop(tmp_path)
|
||||
@@ -1245,6 +1548,9 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
|
||||
session = loop.sessions.get_or_create("feishu:c3")
|
||||
session.add_message("user", "old question")
|
||||
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._run_agent_loop = AsyncMock(return_value=(
|
||||
@@ -1278,6 +1584,7 @@ async def test_next_turn_after_crash_closes_pending_user_turn_before_new_input(t
|
||||
{"role": "assistant", "content": "new answer"},
|
||||
]
|
||||
assert AgentLoop._PENDING_USER_TURN_KEY not in session.metadata
|
||||
assert session.provider_state is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -11,7 +11,13 @@ import pytest
|
||||
|
||||
from agent.runner_helpers import make_run_spec
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
ToolCallRequest,
|
||||
)
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@@ -73,6 +79,311 @@ 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
|
||||
async def test_runner_returns_max_iterations_fallback():
|
||||
from nanobot.agent.runner import AgentRunner
|
||||
@@ -422,6 +733,66 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
|
||||
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
|
||||
async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
||||
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
|
||||
@@ -450,6 +821,56 @@ async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
||||
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
|
||||
async def test_runner_length_recovery_returns_all_segments():
|
||||
"""Recovered output segments are returned together instead of only the tail."""
|
||||
|
||||
@@ -9,8 +9,15 @@ import pytest
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
)
|
||||
from nanobot.providers.conversation_state import ProviderConversationStateController
|
||||
from nanobot.providers.fallback_provider import FallbackProvider
|
||||
from nanobot.providers.openai_responses import resolve_compact_threshold
|
||||
|
||||
|
||||
def _make_response(
|
||||
@@ -66,6 +73,9 @@ class _FakeProvider(LLMProvider):
|
||||
self._response = response or _make_response()
|
||||
self.chat_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:
|
||||
return f"{self.name}/model"
|
||||
@@ -81,6 +91,26 @@ class _FakeProvider(LLMProvider):
|
||||
await on_delta(self._response.content)
|
||||
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 --
|
||||
|
||||
@@ -211,6 +241,8 @@ def test_provider_snapshot_uses_smallest_fallback_context_window() -> None:
|
||||
snapshot = build_provider_snapshot(config)
|
||||
|
||||
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:
|
||||
@@ -285,6 +317,257 @@ class TestFallbackOnPrimaryError:
|
||||
assert primary.chat_calls[0]["model"] == "primary-model"
|
||||
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
|
||||
async def test_reports_the_fallback_model_before_its_request(self) -> None:
|
||||
primary = _FakeProvider("primary", _error_response())
|
||||
|
||||
@@ -15,7 +15,11 @@ from nanobot.agent.context_governance import (
|
||||
)
|
||||
from nanobot.agent.runner import AgentRunSpec
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import (
|
||||
LLMResponse,
|
||||
ProviderConversationState,
|
||||
ToolCallRequest,
|
||||
)
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@@ -886,6 +890,13 @@ def test_drop_malformed_tool_calls_trims_response():
|
||||
"""LLM response tool_calls with a missing/empty name are dropped in place."""
|
||||
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(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
@@ -895,9 +906,11 @@ def test_drop_malformed_tool_calls_trims_response():
|
||||
ToolCallRequest(id="4", name="read_file", arguments={}),
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
provider_state=candidate_state,
|
||||
)
|
||||
dropped, all_dropped, orig = AgentRunner._drop_malformed_tool_calls(response)
|
||||
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.should_execute_tools is True
|
||||
assert dropped == 3
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.providers.base import ProviderConversationState
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
|
||||
@@ -101,6 +102,137 @@ class TestAtomicSave:
|
||||
for i in range(5):
|
||||
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:
|
||||
def _write_corrupt_jsonl(self, path: Path, lines: list[str]) -> None:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from nanobot.providers.base import ProviderConversationState
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
RuntimeContextBlock,
|
||||
@@ -769,7 +770,16 @@ def test_get_history_extend_to_user_keeps_newer_user_inside_window():
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_dropped_messages():
|
||||
"""retain_recent_legal_suffix returns the actually-dropped messages."""
|
||||
session = Session(key="test:return-dropped")
|
||||
session = Session(
|
||||
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):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
@@ -779,11 +789,19 @@ 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 len(session.messages) == 4
|
||||
assert result.already_consolidated_count == 0
|
||||
assert session.provider_state is None
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||
"""No messages dropped → empty list returned."""
|
||||
session = Session(key="test:no-drop")
|
||||
state = ProviderConversationState(
|
||||
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):
|
||||
session.messages.append({"role": "user", "content": f"msg{i}"})
|
||||
|
||||
@@ -792,6 +810,7 @@ def test_retain_recent_legal_suffix_returns_empty_when_no_drop():
|
||||
assert result.dropped == []
|
||||
assert result.already_consolidated_count == 0
|
||||
assert len(session.messages) == 3
|
||||
assert session.provider_state is state
|
||||
|
||||
|
||||
def test_retain_recent_legal_suffix_returns_all_on_zero():
|
||||
|
||||
@@ -504,6 +504,7 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
provider_state=None,
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
@@ -589,6 +590,7 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
provider_state=None,
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
@@ -638,6 +640,7 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
usage={},
|
||||
had_injections=False,
|
||||
tools_used=[],
|
||||
provider_state=None,
|
||||
)
|
||||
|
||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||
|
||||
Reference in New Issue
Block a user