refactor(agent): reduce loop runner parameter plumbing

This commit is contained in:
chengyongru
2026-08-26 17:50:52 +08:00
committed by chengyongru
parent c62aec0175
commit a618e80887
14 changed files with 168 additions and 194 deletions
+26 -41
View File
@@ -76,7 +76,6 @@ from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
sustained_goal_active,
)
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.keys import UNIFIED_SESSION_KEY, remember_last_channel
@@ -952,12 +951,6 @@ class AgentLoop:
*,
runtime: LLMRuntime,
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,
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)
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(
channel=channel,
message_metadata=metadata,
channel=request_ctx.channel,
message_metadata=request_metadata,
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
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))
request_token = bind_request_context(request_ctx)
workspace_token = bind_workspace_scope(effective_scope)
@@ -1159,10 +1154,10 @@ class AgentLoop:
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
channel=channel,
chat_id=chat_id,
message_id=message_id,
metadata=metadata,
channel=request_ctx.channel,
chat_id=request_ctx.chat_id,
message_id=request_ctx.message_id,
metadata=request_metadata,
attributes=dict(request_ctx.attributes),
session_key=active_session_key,
workspace=effective_scope.project_path,
@@ -1181,14 +1176,11 @@ class AgentLoop:
max_iterations=self.max_iterations,
max_tool_result_chars=self.max_tool_result_chars,
hook=hook,
error_message="Sorry, I encountered an error calling the AI model.",
concurrent_tools=True,
workspace=effective_scope.project_path,
session_key=session.key if session else None,
context_block_limit=self.context_block_limit,
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,
checkpoint_callback=_checkpoint,
injection_callback=_drain_pending,
@@ -1197,22 +1189,21 @@ class AgentLoop:
# is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers.
llm_timeout_s=runner_wall_llm_timeout_s(
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,
message_metadata=metadata,
message_metadata=request_metadata,
),
goal_active_predicate=lambda: sustained_goal_active(session.metadata) if session is not None else False,
goal_continue_message=_goal_continue,
continuation_callback=_goal_continue,
finalize_on_max_iterations=turn_continuation.should_finalize_on_max_iterations(
pending_queue_available=pending_queue is not None and session is not None,
session_metadata=session_metadata,
message_metadata=metadata,
message_metadata=request_metadata,
),
provider_state=provider_state,
llm_usage_source=source_from_request(
active_session_key,
channel=channel,
metadata=metadata,
channel=request_ctx.channel,
metadata=request_metadata,
),
))
finally:
@@ -1228,7 +1219,7 @@ class AgentLoop:
stop_reason=result.stop_reason,
pending_queue_available=pending_queue is not None and session is not None,
session_metadata=session_metadata,
message_metadata=metadata,
message_metadata=request_metadata,
)
# Push final content through stream so streaming channels (e.g. Feishu)
# update the card instead of leaving it empty.
@@ -2021,12 +2012,6 @@ class AgentLoop:
on_stream_end=ctx.on_stream_end,
on_retry_wait=ctx.on_retry_wait,
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,
ephemeral=ctx.ephemeral,
run_extra_hooks_for_ephemeral=ctx.run_extra_hooks_for_ephemeral,
+22 -22
View File
@@ -60,14 +60,13 @@ from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
build_budget_exhausted_finalization_message,
build_finalization_retry_message,
build_goal_continue_message,
build_length_recovery_message,
is_blank_text,
repeated_external_lookup_error,
repeated_workspace_violation_error,
)
GoalContinueMessage = str | Callable[[], str | None]
ContinuationCallback = Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
@@ -114,14 +113,12 @@ class AgentRunSpec:
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: ProgressCallback | None = None
stream_progress_deltas: bool = True
retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None
terminal_injection_callback: InjectionCallback | None = None
llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None
continuation_callback: ContinuationCallback | None = None
finalize_on_max_iterations: bool = True
provider_state: ProviderConversationState | None = None
llm_usage_source: LLMUsageSource | None = None
@@ -274,7 +271,7 @@ class AgentRunner:
conversation_state: ProviderConversationStateController | None = None,
phase: str = "after error",
iteration: int | None = None,
allow_goal_continue: bool = False,
allow_continuation: bool = False,
wait_at_terminal: bool = False,
) -> tuple[bool, int]:
"""Drain pending injections. Returns (should_continue, updated_cycles).
@@ -289,10 +286,10 @@ class AgentRunner:
if injection_cycles < _MAX_INJECTION_CYCLES:
injections = await self._drain_injections(spec)
real_injection = bool(injections)
if not injections and allow_goal_continue and assistant_message is not None:
predicate = spec.goal_active_predicate
if predicate is not None and predicate():
injections = [self._build_goal_continue_message(spec)]
if not injections and allow_continuation and assistant_message is not None:
continuation = self._build_continuation_message(spec)
if continuation is not None:
injections = [continuation]
if (
not injections
and wait_at_terminal
@@ -330,18 +327,22 @@ class AgentRunner:
len(injections), phase, injection_cycles, _MAX_INJECTION_CYCLES,
)
else:
logger.info("Injected sustained-goal continuation {}", phase)
logger.info("Injected caller-requested continuation {}", phase)
return True, injection_cycles
def _build_goal_continue_message(self, spec: AgentRunSpec) -> dict[str, str]:
custom = spec.goal_continue_message
if callable(custom):
try:
custom = custom()
except Exception:
logger.exception("goal_continue_message callback failed")
custom = None
return build_goal_continue_message(custom)
@staticmethod
def _build_continuation_message(spec: AgentRunSpec) -> dict[str, str] | None:
callback = spec.continuation_callback
if callback is None:
return None
try:
content = callback()
except Exception:
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(
self,
@@ -770,7 +771,7 @@ class AgentRunner:
conversation_state=conversation_state,
phase="after final response",
iteration=iteration,
allow_goal_continue=(
allow_continuation=(
response.finish_reason not in {"refusal", "content_filter"}
),
wait_at_terminal=(
@@ -953,7 +954,6 @@ class AgentRunner:
progress_callback = spec.progress_callback
wants_progress_streaming = (
not wants_streaming
and spec.stream_progress_deltas
and progress_callback is not None
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
)
-12
View File
@@ -40,13 +40,6 @@ LENGTH_RECOVERY_PROMPT = (
"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:
"""Short prompt-safe marker for tools that completed without visible 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}
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:
"""Stable signature for repeated external lookups we want to throttle."""
if not isinstance(arguments, dict):
+4 -3
View File
@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.filesystem import ReadFileTool
from nanobot.bus.events import InboundMessage
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(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
runtime=runtime,
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
pending_queue=pending_queue,
)
+11 -6
View File
@@ -13,6 +13,7 @@ from nanobot.agent.hook import (
AgentTurnHookContext,
CompositeHook,
)
from nanobot.agent.tools.context import RequestContext
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):
pass
runtime = loop.llm_runtime()
await loop._run_agent_loop(
[{"role": "user", "content": "hi"}],
runtime=loop.llm_runtime(),
runtime=runtime,
on_progress=on_progress,
channel="websocket",
chat_id="chat-1",
message_id="msg-1",
metadata={"source": "test"},
session_key="websocket:chat-1",
request_context=RequestContext(
channel="websocket",
chat_id="chat-1",
message_id="msg-1",
session_key="websocket:chat-1",
runtime=runtime,
metadata={"source": "test"},
),
hook_factories=[factory("turn")],
)
+9 -2
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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.config.schema import AgentDefaults
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.max_iterations = 2
runtime = loop.llm_runtime()
result = await loop._run_agent_loop(
[],
runtime=loop.llm_runtime(),
metadata={"original_command": "/goal"},
runtime=runtime,
request_context=RequestContext(
channel="cli",
chat_id="direct",
runtime=runtime,
metadata={"original_command": "/goal"},
),
)
assert result.stop_reason == "max_iterations"
+14 -9
View File
@@ -1543,7 +1543,8 @@ async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path:
assert result is not None
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
@@ -1592,12 +1593,12 @@ async def test_process_message_uses_explicit_session_for_goal_context(
assert result.content == "ok"
kwargs = loop._run_agent_loop.call_args.kwargs
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
@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,
) -> None:
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] = {}
async def fake_run(spec):
assert callable(spec.goal_continue_message)
assert callable(spec.continuation_callback)
session.metadata[GOAL_STATE_KEY] = {
"status": "active",
"objective": "Goal created during this runner call.",
}
seen["goal_continue"] = spec.goal_continue_message()
seen["goal_continue"] = spec.continuation_callback()
return AgentRunResult(
final_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]
runtime = loop.llm_runtime()
await loop._run_agent_loop(
[],
runtime=loop.llm_runtime(),
runtime=runtime,
session=session,
channel="websocket",
chat_id="late-goal",
session_key=session.key,
request_context=RequestContext(
channel="websocket",
chat_id="late-goal",
session_key=session.key,
runtime=runtime,
),
)
assert "Goal created during this runner call." in (seen["goal_continue"] or "")
+14 -8
View File
@@ -135,10 +135,13 @@ async def test_loop_binds_request_context_for_tool_execution(tmp_path: Path) ->
await loop._run_agent_loop(
[],
runtime=runtime,
channel="slack",
chat_id="C123",
metadata=metadata,
session_key="slack:C123:111.222",
request_context=RequestContext(
channel="slack",
chat_id="C123",
session_key="slack:C123:111.222",
runtime=runtime,
metadata=metadata,
),
)
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(
[],
runtime=runtime,
channel="slack",
chat_id="C123",
session_key="slack:C123:111.222",
original_user_text=" unchanged user text ",
request_context=RequestContext(
channel="slack",
chat_id="C123",
session_key="slack:C123:111.222",
original_user_text=" unchanged user text ",
runtime=runtime,
),
)
assert current_request_context() is outer
finally:
+1 -2
View File
@@ -843,7 +843,6 @@ async def test_runner_closes_progress_reasoning_on_streaming_wall_timeout():
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=ProgressReasoningHook(),
progress_callback=AsyncMock(),
stream_progress_deltas=True,
llm_timeout_s=1,
))
@@ -994,7 +993,7 @@ async def test_runner_does_not_auto_continue_goal_after_policy_terminal(
model="test-model",
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
continuation_callback=lambda: "Continue working.",
terminal_injection_callback=terminal_injection_callback,
))
+31 -29
View File
@@ -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
stop_reason="completed" after a plain-text final response. Instead it should
inject a continuation message and keep looping (similar to mid-turn injection).
When the continuation callback returns a message, the runner must not exit with
stop_reason="completed" after a plain-text final response. Instead it injects
that message and keeps looping, similar to a mid-turn injection.
"""
from __future__ import annotations
@@ -18,9 +18,13 @@ from nanobot.providers.base import LLMProvider, LLMResponse
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
def _continue_goal() -> str:
return "Continue working toward the active sustained goal."
@pytest.mark.asyncio
async def test_runner_exits_normally_without_predicate():
"""Baseline: no predicate, runner exits with completed on final text."""
async def test_runner_exits_normally_without_continuation_callback():
"""Without a continuation request, final text completes the run."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -44,8 +48,8 @@ async def test_runner_exits_normally_without_predicate():
@pytest.mark.asyncio
async def test_runner_exits_normally_with_inactive_goal():
"""Predicate returns False, runner should exit normally."""
async def test_runner_exits_normally_when_continuation_callback_returns_none():
"""A callback returning None leaves the final response terminal."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -62,7 +66,7 @@ async def test_runner_exits_normally_with_inactive_goal():
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: False,
continuation_callback=lambda: None,
))
assert result.stop_reason == "completed"
@@ -70,8 +74,8 @@ async def test_runner_exits_normally_with_inactive_goal():
@pytest.mark.asyncio
async def test_runner_forces_continue_when_goal_active():
"""Predicate returns True on final text → runner injects continuation and loops.
async def test_runner_continues_when_callback_returns_message():
"""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.
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",
max_iterations=3,
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.
assert result.stop_reason == "max_iterations"
# 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
async def test_runner_respects_max_iterations_even_with_active_goal():
"""A single iteration with active goal still hits max_iterations."""
async def test_runner_respects_max_iterations_with_continuation():
"""A continuation request after one iteration still hits max_iterations."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -124,15 +128,15 @@ async def test_runner_respects_max_iterations_even_with_active_goal():
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
continuation_callback=_continue_goal,
))
assert result.stop_reason == "max_iterations"
@pytest.mark.asyncio
async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
"""Synthetic goal continuation should be governed by max_iterations."""
async def test_runner_continuation_not_limited_by_injection_cycle_cap():
"""Caller-requested continuation is governed by max_iterations."""
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -150,7 +154,7 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
model="test-model",
max_iterations=max_iterations,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
continuation_callback=_continue_goal,
finalize_on_max_iterations=False,
))
@@ -159,8 +163,8 @@ async def test_runner_goal_continue_not_limited_by_injection_cycle_cap():
@pytest.mark.asyncio
async def test_runner_does_not_force_continue_on_error():
"""Even with active goal, an LLM error should exit with stop_reason="error"."""
async def test_runner_does_not_continue_on_error():
"""An LLM error remains terminal even when continuation is available."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -178,15 +182,15 @@ async def test_runner_does_not_force_continue_on_error():
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
continuation_callback=_continue_goal,
))
assert result.stop_reason == "error"
@pytest.mark.asyncio
async def test_runner_uses_custom_goal_continue_message():
"""Custom goal_continue_message should be injected instead of the default."""
async def test_runner_injects_continuation_callback_message():
"""The callback result becomes the injected user message."""
from nanobot.agent.runner import AgentRunner
provider = MagicMock(spec=LLMProvider)
@@ -205,8 +209,7 @@ async def test_runner_uses_custom_goal_continue_message():
model="test-model",
max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
goal_continue_message=custom_msg,
continuation_callback=lambda: custom_msg,
))
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
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."""
from nanobot.agent.runner import AgentRunner
@@ -237,8 +240,7 @@ async def test_runner_resolves_goal_continue_message_lazily():
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
goal_active_predicate=lambda: True,
goal_continue_message=dynamic_msg,
continuation_callback=dynamic_msg,
finalize_on_max_iterations=False,
))
+24 -19
View File
@@ -10,6 +10,7 @@ import pytest
from agent.runner_helpers import make_run_spec
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.providers.base import LLMResponse, ToolCallRequest
@@ -390,13 +391,13 @@ async def test_goal_continuation_precedes_terminal_wait():
])
tools = MagicMock()
tools.get_definitions.return_value = []
goal_checks = 0
continuation_checks = 0
terminal_waits = 0
def goal_active() -> bool:
nonlocal goal_checks
goal_checks += 1
return goal_checks == 1
def continue_goal() -> str | None:
nonlocal continuation_checks
continuation_checks += 1
return "Continue the active goal." if continuation_checks == 1 else None
async def drain_available():
return []
@@ -415,7 +416,7 @@ async def test_goal_continuation_precedes_terminal_wait():
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
injection_callback=drain_available,
terminal_injection_callback=wait_at_terminal,
goal_active_predicate=goal_active,
continuation_callback=continue_goal,
))
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)],
))
runtime = loop.llm_runtime()
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
runtime=runtime,
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
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(
[{"role": "user", "content": "initial message from user A"}],
runtime=loop.llm_runtime(),
runtime=runtime,
session=session,
channel="telegram",
chat_id="group-1",
session_key=session.key,
request_context=RequestContext(
channel="telegram",
chat_id="group-1",
session_key=session.key,
runtime=runtime,
),
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"},
))
runtime = loop.llm_runtime()
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
runtime=runtime,
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
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}",
))
runtime = loop.llm_runtime()
result = await loop._run_agent_loop(
[{"role": "user", "content": "hello"}],
runtime=loop.llm_runtime(),
channel="cli",
chat_id="c",
runtime=runtime,
request_context=RequestContext(channel="cli", chat_id="c", runtime=runtime),
pending_queue=pending_queue,
)
@@ -17,39 +17,6 @@ from nanobot.providers.base import LLMResponse, ToolCallRequest
_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
async def test_runner_streams_provider_progress_deltas_by_default():
"""Direct runner users keep the existing opt-in provider progress behavior."""
-2
View File
@@ -232,7 +232,6 @@ async def test_runner_emits_reasoning_content_even_when_answer_was_streamed():
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
stream_progress_deltas=True,
progress_callback=_progress,
))
@@ -276,7 +275,6 @@ async def test_runner_does_not_double_emit_when_inline_think_already_streamed():
max_iterations=3,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
hook=hook,
stream_progress_deltas=True,
progress_callback=_progress,
))
+12 -6
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.agent.tools.context import RequestContext
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import GenerationSettings
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)
runtime = loop.llm_runtime()
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
runtime=loop.llm_runtime(),
runtime=runtime,
session=None,
channel="test",
chat_id="c1",
request_context=RequestContext(channel="test", chat_id="c1", runtime=runtime),
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._running_tasks["sub-timeout-1"] = hang_task
runtime = loop.llm_runtime()
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
runtime=loop.llm_runtime(),
runtime=runtime,
session=session,
channel="test",
chat_id="c1",
request_context=RequestContext(
channel="test",
chat_id="c1",
session_key=session.key,
runtime=runtime,
),
pending_queue=pending_queue,
)