diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index d87c748e2..7e4610049 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -32,7 +32,11 @@ from nanobot.command import CommandContext, CommandRouter, register_builtin_comm from nanobot.config.schema import AgentDefaults, ModelPresetConfig from nanobot.providers.base import LLMProvider from nanobot.providers.factory import ProviderSnapshot -from nanobot.session.goal_state import goal_state_runtime_lines, goal_state_ws_blob +from nanobot.session.goal_state import ( + goal_state_runtime_lines, + goal_state_ws_blob, + sustained_goal_active, +) from nanobot.session.manager import Session, SessionManager from nanobot.utils.artifacts import generated_image_paths_from_messages from nanobot.utils.document import extract_documents @@ -789,6 +793,13 @@ class AgentLoop: retry_wait_callback=on_retry_wait, checkpoint_callback=_checkpoint, injection_callback=_drain_pending, + # Sustained goals may legitimately exceed NANOBOT_LLM_TIMEOUT_S; idle stall + # is still capped by NANOBOT_STREAM_IDLE_TIMEOUT_S in streaming providers. + llm_timeout_s=( + 0.0 + if session is not None and sustained_goal_active(session.metadata) + else None + ), )) finally: reset_file_states(file_state_token) diff --git a/nanobot/session/goal_state.py b/nanobot/session/goal_state.py index 2f32e6c25..9992dd789 100644 --- a/nanobot/session/goal_state.py +++ b/nanobot/session/goal_state.py @@ -35,6 +35,12 @@ def goal_state_raw(metadata: Mapping[str, Any] | None) -> Any: return _session_goal_raw(metadata) +def sustained_goal_active(metadata: Mapping[str, Any] | None) -> bool: + """True when this session has an active sustained objective (``long_task`` bookkeeping).""" + goal = parse_goal_state(goal_state_raw(metadata)) + return isinstance(goal, dict) and goal.get("status") == "active" + + def parse_goal_state(blob: Any) -> dict[str, Any] | None: if blob is None: return None diff --git a/tests/session/test_goal_state.py b/tests/session/test_goal_state.py index 9a83fd467..991d51513 100644 --- a/tests/session/test_goal_state.py +++ b/tests/session/test_goal_state.py @@ -8,6 +8,7 @@ from nanobot.session.goal_state import ( goal_state_runtime_lines, goal_state_ws_blob, parse_goal_state, + sustained_goal_active, ) @@ -88,3 +89,19 @@ def test_goal_state_ws_blob_active_shape(): "ui_summary": "feat", "objective": "Build feature.", } + + +def test_sustained_goal_active_false_when_missing_or_completed(): + assert sustained_goal_active(None) is False + assert sustained_goal_active({}) is False + assert sustained_goal_active({GOAL_STATE_KEY: {"status": "completed", "objective": "x"}}) is False + + +def test_sustained_goal_active_true_when_active(): + meta = {GOAL_STATE_KEY: {"status": "active", "objective": "Run long task."}} + assert sustained_goal_active(meta) is True + + +def test_sustained_goal_active_respects_legacy_thread_goal_key(): + meta = {"thread_goal": {"status": "active", "objective": "Legacy."}} + assert sustained_goal_active(meta) is True