fix: add minimum time budget guard for agent stages and steps (#1003)

* fix: add minimum budget guard to prevent wasted LLM calls near timeout

When earlier pipeline stages consume most of the timeout budget, later
stages and ReAct loop steps start with insufficient time, virtually
guaranteeing a mid-call timeout that still bills a full LLM request.

Changes:
- Runner: skip step when remaining budget < 8s (step > 0 only)
- Orchestrator: skip stage when remaining budget < 15s (index > 0 only)
- First step/stage always runs regardless of budget size, so small
  overall timeouts still work for simple pipelines and tests
This commit is contained in:
mumu
2026-04-05 10:21:12 +08:00
committed by GitHub
parent 6eab96f8e1
commit de5c5376a2
6 changed files with 323 additions and 5 deletions

View File

@@ -17,7 +17,7 @@ import unittest
import sys
import os
from dataclasses import dataclass
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
@@ -580,6 +580,38 @@ class TestAgentExecutor(unittest.TestCase):
self.assertGreater(captured["timeout"], 0.0)
self.assertLessEqual(captured["timeout"], 1.0)
def test_min_step_budget_skips_followup_llm_call(self):
"""When step>0 and remaining budget is too small, no extra LLM call should be made."""
registry = _make_registry_with_echo()
adapter = _make_mock_adapter()
adapter.call_with_tools.return_value = LLMResponse(
content="Need one tool first.",
tool_calls=[ToolCall(id="echo_1", name="echo", arguments={"message": "hello"})],
usage={"total_tokens": 10},
provider="openai",
)
with patch(
"src.agent.runner._remaining_timeout_seconds",
side_effect=[9.0, 9.0, 7.5, 7.5],
):
result = run_agent_loop(
messages=[
{"role": "system", "content": "system"},
{"role": "user", "content": "Analyze"},
],
tool_registry=registry,
llm_adapter=adapter,
max_steps=3,
max_wall_clock_seconds=10.0,
)
self.assertFalse(result.success)
self.assertIn("insufficient budget", (result.error or "").lower())
self.assertEqual(adapter.call_with_tools.call_count, 1)
self.assertEqual(len(result.tool_calls_log), 1)
self.assertEqual(result.total_steps, 1)
# ============================================================
# Dashboard parsing