Fix: multi-agent AGENT_MAX_STEPS not respecting user override & skill agent graceful degradation (#1031)

* Fix multi-agent AGENT_MAX_STEPS not respecting user override and skill agent failure handling

- Fix AGENT_MAX_STEPS ceiling-only logic: when user raises value above default
  (10), all sub-agents now adopt the global value instead of being capped at
  their per-agent defaults (fixes #1026)
- Fix skill agent failure aborting entire pipeline: specialist/skill agents now
  degrade gracefully like intel/risk stages
- Improve max-steps-exceeded error message with AGENT_MAX_STEPS guidance
- Sync config descriptions in README, .env.example, and config_registry
This commit is contained in:
mumu
2026-04-10 20:52:01 +08:00
committed by GitHub
parent 04933583a9
commit 3dceb1eca8
13 changed files with 123 additions and 32 deletions

View File

@@ -160,7 +160,7 @@ SEARXNG_PUBLIC_INSTANCES_ENABLED=true
# AGENT_MODE=true
# Agent 主模型(可选):留空时继承主模型;无 provider 前缀会按 openai/<model> 解析
# AGENT_LITELLM_MODEL=
# Agent 最大推理步数上限(多 Agent orchestrator 模式下作为各子 Agent ceiling不会抬高内建较低默认值
# Agent 最大推理步数上限(默认 10 时各子 Agent 按自身预设运行;高于默认 10 时所有子 Agent 统一采用该值;低于某子 Agent 默认值时会作为上限进行封顶
# AGENT_MAX_STEPS=10
# 默认启用策略(逗号分隔),不配置时使用以下内置默认值
#

View File

@@ -211,7 +211,7 @@
| `AGENT_MODE` | 开启 Agent 策略问股模式(内部统一命名为 skill`true`/`false`,默认 false | 可选 |
| `AGENT_LITELLM_MODEL` | Agent 主模型(可选);留空继承主模型,无前缀会按 `openai/<model>` 解析 | 可选 |
| `AGENT_SKILLS` | 激活的策略技能 id逗号分隔`all` 启用全部策略技能;留空时使用主默认策略 skill内置默认是 `bull_trend`),详见 `.env.example` | 可选 |
| `AGENT_MAX_STEPS` | Agent 最大推理步数上限(默认 10多 Agent orchestrator 模式下按 `min(子 Agent 默认值, AGENT_MAX_STEPS)` 生效,不会抬高低默认值 Agent 的步数 | 可选 |
| `AGENT_MAX_STEPS` | Agent 最大推理步数上限(默认 `10`);保持默认时各子 Agent 按自身预设步数运行;用户主动调高到高于默认值时,所有子 Agent 统一采用该值;若设置值低于某子 Agent 的默认步数,则仍按该值作为上限进行限制 | 可选 |
| `AGENT_SKILL_DIR` | 自定义策略技能目录(默认沿用内置 `strategies/` 兼容路径) | 可选 |
| `TRADING_DAY_CHECK_ENABLED` | 交易日检查(默认 `true`):非交易日跳过执行;设为 `false` 或使用 `--force-run` 强制执行 | 可选 |
| `ENABLE_CHIP_DISTRIBUTION` | 启用筹码分布Actions 默认 false需筹码数据时在 Variables 中设为 true接口可能不稳定 | 可选 |

View File

@@ -129,7 +129,7 @@ const fieldDescriptionMap: Record<string, string> = {
LOG_LEVEL: '设置日志输出级别。',
WEBUI_PORT: 'Web 页面服务监听端口。',
AGENT_MODE: '是否启用 ReAct Agent 策略问股。对外文案仍叫“策略”,内部配置字段统一使用 skill。',
AGENT_MAX_STEPS: 'Agent 思考和调用工具的最大步数。',
AGENT_MAX_STEPS: 'Agent 最大推理步数上限。保持默认 10 时,各子 Agent 按自身预设步数运行;调高到高于默认值时,所有子 Agent 统一采用该值;调低到低于某子 Agent 默认值时,该 Agent 会被封顶。',
AGENT_SKILLS: '逗号分隔的交易策略列表。留空时使用 metadata 里声明的主默认策略 skill内置默认是 bull_trend也可填写 all 启用全部策略。',
AGENT_SKILL_DIR: '存放 Agent 策略定义文件的目录路径,支持 YAML 与 SKILL.md bundle。',
AGENT_ARCH: "选择 Agent 执行架构。single 为经典单 Agentmulti 为多 Agent 编排(实验性)。",

View File

@@ -12,8 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [修复] `AGENT_MAX_STEPS` 在 orchestrator 多 Agent 模式下改为作为各子 Agent 的步数上限而非硬覆盖TechnicalAgent 等高默认值 Agent 会被封顶,低默认值 Agent 保持原值,减少不必要的 LLM 调用膨胀与配额消耗。
- [修复] 大盘复盘链路接入 `REPORT_LANGUAGE``REPORT_LANGUAGE=en`A 股/合并复盘的 Prompt、章节标题、模板兜底文案与通知包装标题统一改为英文避免出现英文正文外包中文标题的问题。
- [修复] `AGENT_MAX_STEPS` 在 orchestrator 多 Agent 模式下统一明确为“默认作为各子 Agent 的步数上限而非硬覆盖TechnicalAgent 等高默认值 Agent 会被封顶、低默认值 Agent 保持原值;当用户主动调高(>10再统一覆盖所有子 Agent 采用全局值”,同时修复用户设置 12 但 TechnicalAgent 仍以默认 6 步运行并报 "Agent exceeded max steps" 的问题fixes #1026
- [修复] SpecialistSkillAgent 失败不再中断整个分析管线,改为与 intel/risk 相同的优雅降级策略
- [改进] Agent 超步数错误信息增加 AGENT_MAX_STEPS 调整提示,帮助用户自助排查
- [修复] **MiniMax-M2.7 模型连接测试支持** — 修复 LLM 通道连接测试在 MiniMax-M2.7 模型下返回 "Empty response" 的问题;增加了 `max_tokens` 上限8→256以容纳 MiniMax 思考过程,并添加 `content_blocks` 格式解析逻辑统一处理 MiniMax 响应格式差异。
- [修复] 移除 `HistoryItem``ReportSummary` 响应 Schema 中 `sentiment_score``ge=0/le=100` 约束fixes #942)——历史库中存储的超范围负值或大于 100 的情绪评分不再触发 Pydantic ValidationError历史列表与详情接口恢复正常返回。
- [改进] 后端股票名称解析改为优先复用前端 `stocks.index.json` 全量索引并懒加载缓存;纯后端/缺失静态资源场景静默降级回 `STOCK_NAME_MAP` 与原有数据源回退链路。

View File

@@ -155,7 +155,7 @@
| `LONGBRIDGE_PRINT_QUOTE_PACKAGES` | 連線時是否列印行情包(未設定時預設 `false`;設為 `1`/`true`/`yes` 開啟) | 可選 |
| `AGENT_MODE` | 啟用 Agent 策略問股模式(內部統一命名為 skill`true`/`false`,預設 `false` | 可選 |
| `AGENT_LITELLM_MODEL` | Agent 專用主模型(可選);留空時繼承主模型,無 provider 前綴時按 `openai/<model>` 解析 | 可選 |
| `AGENT_MAX_STEPS` | Agent 最大推理步數上限(預設 `10`多 Agent orchestrator 模式下按 `min(子 Agent 預設值, AGENT_MAX_STEPS)` 生效,不會抬高低預設值 Agent 的步數 | 可選 |
| `AGENT_MAX_STEPS` | Agent 最大推理步數上限(預設 `10`保持預設時各子 Agent 依自身預設步數運行;主動調高到高於預設值時,所有子 Agent 統一採用該值;若設定值低於某子 Agent 的預設步數,則仍按該值作為上限進行限制 | 可選 |
| `AGENT_SKILLS` | 逗號分隔的策略技能 id。留空時使用 metadata 宣告的主預設策略 skill內建預設為 `bull_trend`);使用 `all` 可啟用所有已載入策略技能。 | 可選 |
| `AGENT_SKILL_DIR` | 自訂策略技能目錄(預設沿用內建 `strategies/` 相容路徑) | 可選 |

View File

@@ -161,7 +161,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
| `WECHAT_MSG_TYPE` | WeChat Work message type, default `markdown`, set to `text` for plain markdown text | Optional |
| `AGENT_MODE` | Enable Agent strategy chat mode (internally normalized as `skill`, `true`/`false`, default `false`) | Optional |
| `AGENT_LITELLM_MODEL` | Optional Agent-only primary model; when empty it inherits the primary model, and bare names are normalized to `openai/<model>` | Optional |
| `AGENT_MAX_STEPS` | Max reasoning-step ceiling for Agent mode (default `10`); in orchestrator mode each sub-agent uses `min(its default, AGENT_MAX_STEPS)` instead of a hard override | Optional |
| `AGENT_MAX_STEPS` | Max reasoning-step limit for Agent mode (default `10`); at the default each sub-agent keeps its own preset, when raised above the default all sub-agents adopt this value, and when lowered below a sub-agent's preset that sub-agent is capped at this value | Optional |
| `AGENT_SKILLS` | Comma-separated active strategy-skill ids. Leave empty to use the primary default strategy skill declared in metadata (built-in default: `bull_trend`); use `all` to activate every loaded strategy skill. | Optional |
| `AGENT_SKILL_DIR` | Custom strategy-skill directory (default built-in `strategies/` compatibility path) | Optional |

View File

@@ -29,6 +29,8 @@ import logging
from dataclasses import dataclass
from typing import List, Optional
from src.config import AGENT_MAX_STEPS_DEFAULT
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@@ -323,7 +325,7 @@ def build_agent_executor(config=None, skills: Optional[List[str]] = None):
skill_instructions=prompt_state.skill_instructions,
default_skill_policy=prompt_state.default_skill_policy,
use_legacy_default_prompt=prompt_state.use_legacy_default_prompt,
max_steps=getattr(config, "agent_max_steps", 10),
max_steps=getattr(config, "agent_max_steps", AGENT_MAX_STEPS_DEFAULT),
timeout_seconds=getattr(config, "agent_orchestrator_timeout_s", 0),
)
@@ -344,7 +346,7 @@ def _build_orchestrator(config, registry, llm_adapter, skill_manager, *, technic
llm_adapter=llm_adapter,
skill_instructions=skill_manager.get_skill_instructions(),
technical_skill_policy=technical_skill_policy,
max_steps=getattr(config, "agent_max_steps", 10),
max_steps=getattr(config, "agent_max_steps", AGENT_MAX_STEPS_DEFAULT),
mode=mode,
skill_manager=skill_manager,
config=config,

View File

@@ -42,6 +42,7 @@ from src.agent.protocols import (
)
from src.agent.runner import parse_dashboard_json
from src.agent.tools.registry import ToolRegistry
from src.config import AGENT_MAX_STEPS_DEFAULT
from src.report_language import normalize_report_language
if TYPE_CHECKING:
@@ -83,7 +84,7 @@ class AgentOrchestrator:
llm_adapter: LLMToolAdapter,
skill_instructions: str = "",
technical_skill_policy: str = "",
max_steps: int = 10,
max_steps: int = AGENT_MAX_STEPS_DEFAULT,
mode: str = "standard",
skill_manager=None,
config=None,
@@ -200,15 +201,25 @@ class AgentOrchestrator:
def _prepare_agent(self, agent: Any) -> Any:
"""Apply orchestrator-level runtime settings to a child agent.
The orchestrator-level ``max_steps`` acts as a **ceiling** — it will
never *increase* the per-agent limit that each specialised agent
already defines. This prevents a global ``AGENT_MAX_STEPS=10``
from inflating a decision agent (designed for 3 steps) to 10 steps,
which is the primary cause of excessive LLM calls and quota
exhaustion in multi-agent pipelines.
When the orchestrator-level ``max_steps`` equals the default
(``AGENT_MAX_STEPS_DEFAULT``),
each agent keeps its own per-agent limit — this prevents inflating
a decision agent (designed for 3 steps) to 10 steps.
When the user **explicitly** raises the global limit above the
default, all agents adopt the global value so the user's intent to
allow more steps is respected.
When the user **lowers** the global limit below an agent's default,
the agent is capped at the global value.
"""
if hasattr(agent, "max_steps"):
agent.max_steps = min(agent.max_steps, self.max_steps)
if self.max_steps > AGENT_MAX_STEPS_DEFAULT:
# User explicitly raised the limit — apply to all agents.
agent.max_steps = self.max_steps
else:
# Default or lowered — keep per-agent limit as ceiling.
agent.max_steps = min(agent.max_steps, self.max_steps)
return agent
def _callable_accepts_timeout_kwarg(self, func: Any) -> Optional[bool]:
@@ -507,9 +518,16 @@ class AgentOrchestrator:
if result.success and agent.agent_name == "decision":
self._apply_risk_override(ctx)
# Abort pipeline on critical failure (except intel/risk — degrade gracefully)
# Abort pipeline on critical failure.
# Non-critical stages that degrade gracefully:
# - intel / risk (standard support stages)
# - skill agents (specialist evaluation, optional)
if result.status == StageStatus.FAILED:
if agent.agent_name not in ("intel", "risk"):
non_critical = (
agent.agent_name in ("intel", "risk")
or agent.agent_name in getattr(self, "_skill_agent_names", set())
)
if not non_critical:
logger.error("[Orchestrator] critical stage '%s' failed: %s", agent.agent_name, result.error)
return OrchestratorResult(
success=False,

View File

@@ -595,7 +595,7 @@ def run_agent_loop(
total_tokens=total_tokens,
provider=provider_used,
models_used=models_used,
error=f"Agent exceeded max steps ({max_steps})",
error=f"Agent exceeded max steps ({max_steps}). Try increasing AGENT_MAX_STEPS if analysis tasks are complex.",
messages=messages,
)

View File

@@ -50,6 +50,7 @@ class ConfigIssue:
_MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai", "deepseek"}
SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama")
_FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
AGENT_MAX_STEPS_DEFAULT = 10
NEWS_STRATEGY_WINDOWS: Dict[str, int] = {
"ultra_short": 1,
"short": 3,
@@ -520,7 +521,7 @@ class Config:
agent_litellm_model: str = "" # Optional Agent-only primary model; empty inherits LITELLM_MODEL
agent_mode: bool = False
_agent_mode_explicit: bool = False # True when AGENT_MODE was explicitly set in env
agent_max_steps: int = 10
agent_max_steps: int = AGENT_MAX_STEPS_DEFAULT
agent_skills: List[str] = field(default_factory=list)
agent_skill_dir: Optional[str] = None
agent_nl_routing: bool = False # Enable natural language routing in bot dispatcher
@@ -1174,7 +1175,12 @@ class Config:
agent_litellm_model=agent_litellm_model,
agent_mode=os.getenv('AGENT_MODE', 'false').lower() == 'true',
_agent_mode_explicit=os.getenv('AGENT_MODE') is not None,
agent_max_steps=parse_env_int(os.getenv('AGENT_MAX_STEPS'), 10, field_name='AGENT_MAX_STEPS', minimum=1),
agent_max_steps=parse_env_int(
os.getenv('AGENT_MAX_STEPS'),
AGENT_MAX_STEPS_DEFAULT,
field_name='AGENT_MAX_STEPS',
minimum=1,
),
agent_skills=[s.strip() for s in os.getenv('AGENT_SKILLS', '').split(',') if s.strip()],
agent_skill_dir=os.getenv('AGENT_SKILL_DIR') or os.getenv('AGENT_STRATEGY_DIR'),
agent_nl_routing=os.getenv('AGENT_NL_ROUTING', 'false').lower() == 'true',

View File

@@ -10,6 +10,8 @@ from __future__ import annotations
from copy import deepcopy
from typing import Any, Dict, List, Optional
from src.config import AGENT_MAX_STEPS_DEFAULT
SCHEMA_VERSION = "2026-03-29"
_CATEGORY_DEFINITIONS: List[Dict[str, Any]] = [
@@ -1563,14 +1565,14 @@ _FIELD_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
"AGENT_MAX_STEPS": {
"title": "Agent Max Steps",
"description": "Maximum reasoning-step ceiling for Agent mode. In orchestrator mode, each sub-agent keeps min(its default, this limit) so lower-default specialists are not inflated.",
"description": f"Maximum reasoning-step limit for Agent mode. At the default ({AGENT_MAX_STEPS_DEFAULT}), each sub-agent keeps its own preset. When raised above {AGENT_MAX_STEPS_DEFAULT}, all sub-agents adopt this value. When lowered below a sub-agent's preset, that sub-agent is capped at this value.",
"category": "agent",
"data_type": "integer",
"ui_control": "number",
"is_sensitive": False,
"is_required": False,
"is_editable": True,
"default_value": "10",
"default_value": str(AGENT_MAX_STEPS_DEFAULT),
"options": [],
"validation": {"min": 1, "max": 50},
"display_order": 20,

View File

@@ -40,12 +40,12 @@ class TestAgentConfig(unittest.TestCase):
@patch('src.config.load_dotenv')
def test_default_agent_config(self, _mock_dotenv):
"""Agent mode should be disabled by default."""
from src.config import Config
from src.config import AGENT_MAX_STEPS_DEFAULT, Config
Config._instance = None
config = Config._load_from_env()
self.assertEqual(config.agent_litellm_model, "")
self.assertFalse(config.agent_mode)
self.assertEqual(config.agent_max_steps, 10)
self.assertEqual(config.agent_max_steps, AGENT_MAX_STEPS_DEFAULT)
self.assertEqual(config.agent_skills, [])
@patch.dict(os.environ, {
@@ -334,6 +334,7 @@ class TestAgentResultConversion(unittest.TestCase):
mock_cfg.max_workers = 2
mock_cfg.agent_mode = True
mock_cfg.agent_max_steps = 10
mock_cfg.agent_orchestrator_timeout_s = 0
mock_cfg.agent_skills = []
mock_cfg.bocha_api_keys = []
mock_cfg.tavily_api_keys = []
@@ -628,7 +629,8 @@ class TestAnalyzeWithAgentStockName(unittest.TestCase):
patch('src.core.pipeline.GeminiAnalyzer'), \
patch('src.core.pipeline.NotificationService'), \
patch('src.core.pipeline.SearchService'), \
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
patch('src.agent.factory.build_agent_executor') as mock_build_executor, \
patch('src.agent.executor.AgentExecutor.run') as mock_agent_run:
mock_cfg = MagicMock()
mock_cfg.max_workers = 2
@@ -668,6 +670,7 @@ class TestAnalyzeWithAgentStockName(unittest.TestCase):
mock_executor = MagicMock()
mock_executor.run.return_value = agent_result
mock_build_executor.return_value = mock_executor
mock_agent_run.return_value = agent_result
news_response = MagicMock()
news_response.success = True

View File

@@ -35,6 +35,7 @@ from src.agent.protocols import (
StageResult,
StageStatus,
)
from src.config import AGENT_MAX_STEPS_DEFAULT
# ============================================================
@@ -500,13 +501,9 @@ class TestOrchestratorModes(unittest.TestCase):
self.assertEqual(orch.mode, "standard")
def test_chain_agents_inherit_orchestrator_max_steps(self):
"""Orchestrator max_steps acts as a *ceiling*, not a hard override.
Each agent keeps ``min(own_default, orchestrator_limit)`` so that
specialised agents with lower defaults are not inflated.
"""
"""Default/lowered limits cap agents; raised limits hard-override all agents."""
orch = self._make_orchestrator("full")
orch.max_steps = 9
orch.max_steps = AGENT_MAX_STEPS_DEFAULT
high_limit_chain = orch._build_agent_chain(AgentContext(query="test", stock_code="600519"))
self.assertEqual(
{agent.agent_name: agent.max_steps for agent in high_limit_chain},
@@ -520,6 +517,23 @@ class TestOrchestratorModes(unittest.TestCase):
{"technical": 5, "intel": 4, "risk": 4, "decision": 3},
)
orch.max_steps = AGENT_MAX_STEPS_DEFAULT + 2
raised_limit_chain = orch._build_agent_chain(AgentContext(query="test", stock_code="600519"))
self.assertEqual(
{agent.agent_name: agent.max_steps for agent in raised_limit_chain},
{"technical": AGENT_MAX_STEPS_DEFAULT + 2, "intel": AGENT_MAX_STEPS_DEFAULT + 2, "risk": AGENT_MAX_STEPS_DEFAULT + 2, "decision": AGENT_MAX_STEPS_DEFAULT + 2},
)
def test_prepare_agent_raised_limit_overrides_low_default_agent(self):
orch = self._make_orchestrator("full")
orch.max_steps = AGENT_MAX_STEPS_DEFAULT + 2
decision = MagicMock(agent_name="decision", max_steps=3)
prepared = orch._prepare_agent(decision)
self.assertIs(prepared, decision)
self.assertEqual(prepared.max_steps, AGENT_MAX_STEPS_DEFAULT + 2)
def test_build_context_from_dict(self):
orch = self._make_orchestrator()
ctx = orch._build_context(
@@ -565,6 +579,24 @@ class TestOrchestratorExecution(unittest.TestCase):
result.meta["models_used"] = ["test/model"]
return result
def test_prepare_agent_uses_default_constant_as_raise_threshold(self):
orch = self._make_orchestrator()
agent = MagicMock(agent_name="technical", max_steps=6)
prepared = orch._prepare_agent(agent)
self.assertIs(prepared, agent)
self.assertEqual(agent.max_steps, 6)
orch.max_steps = 12
agent.max_steps = 6
orch._prepare_agent(agent)
self.assertEqual(agent.max_steps, 12)
orch.max_steps = 5
agent.max_steps = 6
orch._prepare_agent(agent)
self.assertEqual(agent.max_steps, 5)
def test_execute_pipeline_stops_on_critical_failure(self):
orch = self._make_orchestrator()
technical = MagicMock(agent_name="technical")
@@ -593,6 +625,32 @@ class TestOrchestratorExecution(unittest.TestCase):
self.assertTrue(result.success)
self.assertIn("Analysis Summary", result.content)
def test_execute_pipeline_degrades_on_skill_agent_failure_and_continues_to_decision(self):
orch = self._make_orchestrator()
orch.mode = "specialist"
ctx = AgentContext(query="test", stock_code="600519")
ctx.add_opinion(AgentOpinion(agent_name="technical", signal="buy", confidence=0.8, reasoning="Strong trend"))
technical = MagicMock(agent_name="technical")
technical.run.return_value = self._stage_result("technical")
intel = MagicMock(agent_name="intel")
intel.run.return_value = self._stage_result("intel")
risk = MagicMock(agent_name="risk")
risk.run.return_value = self._stage_result("risk")
skill = MagicMock(agent_name="strategy_bull_trend")
skill.run.return_value = self._stage_result("strategy_bull_trend", StageStatus.FAILED, error="skill boom")
decision = MagicMock(agent_name="decision")
decision.run.return_value = self._stage_result("decision")
with patch.object(orch, "_build_agent_chain", return_value=[technical, intel, risk, decision]):
with patch.object(orch, "_build_specialist_agents", return_value=[skill]):
result = orch._execute_pipeline(ctx, parse_dashboard=False)
self.assertTrue(result.success)
self.assertIn("Analysis Summary", result.content)
skill.run.assert_called_once()
decision.run.assert_called_once()
def test_execute_pipeline_skips_stage_when_remaining_budget_below_minimum(self):
orch = self._make_orchestrator(config=SimpleNamespace(agent_orchestrator_timeout_s=20))
ctx = AgentContext(query="test", stock_code="600519", stock_name="贵州茅台")