Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 16 additions & 19 deletions swe_af/fast/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

import logging

from swe_af.agent_ai import AgentAI, AgentAIConfig
from swe_af.fast import fast_router
from swe_af.fast.prompts import FAST_PLANNER_SYSTEM_PROMPT, fast_planner_task_prompt
from swe_af.fast.schemas import FastPlanResult, FastTask
Expand All @@ -20,8 +19,9 @@
def _note(msg: str, tags: list[str] | None = None) -> None:
"""Log a message via fast_router.note() when attached, else fall back to logger."""
try:
# AgentRouter may raise RuntimeError on attribute access if not attached.
fast_router.note(msg, tags=tags or [])
except RuntimeError:
except (RuntimeError, AttributeError):
logger.debug("[fast_planner] %s (tags=%s)", msg, tags)


Expand Down Expand Up @@ -96,38 +96,35 @@ async def fast_plan_tasks(
additional_context=additional_context,
)

ai = AgentAI(
AgentAIConfig(
provider=ai_provider,
# Map 'claude' to 'claude-code' for AgentField router compatibility
provider = "claude-code" if ai_provider == "claude" else ai_provider
try:
res = await fast_router.harness(
prompt=task_prompt,
schema=FastPlanResult,
provider=provider,
model=pm_model,
cwd=repo_path,
max_turns=3,
permission_mode=permission_mode or None,
)
)

try:
response = await ai.run(
task_prompt,
system_prompt=FAST_PLANNER_SYSTEM_PROMPT,
output_schema=FastPlanResult,
cwd=repo_path,
)
except Exception:
logger.exception("fast_plan_tasks: AgentAI.run() raised an exception; using fallback")
plan = res.parsed
except Exception as e:
logger.exception("fast_plan_tasks: fast_router.harness() raised an exception; using fallback")
_note(
"fast_plan_tasks: LLM call failed; returning fallback plan",
tags=["fast_planner", "fallback"],
f"fast_plan_tasks: LLM call failed ({e}); returning fallback plan",
tags=["fast_planner", "fallback", "error"],
)
return _fallback_plan(goal).model_dump()

if response.parsed is None:
if plan is None:
_note(
"fast_plan_tasks: parsed response is None; returning fallback plan",
tags=["fast_planner", "fallback"],
)
return _fallback_plan(goal).model_dump()

plan: FastPlanResult = response.parsed
# Truncate to max_tasks using model_copy to avoid class-identity issues
if len(plan.tasks) > max_tasks:
plan = plan.model_copy(update={"tasks": plan.tasks[:max_tasks]})
Expand Down
16 changes: 16 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ def agentfield_server_guard() -> None:
)


@pytest.fixture(scope="session", autouse=True)
def attach_fast_router() -> None:
"""Explicitly 'attach' fast_router to a mock agent to avoid RuntimeError in tests.

AgentRouter raised RuntimeError on any attribute access if not attached.
This session fixture ensures all tests can safely interact with or patch
fast_router without triggering that check.
"""
from unittest.mock import MagicMock

from swe_af.fast import fast_router
# Set the private _agent attribute to satisfy AgentRouter's attachment check.
# We use object.__setattr__ to avoid any potential __setattr__ guards.
object.__setattr__(fast_router, "_agent", MagicMock())


# ---------------------------------------------------------------------------
# mock_agent_ai fixture
# ---------------------------------------------------------------------------
Expand Down
6 changes: 6 additions & 0 deletions tests/fast/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ def _reset_fast_router() -> None: # type: ignore[return]
# Re-import swe_af.fast — this recreates fast_router and re-registers
# all the @fast_router.reasoner() wrappers with original func references.
importlib.import_module("swe_af.fast")

# Explicitly 'attach' the fresh fast_router to a mock agent to avoid RuntimeError.
from unittest.mock import MagicMock
from swe_af.fast import fast_router
object.__setattr__(fast_router, "_agent", MagicMock())

# Re-import sub-modules that register reasoners on the fresh fast_router.
for mod in (
"swe_af.fast.executor",
Expand Down
50 changes: 25 additions & 25 deletions tests/fast/test_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ def test_valid_llm_response_produces_fast_plan_result(self) -> None:
)
mock_response = _make_mock_response(plan)

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(
goal="Build a REST API",
Expand All @@ -164,10 +164,10 @@ def test_llm_parsed_none_triggers_fallback(self) -> None:

mock_response = _make_mock_response(None)

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(
goal="Build something",
Expand All @@ -184,11 +184,11 @@ def test_llm_parsed_none_triggers_fallback(self) -> None:
def test_llm_exception_triggers_fallback(self) -> None:
"""When AgentAI.run() raises, the fallback plan is returned."""
from swe_af.fast.planner import fast_plan_tasks

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(side_effect=RuntimeError("LLM connection error"))
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(side_effect=RuntimeError("LLM connection error"))
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(
goal="Build something",
Expand All @@ -205,10 +205,10 @@ def test_fallback_contains_at_least_one_task(self) -> None:

mock_response = _make_mock_response(None)

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(goal="Any goal", repo_path="/repo"))

Expand All @@ -229,10 +229,10 @@ def test_max_tasks_one_truncates_result(self) -> None:
plan = FastPlanResult(tasks=many_tasks, rationale="Many tasks.")
mock_response = _make_mock_response(plan)

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(
goal="Build a thing",
Expand All @@ -250,10 +250,10 @@ def test_max_tasks_respected_when_llm_returns_exact_count(self) -> None:
plan = FastPlanResult(tasks=tasks, rationale="Exactly 3 tasks.")
mock_response = _make_mock_response(plan)

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(fast_plan_tasks(
goal="Build a thing",
Expand Down
16 changes: 8 additions & 8 deletions tests/test_malformed_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ def test_fast_plan_tasks_missing_tasks_field_triggers_fallback() -> None:
mock_response = MagicMock()
mock_response.parsed = None

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(return_value=mock_response)
MockAgentAI.return_value = instance
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(return_value=mock_response)
mock_router.note = MagicMock()

result = _run(
fast_plan_tasks(
Expand All @@ -79,12 +79,12 @@ def test_fast_plan_tasks_exception_in_run_triggers_fallback() -> None:
"""When AgentAI.run raises an exception, the planner falls back gracefully."""
from swe_af.fast.planner import fast_plan_tasks

with patch("swe_af.fast.planner.AgentAI") as MockAgentAI:
instance = MagicMock()
instance.run = AsyncMock(
with patch("swe_af.fast.planner._note"), \
patch("swe_af.fast.planner.fast_router") as mock_router:
mock_router.harness = AsyncMock(
side_effect=ValueError("Response missing required 'tasks' field")
)
MockAgentAI.return_value = instance
mock_router.note = MagicMock()

result = _run(
fast_plan_tasks(
Expand Down
Loading