Skip to content

Commit fcb8420

Browse files
committed
fix(review-feedback-1003): address latest review comments
1 parent 512216f commit fcb8420

6 files changed

Lines changed: 304 additions & 21 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
2626
- [文档] FAQ 补充 Ollama `OllamaException / APIConnectionError` 连接失败排障条目(Q12c),覆盖服务未启动、URL 配置错误、模型前缀缺失、模型未下载、远程防火墙等 5 个检查点
2727
- [修复] 技能加载异常被静默吞没问题 — 在 ask.py、skills/aggregator.py、skills/router.py 的静默 except 块补充 logger.warning 日志,确保技能列表为空时有日志可查(fixes #970
2828
- [修复] SQLite 主写入链路现在对 `stock_daily(code,date)` 使用批量原子 upsert,并在文件型 SQLite 连接上默认启用 `WAL``busy_timeout` 与有限写入重试,降低批量分析和并发回写场景下的锁竞争与吞吐抖动,返回值中的“新增数”改为按本次真正插入窗口计算(并发场景不再把并行写入行误算入当前调用)。
29+
- [修复] 优化多 Agent 与单 Agent 的预算护栏语义:当后续阶段/步骤剩余预算低于最小阈值(首阶段除外)时会主动跳过并进行降级处理;在已有阶段结果可用于合成仪表盘时返回 `success=True``content` 非空的降级结果,若无可恢复输出则返回 `success=False``content=""`;同时补齐 `run_agent_loop``orchestrator` 的边界回归用例,并同步 `ResearchAgent._research_sub_question` 对“预算不足”语义的识别,避免单次子问题在预算耗尽时被静默降为空内容。
2930

3031
## [3.12.0] - 2026-04-01
3132

src/agent/orchestrator.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,51 @@ def _build_timeout_result(
152152
model=model,
153153
)
154154

155+
def _build_budget_skip_result(
156+
self,
157+
stats: AgentRunStats,
158+
all_tool_calls: List[Dict[str, Any]],
159+
models_used: List[str],
160+
elapsed_s: float,
161+
timeout_s: int,
162+
stage_name: str,
163+
remaining_budget: float,
164+
min_stage_budget_s: int,
165+
ctx: Optional[AgentContext] = None,
166+
parse_dashboard: bool = True,
167+
) -> OrchestratorResult:
168+
"""Build a result for budget-insufficient stage skip (non-timeout semantics)."""
169+
stats.total_duration_s = round(elapsed_s, 2)
170+
stats.models_used = list(dict.fromkeys(models_used))
171+
dashboard = None
172+
content = ""
173+
if ctx is not None:
174+
dashboard, content = self._resolve_final_output(ctx, parse_dashboard=parse_dashboard)
175+
if parse_dashboard and dashboard is not None:
176+
dashboard = self._mark_partial_dashboard(
177+
dashboard,
178+
note="多 Agent 预算不足,以下结论基于已完成阶段自动降级生成。",
179+
)
180+
ctx.set_data("final_dashboard", dashboard)
181+
content = json.dumps(dashboard, ensure_ascii=False, indent=2)
182+
183+
return OrchestratorResult(
184+
success=bool(content) if (not parse_dashboard or dashboard is not None) else False,
185+
content=content,
186+
dashboard=dashboard,
187+
error=(
188+
f"Pipeline skipped before stage '{stage_name}' due to insufficient budget "
189+
f"({remaining_budget:.1f}s remaining, minimum {min_stage_budget_s}s required)"
190+
),
191+
stats=stats,
192+
total_steps=stats.total_stages,
193+
total_tokens=stats.total_tokens,
194+
tool_calls_log=all_tool_calls,
195+
provider=stats.models_used[0] if stats.models_used else "",
196+
model=", ".join(stats.models_used),
197+
)
198+
199+
155200
def _prepare_agent(self, agent: Any) -> Any:
156201
"""Apply orchestrator-level runtime settings to a child agent."""
157202
if hasattr(agent, "max_steps"):
@@ -313,22 +358,31 @@ def _execute_pipeline(
313358
# completed so that the first stage always gets a chance to run
314359
# even when the total budget is small.
315360
_MIN_STAGE_BUDGET_S = 15
361+
_MIN_TOOLLESS_STAGE_BUDGET_S = 0
316362

317363
while index < len(agents):
318364
agent = agents[index]
319365
elapsed_s = time.time() - t0
320366
remaining_budget = timeout_s - elapsed_s if timeout_s else None
321-
budget_exhausted = (
367+
tool_names = getattr(agent, "tool_names", None)
368+
stage_min_budget_s = (
369+
_MIN_TOOLLESS_STAGE_BUDGET_S
370+
if isinstance(tool_names, (list, tuple, set, frozenset)) and len(tool_names) == 0
371+
else _MIN_STAGE_BUDGET_S
372+
)
373+
timeout_exhausted = (
322374
timeout_s
323375
and remaining_budget is not None
324-
and (
325-
remaining_budget <= 0
326-
or (index > 0 and remaining_budget < _MIN_STAGE_BUDGET_S)
327-
)
376+
and remaining_budget <= 0
328377
)
329-
if budget_exhausted:
330-
reason = "timed out" if remaining_budget <= 0 else f"insufficient budget ({remaining_budget:.1f}s < {_MIN_STAGE_BUDGET_S}s)"
331-
logger.error("[Orchestrator] pipeline %s before stage '%s'", reason, agent.agent_name)
378+
budget_guard_triggered = (
379+
timeout_s
380+
and remaining_budget is not None
381+
and index > 0
382+
and remaining_budget < stage_min_budget_s
383+
)
384+
if timeout_exhausted:
385+
logger.error("[Orchestrator] pipeline timed out before stage '%s'", agent.agent_name)
332386
if progress_callback:
333387
progress_callback({
334388
"type": "pipeline_timeout",
@@ -346,6 +400,33 @@ def _execute_pipeline(
346400
parse_dashboard=parse_dashboard,
347401
)
348402

403+
if budget_guard_triggered:
404+
logger.warning(
405+
"[Orchestrator] pipeline insufficient budget before stage '%s' (%.1fs remaining, min %ds)",
406+
agent.agent_name,
407+
remaining_budget,
408+
stage_min_budget_s,
409+
)
410+
if progress_callback:
411+
progress_callback({
412+
"type": "pipeline_timeout",
413+
"stage": agent.agent_name,
414+
"elapsed": round(elapsed_s, 2),
415+
"timeout": timeout_s,
416+
})
417+
return self._build_budget_skip_result(
418+
stats,
419+
all_tool_calls,
420+
models_used,
421+
elapsed_s,
422+
timeout_s,
423+
agent.agent_name,
424+
remaining_budget,
425+
stage_min_budget_s,
426+
ctx=ctx,
427+
parse_dashboard=parse_dashboard,
428+
)
429+
349430
if (
350431
self.mode == "specialist"
351432
and agent.agent_name == "decision"

src/agent/research.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,12 @@ def _resolve_step_timeout(default_timeout: int, timeout_seconds: Optional[float]
236236
def _looks_like_timeout_error(error: Any) -> bool:
237237
"""Best-effort detection for timeout-like failures from lower layers."""
238238
message = str(error or "").lower()
239-
return "timed out" in message or "timeout" in message
239+
return (
240+
"timed out" in message
241+
or "timeout" in message
242+
or "insufficient budget" in message
243+
or "budget too low" in message
244+
)
240245

241246
@staticmethod
242247
def _build_timeout_result(

src/agent/runner.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,35 @@ def _build_timeout_result(
324324
)
325325

326326

327+
def _build_budget_guard_result(
328+
*,
329+
start_time: float,
330+
step: int,
331+
tool_calls_log: List[Dict[str, Any]],
332+
total_tokens: int,
333+
provider_used: str,
334+
models_used: List[str],
335+
messages: List[Dict[str, Any]],
336+
remaining_timeout_s: float,
337+
min_step_budget_s: float,
338+
) -> RunLoopResult:
339+
elapsed = time.time() - start_time
340+
return RunLoopResult(
341+
success=False,
342+
content="",
343+
tool_calls_log=tool_calls_log,
344+
total_steps=step,
345+
total_tokens=total_tokens,
346+
provider=provider_used,
347+
models_used=models_used,
348+
error=(
349+
"Agent step skipped due to insufficient budget: "
350+
f"{remaining_timeout_s:.2f}s remaining, minimum {min_step_budget_s:.1f}s required"
351+
),
352+
messages=messages,
353+
)
354+
355+
327356
# ============================================================
328357
# Core loop
329358
# ============================================================
@@ -379,23 +408,35 @@ def run_agent_loop(
379408

380409
for step in range(max_steps):
381410
remaining_timeout = _remaining_timeout_seconds(start_time, max_wall_clock_seconds)
382-
budget_exhausted = (
383-
remaining_timeout is not None
384-
and (
385-
remaining_timeout <= 0
386-
or (step > 0 and remaining_timeout <= _MIN_STEP_BUDGET_S)
387-
)
411+
timeout_exhausted = remaining_timeout is not None and remaining_timeout <= 0
412+
budget_guard_triggered = (
413+
not timeout_exhausted
414+
and remaining_timeout is not None
415+
and step > 0
416+
and remaining_timeout <= _MIN_STEP_BUDGET_S
388417
)
389-
if budget_exhausted:
390-
if remaining_timeout <= 0:
391-
logger.warning("Agent timed out before step %d", step + 1)
392-
else:
418+
if timeout_exhausted or budget_guard_triggered:
419+
if budget_guard_triggered:
393420
logger.warning(
394-
"Agent budget too low for step %d (%.1fs remaining, min %.1fs) — treating as timeout",
421+
"Agent budget too low for step %d (%.1fs remaining, min %.1fs)",
395422
step + 1,
396423
remaining_timeout,
397424
_MIN_STEP_BUDGET_S,
398425
)
426+
return _build_budget_guard_result(
427+
start_time=start_time,
428+
step=step,
429+
tool_calls_log=tool_calls_log,
430+
total_tokens=total_tokens,
431+
provider_used=provider_used,
432+
models_used=models_used,
433+
messages=messages,
434+
remaining_timeout_s=remaining_timeout,
435+
min_step_budget_s=_MIN_STEP_BUDGET_S,
436+
)
437+
438+
if remaining_timeout <= 0:
439+
logger.warning("Agent timed out before step %d", step + 1)
399440
return _build_timeout_result(
400441
start_time=start_time,
401442
max_wall_clock_seconds=float(max_wall_clock_seconds),

tests/test_agent_executor.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import sys
1818
import os
1919
from dataclasses import dataclass
20-
from unittest.mock import MagicMock
20+
from unittest.mock import MagicMock, patch
2121

2222
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
2323

@@ -580,6 +580,38 @@ def _capture_timeout(*_args, **kwargs):
580580
self.assertGreater(captured["timeout"], 0.0)
581581
self.assertLessEqual(captured["timeout"], 1.0)
582582

583+
def test_min_step_budget_skips_followup_llm_call(self):
584+
"""When step>0 and remaining budget is too small, no extra LLM call should be made."""
585+
registry = _make_registry_with_echo()
586+
adapter = _make_mock_adapter()
587+
adapter.call_with_tools.return_value = LLMResponse(
588+
content="Need one tool first.",
589+
tool_calls=[ToolCall(id="echo_1", name="echo", arguments={"message": "hello"})],
590+
usage={"total_tokens": 10},
591+
provider="openai",
592+
)
593+
594+
with patch(
595+
"src.agent.runner._remaining_timeout_seconds",
596+
side_effect=[9.0, 9.0, 7.5, 7.5],
597+
):
598+
result = run_agent_loop(
599+
messages=[
600+
{"role": "system", "content": "system"},
601+
{"role": "user", "content": "Analyze"},
602+
],
603+
tool_registry=registry,
604+
llm_adapter=adapter,
605+
max_steps=3,
606+
max_wall_clock_seconds=10.0,
607+
)
608+
609+
self.assertFalse(result.success)
610+
self.assertIn("insufficient budget", (result.error or "").lower())
611+
self.assertEqual(adapter.call_with_tools.call_count, 1)
612+
self.assertEqual(len(result.tool_calls_log), 1)
613+
self.assertEqual(result.total_steps, 1)
614+
583615

584616
# ============================================================
585617
# Dashboard parsing

0 commit comments

Comments
 (0)