mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
refactor(agent): reduce loop runner parameter plumbing
This commit is contained in:
+26
-41
@@ -76,7 +76,6 @@ from nanobot.session.automation_turns import automation_history_overrides
|
|||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_runtime_lines,
|
goal_state_runtime_lines,
|
||||||
runner_wall_llm_timeout_s,
|
runner_wall_llm_timeout_s,
|
||||||
sustained_goal_active,
|
|
||||||
)
|
)
|
||||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, remember_last_channel
|
from nanobot.session.keys import UNIFIED_SESSION_KEY, remember_last_channel
|
||||||
@@ -952,12 +951,6 @@ class AgentLoop:
|
|||||||
*,
|
*,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
session: Session | None = None,
|
session: Session | None = None,
|
||||||
channel: str = "cli",
|
|
||||||
chat_id: str = "direct",
|
|
||||||
message_id: str | None = None,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
session_key: str | None = None,
|
|
||||||
original_user_text: str | None = None,
|
|
||||||
pending_queue: asyncio.Queue[InboundMessage] | None = None,
|
pending_queue: asyncio.Queue[InboundMessage] | None = None,
|
||||||
ephemeral: bool = False,
|
ephemeral: bool = False,
|
||||||
run_extra_hooks_for_ephemeral: bool = False,
|
run_extra_hooks_for_ephemeral: bool = False,
|
||||||
@@ -1118,23 +1111,25 @@ class AgentLoop:
|
|||||||
|
|
||||||
return await _drain_pending(limit=limit, first_msg=msg)
|
return await _drain_pending(limit=limit, first_msg=msg)
|
||||||
|
|
||||||
active_session_key = session.key if session else session_key
|
request_ctx = request_context or RequestContext(
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
session_key=session.key if session is not None else None,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
active_session_key = session.key if session else request_ctx.session_key
|
||||||
|
request_metadata = request_ctx.metadata
|
||||||
effective_scope = self.workspace_scopes.for_turn(
|
effective_scope = self.workspace_scopes.for_turn(
|
||||||
channel=channel,
|
channel=request_ctx.channel,
|
||||||
message_metadata=metadata,
|
message_metadata=request_metadata,
|
||||||
session_metadata=session.metadata if session is not None else None,
|
session_metadata=session.metadata if session is not None else None,
|
||||||
)
|
)
|
||||||
|
if request_context is None:
|
||||||
|
request_ctx = dataclasses.replace(
|
||||||
|
request_ctx,
|
||||||
|
workspace=effective_scope.project_path,
|
||||||
|
)
|
||||||
effective_tools = tools or self.tools
|
effective_tools = tools or self.tools
|
||||||
request_ctx = request_context or RequestContext(
|
|
||||||
channel=channel,
|
|
||||||
chat_id=chat_id,
|
|
||||||
message_id=message_id,
|
|
||||||
session_key=active_session_key,
|
|
||||||
original_user_text=original_user_text,
|
|
||||||
runtime=runtime,
|
|
||||||
metadata=dict(metadata or {}),
|
|
||||||
workspace=effective_scope.project_path,
|
|
||||||
)
|
|
||||||
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
file_state_token = bind_file_states(self._file_state_store.for_session(active_session_key))
|
||||||
request_token = bind_request_context(request_ctx)
|
request_token = bind_request_context(request_ctx)
|
||||||
workspace_token = bind_workspace_scope(effective_scope)
|
workspace_token = bind_workspace_scope(effective_scope)
|
||||||
@@ -1159,10 +1154,10 @@ class AgentLoop:
|
|||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
on_stream_end=on_stream_end,
|
||||||
channel=channel,
|
channel=request_ctx.channel,
|
||||||
chat_id=chat_id,
|
chat_id=request_ctx.chat_id,
|
||||||
message_id=message_id,
|
message_id=request_ctx.message_id,
|
||||||
metadata=metadata,
|
metadata=request_metadata,
|
||||||
attributes=dict(request_ctx.attributes),
|
attributes=dict(request_ctx.attributes),
|
||||||
session_key=active_session_key,
|
session_key=active_session_key,
|
||||||
workspace=effective_scope.project_path,
|
workspace=effective_scope.project_path,
|
||||||
@@ -1181,14 +1176,11 @@ class AgentLoop:
|
|||||||
max_iterations=self.max_iterations,
|
max_iterations=self.max_iterations,
|
||||||
max_tool_result_chars=self.max_tool_result_chars,
|
max_tool_result_chars=self.max_tool_result_chars,
|
||||||
hook=hook,
|
hook=hook,
|
||||||
error_message="Sorry, I encountered an error calling the AI model.",
|
|
||||||
concurrent_tools=True,
|
concurrent_tools=True,
|
||||||
workspace=effective_scope.project_path,
|
workspace=effective_scope.project_path,
|
||||||
session_key=session.key if session else None,
|
session_key=session.key if session else None,
|
||||||
context_block_limit=self.context_block_limit,
|
context_block_limit=self.context_block_limit,
|
||||||
provider_retry_mode=self.provider_retry_mode,
|
provider_retry_mode=self.provider_retry_mode,
|
||||||
progress_callback=on_progress,
|
|
||||||
stream_progress_deltas=on_stream is not None,
|
|
||||||
retry_wait_callback=on_retry_wait,
|
retry_wait_callback=on_retry_wait,
|
||||||
checkpoint_callback=_checkpoint,
|
checkpoint_callback=_checkpoint,
|
||||||
injection_callback=_drain_pending,
|
injection_callback=_drain_pending,
|
||||||
@@ -1197,22 +1189,21 @@ class AgentLoop:
|
|||||||
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
|
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
|
||||||
llm_timeout_s=runner_wall_llm_timeout_s(
|
llm_timeout_s=runner_wall_llm_timeout_s(
|
||||||
self.sessions,
|
self.sessions,
|
||||||
session.key if session is not None else session_key,
|
session.key if session is not None else request_ctx.session_key,
|
||||||
metadata=session_metadata,
|
metadata=session_metadata,
|
||||||
message_metadata=metadata,
|
message_metadata=request_metadata,
|
||||||
),
|
),
|
||||||
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
|
continuation_callback=_goal_continue,
|
||||||
goal_continue_message=_goal_continue,
|
|
||||||
finalize_on_max_iterations=turn_continuation.should_finalize_on_max_iterations(
|
finalize_on_max_iterations=turn_continuation.should_finalize_on_max_iterations(
|
||||||
pending_queue_available=pending_queue is not None and session is not None,
|
pending_queue_available=pending_queue is not None and session is not None,
|
||||||
session_metadata=session_metadata,
|
session_metadata=session_metadata,
|
||||||
message_metadata=metadata,
|
message_metadata=request_metadata,
|
||||||
),
|
),
|
||||||
provider_state=provider_state,
|
provider_state=provider_state,
|
||||||
llm_usage_source=source_from_request(
|
llm_usage_source=source_from_request(
|
||||||
active_session_key,
|
active_session_key,
|
||||||
channel=channel,
|
channel=request_ctx.channel,
|
||||||
metadata=metadata,
|
metadata=request_metadata,
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
finally:
|
finally:
|
||||||
@@ -1228,7 +1219,7 @@ class AgentLoop:
|
|||||||
stop_reason=result.stop_reason,
|
stop_reason=result.stop_reason,
|
||||||
pending_queue_available=pending_queue is not None and session is not None,
|
pending_queue_available=pending_queue is not None and session is not None,
|
||||||
session_metadata=session_metadata,
|
session_metadata=session_metadata,
|
||||||
message_metadata=metadata,
|
message_metadata=request_metadata,
|
||||||
)
|
)
|
||||||
# Push final content through stream so streaming channels (e.g. Feishu)
|
# Push final content through stream so streaming channels (e.g. Feishu)
|
||||||
# update the card instead of leaving it empty.
|
# update the card instead of leaving it empty.
|
||||||
@@ -2021,12 +2012,6 @@ class AgentLoop:
|
|||||||
on_stream_end=ctx.on_stream_end,
|
on_stream_end=ctx.on_stream_end,
|
||||||
on_retry_wait=ctx.on_retry_wait,
|
on_retry_wait=ctx.on_retry_wait,
|
||||||
session=ctx.session,
|
session=ctx.session,
|
||||||
channel=ctx.delivery.route.channel,
|
|
||||||
chat_id=ctx.delivery.route.chat_id,
|
|
||||||
message_id=ctx.msg.metadata.get("message_id"),
|
|
||||||
metadata=ctx.msg.metadata,
|
|
||||||
session_key=ctx.session_key,
|
|
||||||
original_user_text=ctx.original_user_text,
|
|
||||||
pending_queue=ctx.pending_queue,
|
pending_queue=ctx.pending_queue,
|
||||||
ephemeral=ctx.ephemeral,
|
ephemeral=ctx.ephemeral,
|
||||||
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
|
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
|
||||||
|
|||||||
+22
-22
@@ -60,14 +60,13 @@ from nanobot.utils.runtime import (
|
|||||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||||
build_budget_exhausted_finalization_message,
|
build_budget_exhausted_finalization_message,
|
||||||
build_finalization_retry_message,
|
build_finalization_retry_message,
|
||||||
build_goal_continue_message,
|
|
||||||
build_length_recovery_message,
|
build_length_recovery_message,
|
||||||
is_blank_text,
|
is_blank_text,
|
||||||
repeated_external_lookup_error,
|
repeated_external_lookup_error,
|
||||||
repeated_workspace_violation_error,
|
repeated_workspace_violation_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
GoalContinueMessage = str | Callable[[], str | None]
|
ContinuationCallback = Callable[[], str | None]
|
||||||
ProgressCallback = Callable[[str], Awaitable[None]]
|
ProgressCallback = Callable[[str], Awaitable[None]]
|
||||||
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
RetryWaitCallback = Callable[[str], Awaitable[None]]
|
||||||
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
|
||||||
@@ -114,14 +113,12 @@ class AgentRunSpec:
|
|||||||
context_block_limit: int | None = None
|
context_block_limit: int | None = None
|
||||||
provider_retry_mode: str = "standard"
|
provider_retry_mode: str = "standard"
|
||||||
progress_callback: ProgressCallback | None = None
|
progress_callback: ProgressCallback | None = None
|
||||||
stream_progress_deltas: bool = True
|
|
||||||
retry_wait_callback: RetryWaitCallback | None = None
|
retry_wait_callback: RetryWaitCallback | None = None
|
||||||
checkpoint_callback: CheckpointCallback | None = None
|
checkpoint_callback: CheckpointCallback | None = None
|
||||||
injection_callback: InjectionCallback | None = None
|
injection_callback: InjectionCallback | None = None
|
||||||
terminal_injection_callback: InjectionCallback | None = None
|
terminal_injection_callback: InjectionCallback | None = None
|
||||||
llm_timeout_s: float | None = None
|
llm_timeout_s: float | None = None
|
||||||
goal_active_predicate: Callable[[], bool] | None = None
|
continuation_callback: ContinuationCallback | 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
|
provider_state: ProviderConversationState | None = None
|
||||||
llm_usage_source: LLMUsageSource | None = None
|
llm_usage_source: LLMUsageSource | None = None
|
||||||
@@ -274,7 +271,7 @@ class AgentRunner:
|
|||||||
conversation_state: ProviderConversationStateController | None = None,
|
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_continuation: bool = False,
|
||||||
wait_at_terminal: bool = False,
|
wait_at_terminal: bool = False,
|
||||||
) -> tuple[bool, int]:
|
) -> tuple[bool, int]:
|
||||||
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
"""Drain pending injections. Returns (should_continue, updated_cycles).
|
||||||
@@ -289,10 +286,10 @@ class AgentRunner:
|
|||||||
if injection_cycles < _MAX_INJECTION_CYCLES:
|
if injection_cycles < _MAX_INJECTION_CYCLES:
|
||||||
injections = await self._drain_injections(spec)
|
injections = await self._drain_injections(spec)
|
||||||
real_injection = bool(injections)
|
real_injection = bool(injections)
|
||||||
if not injections and allow_goal_continue and assistant_message is not None:
|
if not injections and allow_continuation and assistant_message is not None:
|
||||||
predicate = spec.goal_active_predicate
|
continuation = self._build_continuation_message(spec)
|
||||||
if predicate is not None and predicate():
|
if continuation is not None:
|
||||||
injections = [self._build_goal_continue_message(spec)]
|
injections = [continuation]
|
||||||
if (
|
if (
|
||||||
not injections
|
not injections
|
||||||
and wait_at_terminal
|
and wait_at_terminal
|
||||||
@@ -330,18 +327,22 @@ class AgentRunner:
|
|||||||
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
|
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Injected sustained-goal continuation {}", phase)
|
logger.info("Injected caller-requested continuation {}", phase)
|
||||||
return True, injection_cycles
|
return True, injection_cycles
|
||||||
|
|
||||||
def _build_goal_continue_message(self, spec: AgentRunSpec) -> dict[str, str]:
|
@staticmethod
|
||||||
custom = spec.goal_continue_message
|
def _build_continuation_message(spec: AgentRunSpec) -> dict[str, str] | None:
|
||||||
if callable(custom):
|
callback = spec.continuation_callback
|
||||||
try:
|
if callback is None:
|
||||||
custom = custom()
|
return None
|
||||||
except Exception:
|
try:
|
||||||
logger.exception("goal_continue_message callback failed")
|
content = callback()
|
||||||
custom = None
|
except Exception:
|
||||||
return build_goal_continue_message(custom)
|
logger.exception("continuation_callback failed")
|
||||||
|
return None
|
||||||
|
if content is None or not content.strip():
|
||||||
|
return None
|
||||||
|
return {"role": "user", "content": content}
|
||||||
|
|
||||||
async def _drain_injections(
|
async def _drain_injections(
|
||||||
self,
|
self,
|
||||||
@@ -770,7 +771,7 @@ class AgentRunner:
|
|||||||
conversation_state=conversation_state,
|
conversation_state=conversation_state,
|
||||||
phase="after final response",
|
phase="after final response",
|
||||||
iteration=iteration,
|
iteration=iteration,
|
||||||
allow_goal_continue=(
|
allow_continuation=(
|
||||||
response.finish_reason not in {"refusal", "content_filter"}
|
response.finish_reason not in {"refusal", "content_filter"}
|
||||||
),
|
),
|
||||||
wait_at_terminal=(
|
wait_at_terminal=(
|
||||||
@@ -953,7 +954,6 @@ class AgentRunner:
|
|||||||
progress_callback = spec.progress_callback
|
progress_callback = spec.progress_callback
|
||||||
wants_progress_streaming = (
|
wants_progress_streaming = (
|
||||||
not wants_streaming
|
not wants_streaming
|
||||||
and spec.stream_progress_deltas
|
|
||||||
and progress_callback is not None
|
and progress_callback is not None
|
||||||
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,13 +40,6 @@ LENGTH_RECOVERY_PROMPT = (
|
|||||||
"existing text, recap, or apologize."
|
"existing text, recap, or apologize."
|
||||||
)
|
)
|
||||||
|
|
||||||
SUSTAINED_GOAL_CONTINUE_PROMPT = (
|
|
||||||
"You have an active sustained goal. Please continue working toward the "
|
|
||||||
"objective using your tools, or call update_goal with action='complete' "
|
|
||||||
"if the work is truly finished."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def empty_tool_result_message(tool_name: str) -> str:
|
def empty_tool_result_message(tool_name: str) -> str:
|
||||||
"""Short prompt-safe marker for tools that completed without visible output."""
|
"""Short prompt-safe marker for tools that completed without visible output."""
|
||||||
return f"({tool_name} completed with no output)"
|
return f"({tool_name} completed with no output)"
|
||||||
@@ -97,11 +90,6 @@ def build_length_recovery_message(content: str) -> dict[str, str]:
|
|||||||
return {"role": "user", "content": prompt}
|
return {"role": "user", "content": prompt}
|
||||||
|
|
||||||
|
|
||||||
def build_goal_continue_message(custom: str | None = None) -> dict[str, str]:
|
|
||||||
"""Prompt the model to continue when a sustained goal is still active."""
|
|
||||||
return {"role": "user", "content": custom or SUSTAINED_GOAL_CONTINUE_PROMPT}
|
|
||||||
|
|
||||||
|
|
||||||
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
def external_lookup_signature(tool_name: str, arguments: Any) -> str | None:
|
||||||
"""Stable signature for repeated external lookups we want to throttle."""
|
"""Stable signature for repeated external lookups we want to throttle."""
|
||||||
if not isinstance(arguments, dict):
|
if not isinstance(arguments, dict):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.agent.tools.filesystem import ReadFileTool
|
from nanobot.agent.tools.filesystem import ReadFileTool
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
@@ -145,11 +146,11 @@ async def test_pending_document_attachment_keeps_body_out_of_prompt(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "hello"}],
|
[{"role": "user", "content": "hello"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
channel="cli",
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||||
chat_id="c",
|
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from nanobot.agent.hook import (
|
|||||||
AgentTurnHookContext,
|
AgentTurnHookContext,
|
||||||
CompositeHook,
|
CompositeHook,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
|
|
||||||
|
|
||||||
def _ctx() -> AgentHookContext:
|
def _ctx() -> AgentHookContext:
|
||||||
@@ -501,15 +502,19 @@ async def test_agent_loop_turn_hook_factories_receive_context(tmp_path):
|
|||||||
async def on_progress(*args, **kwargs):
|
async def on_progress(*args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "hi"}],
|
[{"role": "user", "content": "hi"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
channel="websocket",
|
request_context=RequestContext(
|
||||||
chat_id="chat-1",
|
channel="websocket",
|
||||||
message_id="msg-1",
|
chat_id="chat-1",
|
||||||
metadata={"source": "test"},
|
message_id="msg-1",
|
||||||
session_key="websocket:chat-1",
|
session_key="websocket:chat-1",
|
||||||
|
runtime=runtime,
|
||||||
|
metadata={"source": "test"},
|
||||||
|
),
|
||||||
hook_factories=[factory("turn")],
|
hook_factories=[factory("turn")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
from nanobot.agent.goal_permission import goal_mutation_allowed, goal_mutation_permission
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.bus.outbound_events import StreamedResponseEvent
|
from nanobot.bus.outbound_events import StreamedResponseEvent
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import GenerationSettings, LLMProvider, LLMResponse, ToolCallRequest
|
||||||
@@ -359,10 +360,16 @@ async def test_loop_goal_turn_uses_standard_iteration_budget(tmp_path):
|
|||||||
loop.tools.execute = AsyncMock(return_value="ok")
|
loop.tools.execute = AsyncMock(return_value="ok")
|
||||||
loop.max_iterations = 2
|
loop.max_iterations = 2
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[],
|
[],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
metadata={"original_command": "/goal"},
|
request_context=RequestContext(
|
||||||
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
|
runtime=runtime,
|
||||||
|
metadata={"original_command": "/goal"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.stop_reason == "max_iterations"
|
assert result.stop_reason == "max_iterations"
|
||||||
|
|||||||
@@ -1543,7 +1543,8 @@ async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path:
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.chat_id == "thread-777"
|
assert result.chat_id == "thread-777"
|
||||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
request = loop._run_agent_loop.call_args.kwargs["request_context"]
|
||||||
|
assert request.chat_id == "thread-777"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1592,12 +1593,12 @@ async def test_process_message_uses_explicit_session_for_goal_context(
|
|||||||
assert result.content == "ok"
|
assert result.content == "ok"
|
||||||
kwargs = loop._run_agent_loop.call_args.kwargs
|
kwargs = loop._run_agent_loop.call_args.kwargs
|
||||||
assert kwargs["session"] is system_session
|
assert kwargs["session"] is system_session
|
||||||
assert kwargs["session_key"] == "system"
|
assert kwargs["request_context"].session_key == "system"
|
||||||
assert GOAL_STATE_KEY not in kwargs["session"].metadata
|
assert GOAL_STATE_KEY not in kwargs["session"].metadata
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
async def test_run_agent_loop_continuation_reads_latest_goal_metadata(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
from nanobot.agent.runner import AgentRunResult
|
from nanobot.agent.runner import AgentRunResult
|
||||||
@@ -1607,12 +1608,12 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
|||||||
seen: dict[str, str | None] = {}
|
seen: dict[str, str | None] = {}
|
||||||
|
|
||||||
async def fake_run(spec):
|
async def fake_run(spec):
|
||||||
assert callable(spec.goal_continue_message)
|
assert callable(spec.continuation_callback)
|
||||||
session.metadata[GOAL_STATE_KEY] = {
|
session.metadata[GOAL_STATE_KEY] = {
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"objective": "Goal created during this runner call.",
|
"objective": "Goal created during this runner call.",
|
||||||
}
|
}
|
||||||
seen["goal_continue"] = spec.goal_continue_message()
|
seen["goal_continue"] = spec.continuation_callback()
|
||||||
return AgentRunResult(
|
return AgentRunResult(
|
||||||
final_content="ok",
|
final_content="ok",
|
||||||
messages=[{"role": "assistant", "content": "ok"}],
|
messages=[{"role": "assistant", "content": "ok"}],
|
||||||
@@ -1620,13 +1621,17 @@ async def test_run_agent_loop_goal_continue_message_reads_latest_metadata(
|
|||||||
|
|
||||||
loop.runner.run = fake_run # type: ignore[method-assign]
|
loop.runner.run = fake_run # type: ignore[method-assign]
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[],
|
[],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
session=session,
|
session=session,
|
||||||
channel="websocket",
|
request_context=RequestContext(
|
||||||
chat_id="late-goal",
|
channel="websocket",
|
||||||
session_key=session.key,
|
chat_id="late-goal",
|
||||||
|
session_key=session.key,
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
|
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
|
||||||
|
|||||||
@@ -135,10 +135,13 @@ async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) ->
|
|||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[],
|
[],
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
channel="slack",
|
request_context=RequestContext(
|
||||||
chat_id="C123",
|
channel="slack",
|
||||||
metadata=metadata,
|
chat_id="C123",
|
||||||
session_key="slack:C123:111.222",
|
session_key="slack:C123:111.222",
|
||||||
|
runtime=runtime,
|
||||||
|
metadata=metadata,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert cron.contexts[-1] == {
|
assert cron.contexts[-1] == {
|
||||||
@@ -233,10 +236,13 @@ async def test_agent_loop_restores_outer_request_context_after_runner_exception(
|
|||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[],
|
[],
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
channel="slack",
|
request_context=RequestContext(
|
||||||
chat_id="C123",
|
channel="slack",
|
||||||
session_key="slack:C123:111.222",
|
chat_id="C123",
|
||||||
original_user_text=" unchanged user text ",
|
session_key="slack:C123:111.222",
|
||||||
|
original_user_text=" unchanged user text ",
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
assert current_request_context() is outer
|
assert current_request_context() is outer
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -843,7 +843,6 @@ async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout():
|
|||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
hook=ProgressReasoningHook(),
|
hook=ProgressReasoningHook(),
|
||||||
progress_callback=AsyncMock(),
|
progress_callback=AsyncMock(),
|
||||||
stream_progress_deltas=True,
|
|
||||||
llm_timeout_s=1,
|
llm_timeout_s=1,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -994,7 +993,7 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=3,
|
max_iterations=3,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=lambda: "Continue working.",
|
||||||
terminal_injection_callback=terminal_injection_callback,
|
terminal_injection_callback=terminal_injection_callback,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Tests for sustained-goal continuation in AgentRunner.
|
"""Tests for caller-controlled continuation in AgentRunner.
|
||||||
|
|
||||||
When a goal_active_predicate returns True, the runner must not exit with
|
When the continuation callback returns a message, the runner must not exit with
|
||||||
stop_reason="completed" after a plain-text final response. Instead it should
|
stop_reason="completed" after a plain-text final response. Instead it injects
|
||||||
inject a continuation message and keep looping (similar to mid-turn injection).
|
that message and keeps looping, similar to a mid-turn injection.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -18,9 +18,13 @@ from nanobot.providers.base import LLMProvider, LLMResponse
|
|||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
|
|
||||||
|
def _continue_goal() -> str:
|
||||||
|
return "Continue working toward the active sustained goal."
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_exits_normally_without_predicate():
|
async def test_runner_exits_normally_without_continuation_callback():
|
||||||
"""Baseline: no predicate, runner exits with completed on final text."""
|
"""Without a continuation request, final text completes the run."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -44,8 +48,8 @@ async def test_runner_exits_normally_without_predicate():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_exits_normally_with_inactive_goal():
|
async def test_runner_exits_normally_when_continuation_callback_returns_none():
|
||||||
"""Predicate returns False, runner should exit normally."""
|
"""A callback returning None leaves the final response terminal."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -62,7 +66,7 @@ async def test_runner_exits_normally_with_inactive_goal():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=2,
|
max_iterations=2,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: False,
|
continuation_callback=lambda: None,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert result.stop_reason == "completed"
|
assert result.stop_reason == "completed"
|
||||||
@@ -70,8 +74,8 @@ async def test_runner_exits_normally_with_inactive_goal():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_forces_continue_when_goal_active():
|
async def test_runner_continues_when_callback_returns_message():
|
||||||
"""Predicate returns True on final text → runner injects continuation and loops.
|
"""A callback result after final text is injected for the next iteration.
|
||||||
|
|
||||||
We set max_iterations=3 and let the provider return final text every time.
|
We set max_iterations=3 and let the provider return final text every time.
|
||||||
Without the fix this would exit on the first iteration with stop_reason
|
Without the fix this would exit on the first iteration with stop_reason
|
||||||
@@ -94,10 +98,10 @@ async def test_runner_forces_continue_when_goal_active():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=3,
|
max_iterations=3,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=_continue_goal,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Because the predicate keeps returning True, the runner should never
|
# Because the callback keeps returning a message, the runner should never
|
||||||
# naturally complete. It loops until max_iterations is exhausted.
|
# naturally complete. It loops until max_iterations is exhausted.
|
||||||
assert result.stop_reason == "max_iterations"
|
assert result.stop_reason == "max_iterations"
|
||||||
# The injected continuation message should be present in the message list.
|
# The injected continuation message should be present in the message list.
|
||||||
@@ -106,8 +110,8 @@ async def test_runner_forces_continue_when_goal_active():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_respects_max_iterations_even_with_active_goal():
|
async def test_runner_respects_max_iterations_with_continuation():
|
||||||
"""A single iteration with active goal still hits max_iterations."""
|
"""A continuation request after one iteration still hits max_iterations."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -124,15 +128,15 @@ async def test_runner_respects_max_iterations_even_with_active_goal():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=1,
|
max_iterations=1,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=_continue_goal,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert result.stop_reason == "max_iterations"
|
assert result.stop_reason == "max_iterations"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
async def test_runner_continuation_not_limited_by_injection_cycle_cap():
|
||||||
"""Synthetic goal continuation should be governed by max_iterations."""
|
"""Caller-requested continuation is governed by max_iterations."""
|
||||||
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
|
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -150,7 +154,7 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=max_iterations,
|
max_iterations=max_iterations,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=_continue_goal,
|
||||||
finalize_on_max_iterations=False,
|
finalize_on_max_iterations=False,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -159,8 +163,8 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_does_not_force_continue_on_error():
|
async def test_runner_does_not_continue_on_error():
|
||||||
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
|
"""An LLM error remains terminal even when continuation is available."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -178,15 +182,15 @@ async def test_runner_does_not_force_continue_on_error():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=2,
|
max_iterations=2,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=_continue_goal,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert result.stop_reason == "error"
|
assert result.stop_reason == "error"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_uses_custom_goal_continue_message():
|
async def test_runner_injects_continuation_callback_message():
|
||||||
"""Custom goal_continue_message should be injected instead of the default."""
|
"""The callback result becomes the injected user message."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
@@ -205,8 +209,7 @@ async def test_runner_uses_custom_goal_continue_message():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=2,
|
max_iterations=2,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=lambda: custom_msg,
|
||||||
goal_continue_message=custom_msg,
|
|
||||||
))
|
))
|
||||||
|
|
||||||
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
user_msgs = [m for m in result.messages if m.get("role") == "user"]
|
||||||
@@ -214,7 +217,7 @@ async def test_runner_uses_custom_goal_continue_message():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_resolves_goal_continue_message_lazily():
|
async def test_runner_resolves_continuation_callback_lazily():
|
||||||
"""The continuation text can depend on goal metadata created during the run."""
|
"""The continuation text can depend on goal metadata created during the run."""
|
||||||
from nanobot.agent.runner import AgentRunner
|
from nanobot.agent.runner import AgentRunner
|
||||||
|
|
||||||
@@ -237,8 +240,7 @@ async def test_runner_resolves_goal_continue_message_lazily():
|
|||||||
model="test-model",
|
model="test-model",
|
||||||
max_iterations=1,
|
max_iterations=1,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
goal_active_predicate=lambda: True,
|
continuation_callback=dynamic_msg,
|
||||||
goal_continue_message=dynamic_msg,
|
|
||||||
finalize_on_max_iterations=False,
|
finalize_on_max_iterations=False,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import pytest
|
|||||||
|
|
||||||
from agent.runner_helpers import make_run_spec
|
from agent.runner_helpers import make_run_spec
|
||||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
@@ -390,13 +391,13 @@ async def test_goal_continuation_precedes_terminal_wait():
|
|||||||
])
|
])
|
||||||
tools = MagicMock()
|
tools = MagicMock()
|
||||||
tools.get_definitions.return_value = []
|
tools.get_definitions.return_value = []
|
||||||
goal_checks = 0
|
continuation_checks = 0
|
||||||
terminal_waits = 0
|
terminal_waits = 0
|
||||||
|
|
||||||
def goal_active() -> bool:
|
def continue_goal() -> str | None:
|
||||||
nonlocal goal_checks
|
nonlocal continuation_checks
|
||||||
goal_checks += 1
|
continuation_checks += 1
|
||||||
return goal_checks == 1
|
return "Continue the active goal." if continuation_checks == 1 else None
|
||||||
|
|
||||||
async def drain_available():
|
async def drain_available():
|
||||||
return []
|
return []
|
||||||
@@ -415,7 +416,7 @@ async def test_goal_continuation_precedes_terminal_wait():
|
|||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
injection_callback=drain_available,
|
injection_callback=drain_available,
|
||||||
terminal_injection_callback=wait_at_terminal,
|
terminal_injection_callback=wait_at_terminal,
|
||||||
goal_active_predicate=goal_active,
|
continuation_callback=continue_goal,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert provider.chat_with_retry.await_count == 2
|
assert provider.chat_with_retry.await_count == 2
|
||||||
@@ -614,11 +615,11 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
|||||||
media=[str(image_path)],
|
media=[str(image_path)],
|
||||||
))
|
))
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "hello"}],
|
[{"role": "user", "content": "hello"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
channel="cli",
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||||
chat_id="c",
|
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -708,13 +709,17 @@ async def test_pending_injection_resolves_its_own_runtime_context(tmp_path):
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "initial message from user A"}],
|
[{"role": "user", "content": "initial message from user A"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
session=session,
|
session=session,
|
||||||
channel="telegram",
|
request_context=RequestContext(
|
||||||
chat_id="group-1",
|
channel="telegram",
|
||||||
session_key=session.key,
|
chat_id="group-1",
|
||||||
|
session_key=session.key,
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -805,11 +810,11 @@ async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_p
|
|||||||
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
|
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "hello"}],
|
[{"role": "user", "content": "hello"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
channel="cli",
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||||
chat_id="c",
|
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1469,11 +1474,11 @@ async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_pat
|
|||||||
content=f"follow-up-{idx}",
|
content=f"follow-up-{idx}",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
result = await loop._run_agent_loop(
|
result = await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "hello"}],
|
[{"role": "user", "content": "hello"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
channel="cli",
|
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
|
||||||
chat_id="c",
|
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,39 +17,6 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest
|
|||||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_runner_can_disable_provider_progress_delta_streaming():
|
|
||||||
"""AgentLoop disables token progress streaming for non-streaming channels."""
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.supports_progress_deltas = True
|
|
||||||
provider.chat_with_retry = AsyncMock(
|
|
||||||
return_value=LLMResponse(content="done", tool_calls=[], usage=None)
|
|
||||||
)
|
|
||||||
provider.chat_stream_with_retry = AsyncMock()
|
|
||||||
tools = MagicMock()
|
|
||||||
tools.get_definitions.return_value = []
|
|
||||||
progress_cb = AsyncMock()
|
|
||||||
|
|
||||||
runner = AgentRunner()
|
|
||||||
result = await runner.run(make_run_spec(provider,
|
|
||||||
initial_messages=[
|
|
||||||
{"role": "system", "content": "system"},
|
|
||||||
{"role": "user", "content": "hi"},
|
|
||||||
],
|
|
||||||
tools=tools,
|
|
||||||
model="test-model",
|
|
||||||
max_iterations=1,
|
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
|
||||||
progress_callback=progress_cb,
|
|
||||||
stream_progress_deltas=False,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert result.final_content == "done"
|
|
||||||
provider.chat_with_retry.assert_awaited_once()
|
|
||||||
provider.chat_stream_with_retry.assert_not_awaited()
|
|
||||||
progress_cb.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_runner_streams_provider_progress_deltas_by_default():
|
async def test_runner_streams_provider_progress_deltas_by_default():
|
||||||
"""Direct runner users keep the existing opt-in provider progress behavior."""
|
"""Direct runner users keep the existing opt-in provider progress behavior."""
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
|
|||||||
max_iterations=3,
|
max_iterations=3,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
hook=hook,
|
hook=hook,
|
||||||
stream_progress_deltas=True,
|
|
||||||
progress_callback=_progress,
|
progress_callback=_progress,
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -276,7 +275,6 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
|
|||||||
max_iterations=3,
|
max_iterations=3,
|
||||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||||
hook=hook,
|
hook=hook,
|
||||||
stream_progress_deltas=True,
|
|
||||||
progress_callback=_progress,
|
progress_callback=_progress,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools.context import RequestContext
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import GenerationSettings
|
from nanobot.providers.base import GenerationSettings
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
@@ -503,12 +504,12 @@ async def test_drain_pending_no_block_when_no_subagents(tmp_path):
|
|||||||
|
|
||||||
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "test"}],
|
[{"role": "user", "content": "test"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
session=None,
|
session=None,
|
||||||
channel="test",
|
request_context=RequestContext(channel="test", chat_id="c1", runtime=runtime),
|
||||||
chat_id="c1",
|
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,12 +563,17 @@ async def test_terminal_drain_timeout(tmp_path):
|
|||||||
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
|
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
|
||||||
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
|
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
|
||||||
|
|
||||||
|
runtime = loop.llm_runtime()
|
||||||
await loop._run_agent_loop(
|
await loop._run_agent_loop(
|
||||||
[{"role": "user", "content": "test"}],
|
[{"role": "user", "content": "test"}],
|
||||||
runtime=loop.llm_runtime(),
|
runtime=runtime,
|
||||||
session=session,
|
session=session,
|
||||||
channel="test",
|
request_context=RequestContext(
|
||||||
chat_id="c1",
|
channel="test",
|
||||||
|
chat_id="c1",
|
||||||
|
session_key=session.key,
|
||||||
|
runtime=runtime,
|
||||||
|
),
|
||||||
pending_queue=pending_queue,
|
pending_queue=pending_queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user