Преглед изворни кода

修改agent基础框架和问题

xueyiming пре 2 дана
родитељ
комит
bfcf716d5f
35 измењених фајлова са 473 додато и 891 уклоњено
  1. 4 2
      .env.example
  2. 4 2
      ARCHITECTURE.md
  3. 1 1
      Dockerfile
  4. 2 1
      README.md
  5. 2 2
      agents/demand_grade_agent/tools/demand_priority.py
  6. 1 1
      agents/demand_grade_agent/tools/query_score_distribution.py
  7. 1 1
      agents/demand_grade_agent/tools/shared.py
  8. 1 1
      agents/demand_grade_agent/tools/tree_local.py
  9. 1 1
      agents/demand_grade_orchestrator_agent/common/tree_state.py
  10. 1 1
      agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py
  11. 2 8
      agents/find_agent/PRD.md
  12. 2 1
      agents/find_agent/__init__.py
  13. 1 153
      agents/find_agent/agent.py
  14. 3 4
      agents/find_agent/prompt/system_prompt.md
  15. 15 34
      agents/find_agent/run_outcome.py
  16. 15 0
      agents/find_agent/runtime.py
  17. 1 1
      deploy/README.md
  18. 1 13
      supply_agent/agent/core.py
  19. 155 394
      supply_agent/agent/loop.py
  20. 0 6
      supply_agent/config.py
  21. 7 1
      supply_agent/logging/__init__.py
  22. 22 60
      supply_agent/logging/publish.py
  23. 21 0
      supply_agent/tools/errors.py
  24. 3 4
      supply_agent/tools/registry.py
  25. 5 0
      supply_infra/__init__.py
  26. 9 0
      supply_infra/agent_logging/__init__.py
  27. 77 0
      supply_infra/agent_logging/publish.py
  28. 11 0
      supply_infra/agent_logging/register.py
  29. 20 5
      supply_infra/db/repositories/video_discovery_repo.py
  30. 1 1
      supply_infra/scheduler/jobs/demand_pool/tree_weight.py
  31. 20 0
      supply_infra/scoring/__init__.py
  32. 0 0
      supply_infra/scoring/posterior.py
  33. 0 0
      supply_infra/scoring/ranking.py
  34. 13 4
      supply_infra/services/video_discovery_service.py
  35. 51 189
      tests/supply_infra/scheduler/test_discover_videos_from_demands.py

+ 4 - 2
.env.example

@@ -9,8 +9,10 @@ OPENROUTER_TIMEOUT_SECONDS=120
 # Agent defaults
 AGENT_MAX_ITERATIONS=20
 AGENT_TEMPERATURE=0.7
-# 单个 find_agent 最长运行 30 分钟,超时后标记失败并继续下一条
-FIND_AGENT_TIMEOUT_SECONDS=1800
+
+# find_agent only (read by agents/find_agent/runtime.py)
+# 单个 find_agent 最长运行 10 分钟,超时后标记失败并继续下一条
+FIND_AGENT_TIMEOUT_SECONDS=600
 
 # Skills directory (relative to project root or absolute path)
 SKILLS_DIR=skills

+ 4 - 2
ARCHITECTURE.md

@@ -15,6 +15,8 @@ SupplyAgent/
 ├── supply_infra/                  # 共享基础设施(所有 Agent / 定时任务共用)
 │   ├── config.py                  #   MySQL / ODPS / Scheduler 配置
+│   ├── agent_logging/             #   注册 Agent 运行日志 OSS 发布 hook
+│   ├── scoring/                   #   后验 diff / 排名归一化(分级与热度树共用)
 │   ├── db/
 │   │   ├── base.py                #   SQLAlchemy Base + TimestampMixin
 │   │   ├── session.py             #   Engine + get_session()
@@ -58,8 +60,8 @@ SupplyAgent/
 
 | 层 | 包 | 职责 | 谁用 |
 |----|-----|------|------|
-| 框架层 | `supply_agent` | LLM 调用、工具/技能机制、ReAct 循环 | 所有 Agent |
-| 基础设施层 | `supply_infra` | DB ORM、ODPS、定时任务、共享 DB 工具 | 所有 Agent + 定时任务 |
+| 框架层 | `supply_agent` | LLM 调用、工具/技能机制、ReAct 循环;运行结束后通过 hook 发布产物(不含 OSS/DB) | 所有 Agent |
+| 基础设施层 | `supply_infra` | DB ORM、ODPS、定时任务、共享 DB 工具;`agent_logging` 注册 OSS 日志发布 hook | 所有 Agent + 定时任务 |
 | 业务层 | `agents/*` | 具体 Agent 逻辑、专属工具、工厂函数 | 业务开发 |
 
 ## Data flow

+ 1 - 1
Dockerfile

@@ -30,7 +30,7 @@ ENV PYTHONUNBUFFERED=1 \
     SCHEDULER_CRON_MINUTE=0 \
     PIPELINE_WORKER_PROCESSES=4 \
     PIPELINE_MAX_ACTIVE_STEPS=4 \
-    FIND_AGENT_TIMEOUT_SECONDS=1800 \
+    FIND_AGENT_TIMEOUT_SECONDS=600 \
     MYSQL_CONNECTION_BUDGET=40
 
 RUN sed -i 's|deb.debian.org|mirrors.aliyun.com|g; s|security.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \

+ 2 - 1
README.md

@@ -153,11 +153,12 @@ async for event in agent.astream("Your question"):
 | `OPENROUTER_TIMEOUT_SECONDS` | 单次模型请求超时秒数 | `120` |
 | `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
 | `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
-| `FIND_AGENT_TIMEOUT_SECONDS` | 单个 find_agent 总运行超时秒数 | `1800` |
 | `SKILLS_DIR` | Skills 目录 | `skills` |
 | `LOGS_DIR` | Agent 运行日志目录 | `logs` |
 | `LOG_ENABLED` | 是否写入运行日志 | `true` |
 
+find_agent 本地:`FIND_AGENT_TIMEOUT_SECONDS`(默认 `600`,即 10 分钟)由 `agents/find_agent/runtime.py` 读取,不属于通用 `supply_agent` 配置。
+
 ## 运行日志与可视化
 
 每次 `agent.run()` 会在 `logs/` 下写出:

+ 2 - 2
agents/demand_grade_agent/tools/demand_priority.py

@@ -4,8 +4,8 @@ from __future__ import annotations
 from collections import defaultdict
 from typing import Any
 
-from supply_agent.posterior import format_posterior_value
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.posterior import format_posterior_value
+from supply_infra.scoring.ranking import rank_with_scores
 
 
 DEMAND_PRIORITY_SCORE_METHOD = {

+ 1 - 1
agents/demand_grade_agent/tools/query_score_distribution.py

@@ -9,7 +9,7 @@ from agents.demand_grade_agent.tools.demand_priority import (
     build_demand_priority_index,
 )
 from agents.demand_grade_agent.tools.shared import distribution_summary, normalize_biz_dt, to_float
-from supply_agent.posterior import POSTERIOR_EFFECT_ACCEPTABLE_FLOOR
+from supply_infra.scoring.posterior import POSTERIOR_EFFECT_ACCEPTABLE_FLOOR
 from supply_agent.tools import tool
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.multi_demand_pool_di_repo import MultiDemandPoolDiRepository

+ 1 - 1
agents/demand_grade_agent/tools/shared.py

@@ -5,7 +5,7 @@ import json
 from decimal import Decimal
 from typing import Any
 
-from supply_agent.posterior import (
+from supply_infra.scoring.posterior import (
     POSTERIOR_EFFECT_ACCEPTABLE_FLOOR,
     classify_posterior_effect,
     format_posterior_dim_with_count,

+ 1 - 1
agents/demand_grade_agent/tools/tree_local.py

@@ -10,7 +10,7 @@ from agents.demand_grade_agent.tools.shared import (
     build_category_path,
     format_category_weight_lines,
 )
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.ranking import rank_with_scores
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session

+ 1 - 1
agents/demand_grade_orchestrator_agent/common/tree_state.py

@@ -5,7 +5,7 @@ from collections import defaultdict
 from types import SimpleNamespace
 from typing import Any
 
-from supply_agent.ranking import rank_with_scores
+from supply_infra.scoring.ranking import rank_with_scores
 from supply_infra.db.repositories.category_tree_weight_repo import CategoryTreeWeightRepository
 from supply_infra.db.repositories.global_tree_category_repo import GlobalTreeCategoryRepository
 from supply_infra.db.session import get_session

+ 1 - 1
agents/demand_grade_orchestrator_agent/tools/query_heat_node_group.py

@@ -11,7 +11,7 @@ from agents.demand_grade_orchestrator_agent.common import (
     load_tree_state,
     path,
 )
-from supply_agent.posterior import format_posterior_pair
+from supply_infra.scoring.posterior import format_posterior_pair
 from supply_agent.tools import tool
 
 

+ 2 - 8
agents/find_agent/PRD.md

@@ -214,12 +214,6 @@ Agent 对每个候选独立判断:
 | 模型 | 固定为 `google/gemini-3-flash-preview` |
 | 最大迭代轮次 | 60 |
 | 温度 | 0.2 |
-| 搜索工具预算 | 10 次 |
-| 详情工具预算 | 2 次 |
-| 画像工具预算 | 3 次 |
-| 候选保存预算 | 6 次 |
-| 审计预算 | 4 次 |
-| 状态查询预算 | 8 次 |
 | 单批详情/画像候选数 | 最多 8 条 |
 
 `create_find_agent(..., model=...)` 和 `run_find_agent(..., model=...)` 虽然暴露了模型参数,
@@ -236,7 +230,7 @@ Agent 对每个候选独立判断:
 | 内容理解 | 受限 | 仅使用文本、互动和画像,不使用视频理解 |
 | 评分与分池 | 部分可用 | 规则完整,但主要依赖模型遵守长提示词 |
 | 状态保存 | 可用 | 可保存运行、搜索页和候选状态 |
-| 完成审计 | 已接入 | 可校验完成顺序、审计新鲜度和报告分池 |
+| 完成审计 | 业务侧 | 由工具 `audit_video_discovery_run` + `run_outcome` 判定;框架无完成守卫 |
 | 故障终止 | 已接入 | Agent 明确声明工具故障时允许失败结束 |
 | 最终一致性 | 部分可用 | 报告只读并接受校验,尚无单一事务性最终化入口 |
 | 运行恢复 | 部分可用 | 能查询旧状态,但缺少执行代次和自动恢复机制 |
@@ -270,7 +264,7 @@ Agent 对每个候选独立判断:
 
 5. **补齐 Agent 契约测试**
    - 覆盖未保存搜索页、证据晚于评估、审计失败、旧状态查询和报告分池不一致;
-   - 覆盖幻觉候选 ID、非法分池、缺失证据和工具预算耗尽
+   - 覆盖幻觉候选 ID、非法分池、缺失证据;
    - 用固定样例验证相关性闸门、年龄证据层级和未知数据处理。
 
 ### 9.2 P1:提高搜索与判断质量

+ 2 - 1
agents/find_agent/__init__.py

@@ -10,6 +10,7 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou
 
 from agents.find_agent.agent import create_find_agent
 from agents.find_agent.async_runner import _run_coroutine, arun_find_agent
+from agents.find_agent.runtime import find_agent_timeout_seconds
 from supply_agent.config import Settings
 from supply_agent.types import AgentResult
 
@@ -36,7 +37,7 @@ def run_find_agent(
         arun_find_agent(
             agent,
             user_input,
-            timeout_seconds=agent.settings.find_agent_timeout_seconds,
+            timeout_seconds=find_agent_timeout_seconds(),
         )
     )
     try:

+ 1 - 153
agents/find_agent/agent.py

@@ -6,159 +6,15 @@ find_agent 工厂 — 组装 Agent 实例。
 """
 from __future__ import annotations
 
-import json
-import re
 from pathlib import Path
 
 from supply_agent import Agent
 from supply_agent.config import Settings
-from supply_agent.types import Message, Role
 from agents.find_agent.tools import register_all_tools
 
 _PROMPT_PATH = Path(__file__).parent / "prompt" / "system_prompt.md"
 FIND_AGENT_SYSTEM_PROMPT = _PROMPT_PATH.read_text(encoding="utf-8")
 
-_FAILURE_REPORT_PREFIX = "任务未完成(工具故障)"
-_VIDEO_ID_PATTERN = re.compile(r"(?<!\d)\d{15,22}(?!\d)")
-
-
-def _report_section(
-    content: str,
-    start_marker: str,
-    end_markers: tuple[str, ...],
-) -> str | None:
-    start = content.find(start_marker)
-    if start < 0:
-        return None
-    ends = [
-        position
-        for marker in end_markers
-        if (position := content.find(marker, start + len(start_marker))) >= 0
-    ]
-    end = min(ends) if ends else len(content)
-    return content[start:end]
-
-
-def _validate_report_primary_section(
-    content: str,
-    state: dict[str, object],
-) -> str | None:
-    """Only validate aweme_ids listed in the primary report section."""
-    candidates = state.get("candidates")
-    if not isinstance(candidates, list):
-        return "最终状态缺少 candidates,无法校验报告分池"
-    bucket_by_id = {
-        str(item.get("aweme_id")): str(item.get("decision_bucket"))
-        for item in candidates
-        if isinstance(item, dict) and item.get("aweme_id")
-    }
-
-    primary_section = _report_section(
-        content,
-        "主推荐",
-        ("淘汰候选", "搜索树", "缺失数据", "总结"),
-    )
-    if primary_section is None:
-        return "最终报告必须包含“主推荐”段"
-
-    primary_ids = set(_VIDEO_ID_PATTERN.findall(primary_section))
-    if not primary_ids:
-        return "主推荐段必须输出至少一个 aweme_id"
-
-    wrong_primary = sorted(
-        video_id
-        for video_id in primary_ids
-        if bucket_by_id.get(video_id) != "primary"
-    )
-    if wrong_primary:
-        return (
-            "主推荐段包含非 primary 候选: "
-            + ", ".join(wrong_primary[:5])
-            + "。必须按数据库 decision_bucket 输出"
-        )
-    return None
-
-
-def _primary_count_from_state(state: dict[str, object]) -> int:
-    run = state.get("run")
-    if isinstance(run, dict):
-        return int(run.get("primary_count") or 0)
-    return 0
-
-
-def _successful_tool_events(
-    messages: list[Message],
-) -> list[tuple[int, str, dict[str, object]]]:
-    events: list[tuple[int, str, dict[str, object]]] = []
-    for index, message in enumerate(messages):
-        if message.role != Role.TOOL or not message.name or not message.content:
-            continue
-        try:
-            payload = json.loads(message.content)
-        except (TypeError, json.JSONDecodeError):
-            continue
-        if not isinstance(payload, dict) or payload.get("error"):
-            continue
-        events.append((index, message.name, payload))
-    return events
-
-
-def _last_final_assistant_message(messages: list[Message]) -> Message | None:
-    return next(
-        (
-            message
-            for message in reversed(messages)
-            if message.role == Role.ASSISTANT and not message.tool_calls
-        ),
-        None,
-    )
-
-
-def find_agent_completion_guard(messages: list[Message]) -> str | None:
-    """返回完成提示;默认仅提示、不阻断结束。"""
-    last_assistant = _last_final_assistant_message(messages)
-    content = (last_assistant.content or "").strip() if last_assistant else ""
-    if content.startswith(_FAILURE_REPORT_PREFIX):
-        return None
-
-    events = _successful_tool_events(messages)
-    if not any(name == "create_video_discovery_run" for _, name, _ in events):
-        return "尚未成功创建视频发现运行"
-
-    evaluation_events = [
-        (index, payload)
-        for index, name, payload in events
-        if name == "batch_save_video_candidate_evaluations"
-    ]
-    if not evaluation_events:
-        return "尚未保存候选评估"
-    evaluation_index, evaluation = evaluation_events[-1]
-    if evaluation.get("status") != "finished":
-        return "最后一次候选保存尚未把运行状态设置为 finished"
-
-    state_events = [
-        (index, payload)
-        for index, name, payload in events
-        if name == "query_video_discovery_state"
-    ]
-    if not state_events:
-        return "尚未查询最终数据库状态"
-    state_index, state = state_events[-1]
-    if state_index < evaluation_index:
-        return "最终状态查询必须在候选保存为 finished 之后执行"
-
-    run = state.get("run")
-    run_state = run if isinstance(run, dict) else {}
-    if run_state.get("status") != "finished":
-        return "运行状态尚未持久化为 finished"
-
-    if last_assistant is None or not content:
-        return "最终回答为空"
-
-    if _primary_count_from_state(state) > 0:
-        return _validate_report_primary_section(content, state)
-    return None
-
 
 def create_find_agent(
     settings: Settings | None = None,
@@ -169,18 +25,10 @@ def create_find_agent(
     agent = Agent(
         settings=settings,
         name="find_agent",
-        model="google/gemini-3-flash-preview",
+        model=model or "google/gemini-3-flash-preview",
         system_prompt=FIND_AGENT_SYSTEM_PROMPT,
         max_iterations=60,
         temperature=0.2,
-        completion_guard=find_agent_completion_guard,
-        tool_repeat_requires_change={
-            "query_video_discovery_state": {
-                "record_video_search_page",
-                "batch_save_video_candidate_evaluations",
-                "audit_video_discovery_run",
-            },
-        },
     )
 
     # 本 Agent 专属工具

+ 3 - 4
agents/find_agent/prompt/system_prompt.md

@@ -197,8 +197,7 @@
 3. 说明失败工具、原始错误、已完成内容和未完成内容;
 4. 明确写出“未产出有效推荐”,不得输出看似正常的主推荐或淘汰候选报告。
 
-完成守卫只识别上述失败摘要前缀并允许任务结束,不读取或分类工具返回。该出口表示任务
-失败结束,不是成功完成。
+该出口表示任务失败结束,不是成功完成;调度侧会按运行结果判定为失败。
 
 # 成本与迭代预算
 
@@ -209,8 +208,8 @@
 - 内容相关性与分享动机主要依据标题、描述、`topic_list`、话题标签和详情字段判断,
   不得依赖或声称使用了视频画面、语音、字幕解析;
 - 执行新搜索、翻页、详情或画像后,应重新保存受影响候选;
-- 不得用“接下来我会继续”作为最终回答。运行时完成守卫会拒绝顺序不完整、审计未
-  通过或使用旧状态生成的最终报告
+- 不得用“接下来我会继续”作为最终回答。最终报告必须与库中 `primary / rejected`
+  一致,并由 `audit_video_discovery_run` 与结束后的状态查询支撑
 - 你已按“工具故障终止规则”判断无法继续时,不再尝试正常完成条件,直接输出失败摘要。
 - 数据库保留数量达到 5 条后,若没有明显更高价值的搜索前沿,优先结束任务。
 - 保留数量不足 5 条时,优先继续有效的搜索、翻页或扩标签;合理前沿已经耗尽,或剩余

+ 15 - 34
agents/find_agent/run_outcome.py

@@ -6,9 +6,6 @@ from dataclasses import dataclass
 from supply_agent.types import AgentResult
 from supply_infra.services.video_discovery_service import get_video_discovery_service
 
-TOOL_FAILURE_PREFIX = "任务未完成(工具故障)"
-MAX_ITERATIONS_PREFIX = "Max iterations reached"
-
 
 @dataclass(frozen=True)
 class FindAgentRunOutcome:
@@ -16,34 +13,18 @@ class FindAgentRunOutcome:
     failure_reason: str | None = None
 
 
-def evaluate_find_agent_run(run_id: str, agent_result: AgentResult) -> FindAgentRunOutcome:
-    """根据 Agent 输出与 DB run 状态判断是否算成功完成。"""
-    content = (agent_result.content or "").strip()
-
-    if content.startswith(TOOL_FAILURE_PREFIX):
-        return FindAgentRunOutcome(
-            succeeded=False,
-            failure_reason="agent_declared_tool_failure",
-        )
-
-    if content.startswith(MAX_ITERATIONS_PREFIX):
-        return FindAgentRunOutcome(
-            succeeded=False,
-            failure_reason="max_iterations_reached",
-        )
-
-    run = get_video_discovery_service().lookup_run(run_id)
-    if run is None:
-        return FindAgentRunOutcome(
-            succeeded=False,
-            failure_reason="run_not_found",
-        )
-
-    status = str(run.get("status") or "")
-    if status != "finished":
-        return FindAgentRunOutcome(
-            succeeded=False,
-            failure_reason=f"run_status_{status or 'unknown'}",
-        )
-
-    return FindAgentRunOutcome(succeeded=True)
+def evaluate_find_agent_run(
+    run_id: str,
+    agent_result: AgentResult | None = None,
+) -> FindAgentRunOutcome:
+    """有候选写入即成功:``video_discovery_candidate`` 按 run_id 存在记录。
+
+    ``agent_result`` 保留给调用方兼容,不再参与成败判定。
+    """
+    del agent_result  # 成败只看库表,不看模型最终文案
+    if get_video_discovery_service().has_candidates(run_id):
+        return FindAgentRunOutcome(succeeded=True)
+    return FindAgentRunOutcome(
+        succeeded=False,
+        failure_reason="no_candidates",
+    )

+ 15 - 0
agents/find_agent/runtime.py

@@ -0,0 +1,15 @@
+"""find_agent 本地运行参数(不属于通用 supply_agent 配置)。"""
+
+from __future__ import annotations
+
+import os
+
+
+def find_agent_timeout_seconds() -> float:
+    """单次 find_agent 运行总超时(秒),可读环境变量 FIND_AGENT_TIMEOUT_SECONDS。"""
+    raw = os.getenv("FIND_AGENT_TIMEOUT_SECONDS", "600")
+    try:
+        value = float(raw)
+    except ValueError:
+        value = 600.0
+    return max(60.0, min(7200.0, value))

+ 1 - 1
deploy/README.md

@@ -13,7 +13,7 @@ Scheduler、多个 Pipeline Worker 和 Reconciler。
 5. Docker 停止窗口至少 300 秒。
 6. 容器和 Alembic 连接 MySQL 后会将会话时区固定为 `+08:00`(中国标准时间);定时日批有次日
    调度 deadline,手工/API/CLI 补数不设置 deadline。
-7. 单个 `find_agent` 默认最多运行 1800 秒;运行中的找片批次每 60 秒输出一次
+7. 单个 `find_agent` 默认最多运行 600 秒(10 分钟);运行中的找片批次每 60 秒输出一次
    进度日志,超时记录会标记失败,批次继续处理下一条需求。
 
 示例:

+ 1 - 13
supply_agent/agent/core.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from collections.abc import AsyncIterator, Callable, Iterator
+from collections.abc import AsyncIterator, Iterator
 
 from supply_agent.agent.loop import AgentLoop
 from supply_agent.config import Settings, get_settings
@@ -60,10 +60,6 @@ class Agent:
         temperature: float | None = None,
         reasoning_effort: str | None = None,
         logger: AgentLogger | None = None,
-        completion_guard: Callable[[list[Message]], str | None] | None = None,
-        completion_guard_blocks: bool = False,
-        tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
-        tool_repeat_requires_change: dict[str, set[str]] | None = None,
     ) -> None:
         self.name = name
         self.settings = settings or get_settings()
@@ -81,10 +77,6 @@ class Agent:
         self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
         self.max_iterations = max_iterations or self.settings.agent_max_iterations
         self.temperature = temperature
-        self.completion_guard = completion_guard
-        self.completion_guard_blocks = completion_guard_blocks
-        self.tool_call_budgets = tool_call_budgets or {}
-        self.tool_repeat_requires_change = tool_repeat_requires_change or {}
 
         if model:
             self.llm.set_model(model)
@@ -141,10 +133,6 @@ class Agent:
             temperature=self.temperature,
             logger=self.logger,
             active_skills=self._active_skills,
-            completion_guard=self.completion_guard,
-            completion_guard_blocks=self.completion_guard_blocks,
-            tool_call_budgets=self.tool_call_budgets,
-            tool_repeat_requires_change=self.tool_repeat_requires_change,
         )
 
     def _finish_run(self, result: AgentResult) -> None:

+ 155 - 394
supply_agent/agent/loop.py

@@ -2,7 +2,7 @@ from __future__ import annotations
 
 import json
 from collections.abc import AsyncIterator, Callable, Iterator
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Literal
 
 from supply_agent.llm.client import LLMClient, MalformedFunctionCallError
 from supply_agent.tools.registry import ToolRegistry
@@ -20,12 +20,17 @@ from supply_agent.types import (
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
 
+# Outcome of one assistant turn before tools / completion.
+_TurnKind = Literal["done", "tools"]
+
 
 class AgentLoop:
     """
     ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
 
-    Implements the standard tool-calling pattern used by modern agent frameworks.
+    Sync/async and stream/non-stream entry points share the same turn and
+    tool-handling logic so skill injection and max-iteration behavior stay
+    consistent.
     """
 
     def __init__(
@@ -39,10 +44,6 @@ class AgentLoop:
         logger: AgentLogger | None = None,
         active_skills: list[str] | None = None,
         system_message_builder: Callable[[], Message] | None = None,
-        completion_guard: Callable[[list[Message]], str | None] | None = None,
-        completion_guard_blocks: bool = False,
-        tool_call_budgets: dict[str, tuple[set[str], int]] | None = None,
-        tool_repeat_requires_change: dict[str, set[str]] | None = None,
     ) -> None:
         self.llm = llm
         self.tools = tools
@@ -52,14 +53,8 @@ class AgentLoop:
         self.max_iterations = max_iterations
         self.temperature = temperature
         self.logger = logger
-        self.active_skills = active_skills or []
-        self.completion_guard = completion_guard
-        self.completion_guard_blocks = completion_guard_blocks
-        self.tool_call_budgets = tool_call_budgets or {}
-        self.tool_repeat_requires_change = tool_repeat_requires_change or {}
-        self._tool_budget_counts = {
-            group: 0 for group in self.tool_call_budgets
-        }
+        # Use `is not None` so an empty shared list from Agent is kept by identity.
+        self.active_skills = active_skills if active_skills is not None else []
         self.tool_calls_made = 0
 
     def _all_messages(self) -> list[Message]:
@@ -78,32 +73,6 @@ class AgentLoop:
         if self.system_message_builder:
             self.system_message = self.system_message_builder()
 
-    def _completion_block_reason(self) -> str | None:
-        if self.completion_guard is None:
-            return None
-        return self.completion_guard(self.messages)
-
-    def _should_block_completion(self, reason: str | None) -> bool:
-        return bool(reason and self.completion_guard_blocks)
-
-    def _completion_response_content(self, content: str) -> str:
-        reason = self._completion_block_reason()
-        if reason and not self.completion_guard_blocks:
-            return f"{content}\n\n---\n完成提示(未阻断结束):{reason}"
-        return content
-
-    def _append_completion_feedback(self, reason: str) -> None:
-        self.messages.append(
-            Message(
-                role=Role.USER,
-                content=(
-                    "完成守卫拒绝当前最终回答:"
-                    f"{reason}。请继续调用必要工具修复这些问题;"
-                    "只有守卫条件全部满足后才能输出最终答案。"
-                ),
-            )
-        )
-
     def _append_malformed_tool_feedback(self) -> None:
         self.messages.append(
             Message(
@@ -115,139 +84,100 @@ class AgentLoop:
             )
         )
 
-    def _consume_tool_budget(self, tool_name: str) -> str | None:
-        matching = [
-            (group, limit)
-            for group, (tool_names, limit) in self.tool_call_budgets.items()
-            if tool_name in tool_names
-        ]
-        for group, limit in matching:
-            used = self._tool_budget_counts[group]
-            if used >= limit:
-                return (
-                    f"工具预算已耗尽: group={group}, limit={limit}。"
-                    "请使用已有结果完成筛选、持久化和审计,不要继续扩大搜索。"
-                )
-        for group, _ in matching:
-            self._tool_budget_counts[group] += 1
-        return None
-
-    def _repeat_policy_error(self, tool_name: str) -> str | None:
-        dependencies = self.tool_repeat_requires_change.get(tool_name)
-        if not dependencies:
-            return None
-        last_call_index = -1
-        last_change_index = -1
-        for index, message in enumerate(self.messages):
-            if message.role != Role.TOOL or not message.name:
-                continue
-            try:
-                payload = json.loads(message.content or "")
-            except (TypeError, json.JSONDecodeError):
-                continue
-            if isinstance(payload, dict) and payload.get("error"):
-                continue
-            if message.name == tool_name:
-                last_call_index = index
-            if message.name in dependencies:
-                last_change_index = index
-        if last_call_index >= 0 and last_change_index < last_call_index:
-            return (
-                f"工具 {tool_name} 在状态未变化时不得重复调用。"
-                f"必须先成功执行以下任一状态变更工具: {sorted(dependencies)}"
-            )
-        return None
-
     def _available_tool_definitions(self) -> list[ToolDefinition] | None:
-        exhausted_tools: set[str] = set()
-        for group, (tool_names, limit) in self.tool_call_budgets.items():
-            if self._tool_budget_counts[group] >= limit:
-                exhausted_tools.update(tool_names)
-        definitions = [
-            definition
-            for definition in self.tools.definitions
-            if definition.name not in exhausted_tools
-            and self._repeat_policy_error(definition.name) is None
-        ]
-        return definitions or None
-
-    def _refund_tool_budget_for_input_error(
+        return self.tools.definitions or None
+
+    def _build_result(self, content: str, iterations: int) -> AgentResult:
+        return AgentResult(
+            content=content,
+            messages=self.messages,
+            iterations=iterations,
+            tool_calls_made=self.tool_calls_made,
+            skills_used=list(self.active_skills),
+        )
+
+    def _record_tool_result(
         self,
-        tool_name: str,
+        tool_call: ToolCall,
         result: ToolResult,
+        iterations: int,
     ) -> None:
-        try:
-            payload = json.loads(result.content)
-        except (TypeError, json.JSONDecodeError):
-            return
-        if not isinstance(payload, dict) or payload.get("input_error") is not True:
-            return
-        for group, (tool_names, _) in self.tool_call_budgets.items():
-            if tool_name in tool_names and self._tool_budget_counts[group] > 0:
-                self._tool_budget_counts[group] -= 1
-
-    @staticmethod
-    def _budget_error_result(tool_call: ToolCall, error: str) -> ToolResult:
-        return ToolResult(
-            tool_call_id=tool_call.id,
-            name=tool_call.name,
-            content=json.dumps(
-                {
-                    "error": error,
-                    "budget_exhausted": True,
-                    "title": "工具预算拒绝调用",
-                },
-                ensure_ascii=False,
-            ),
-            is_error=True,
+        if self.logger:
+            self.logger.log_tool_call(
+                iterations,
+                tool_call.name,
+                tool_call.arguments,
+                result.content,
+                result.is_error,
+                tool_call_id=tool_call.id,
+            )
+        if tool_call.name == "load_skill" and not result.is_error:
+            self._on_skill_loaded(tool_call.arguments)
+            if self.logger:
+                self.logger.log_skill_loaded(iterations, tool_call.arguments)
+        self.messages.append(
+            Message(
+                role=Role.TOOL,
+                content=result.content,
+                tool_call_id=result.tool_call_id,
+                name=result.name,
+            )
         )
 
-    @staticmethod
-    def _policy_error_result(tool_call: ToolCall, error: str) -> ToolResult:
-        return ToolResult(
-            tool_call_id=tool_call.id,
-            name=tool_call.name,
-            content=json.dumps(
-                {
-                    "error": error,
-                    "policy_rejected": True,
-                    "title": "工具状态策略拒绝调用",
-                },
-                ensure_ascii=False,
-            ),
-            is_error=True,
+    def _resolve_tool_call_sync(self, tool_call: ToolCall) -> ToolResult:
+        return self.tools.execute(
+            tool_call.id, tool_call.name, tool_call.arguments
         )
 
-    def _max_iterations_result(self, iterations: int) -> AgentResult:
-        reason = self._completion_block_reason()
-        content = "Max iterations reached"
-        if reason:
-            content = f"Max iterations reached before completion: {reason}"
-        return self._build_result(content, iterations)
-
-    def _iterations_exhausted_result(self, iterations: int) -> AgentResult:
-        if self.completion_guard is not None and self.completion_guard_blocks:
-            return self._max_iterations_result(iterations)
-        last_assistant = next(
-            (
-                message
-                for message in reversed(self.messages)
-                if message.role == Role.ASSISTANT and not message.tool_calls
-            ),
-            None,
+    async def _resolve_tool_call_async(self, tool_call: ToolCall) -> ToolResult:
+        return await self.tools.aexecute(
+            tool_call.id, tool_call.name, tool_call.arguments
         )
-        content = (
-            (last_assistant.content or "").strip()
-            if last_assistant and (last_assistant.content or "").strip()
-            else "Max iterations reached"
+
+    def _handle_assistant_message(
+        self,
+        response: Message,
+        iterations: int,
+    ) -> tuple[_TurnKind, AgentResult | None]:
+        """Classify an assistant turn: finish, or run tools."""
+        self.messages.append(response)
+        if response.tool_calls:
+            return "tools", None
+        return "done", self._build_result(response.content or "", iterations)
+
+    def _nudge_best_answer_sync(self, iterations: int) -> AgentResult:
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content=(
+                    "Maximum iterations reached. Please provide your best answer now."
+                ),
+            )
         )
-        return self._build_result(
-            self._completion_response_content(content),
-            iterations,
+        final = self.llm.chat(
+            self._all_messages(),
+            temperature=self.temperature,
+            iteration=iterations + 1,
         )
+        self.messages.append(final)
+        return self._build_result(final.content or "", iterations)
 
-    def _uses_blocking_completion_guard(self) -> bool:
-        return self.completion_guard is not None and self.completion_guard_blocks
+    async def _nudge_best_answer_async(self, iterations: int) -> AgentResult:
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content=(
+                    "Maximum iterations reached. Please provide your best answer now."
+                ),
+            )
+        )
+        final = await self.llm.achat(
+            self._all_messages(),
+            temperature=self.temperature,
+            iteration=iterations + 1,
+        )
+        self.messages.append(final)
+        return self._build_result(final.content or "", iterations)
 
     def run(self) -> AgentResult:
         iterations = 0
@@ -263,73 +193,18 @@ class AgentLoop:
             except MalformedFunctionCallError:
                 self._append_malformed_tool_feedback()
                 continue
-            self.messages.append(response)
-
-            if not response.tool_calls:
-                reason = self._completion_block_reason()
-                if self._should_block_completion(reason):
-                    self._append_completion_feedback(reason)
-                    continue
-                return self._build_result(
-                    self._completion_response_content(response.content or ""),
-                    iterations,
-                )
 
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
+                return done
+
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
-                policy_error = self._repeat_policy_error(tc.name)
-                budget_error = (
-                    None if policy_error else self._consume_tool_budget(tc.name)
-                )
-                result = (
-                    self._policy_error_result(tc, policy_error)
-                    if policy_error
-                    else (
-                        self._budget_error_result(tc, budget_error)
-                        if budget_error
-                        else self.tools.execute(tc.id, tc.name, tc.arguments)
-                    )
-                )
-                if not policy_error and not budget_error:
-                    self._refund_tool_budget_for_input_error(tc.name, result)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
-                if tc.name == "load_skill" and not result.is_error:
-                    self._on_skill_loaded(tc.arguments)
-                    if self.logger:
-                        self.logger.log_skill_loaded(iterations, tc.arguments)
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
-                )
+                result = self._resolve_tool_call_sync(tc)
+                self._record_tool_result(tc, result, iterations)
 
-        if self._uses_blocking_completion_guard():
-            return self._max_iterations_result(iterations)
-
-        self.messages.append(
-            Message(
-                role=Role.USER,
-                content="Maximum iterations reached. Please provide your best answer now.",
-            )
-        )
-        final = self.llm.chat(
-            self._all_messages(),
-            temperature=self.temperature,
-            iteration=iterations + 1,
-        )
-        self.messages.append(final)
-        return self._build_result(final.content or "", iterations)
+        return self._nudge_best_answer_sync(iterations)
 
     async def arun(self) -> AgentResult:
         iterations = 0
@@ -345,75 +220,18 @@ class AgentLoop:
             except MalformedFunctionCallError:
                 self._append_malformed_tool_feedback()
                 continue
-            self.messages.append(response)
-
-            if not response.tool_calls:
-                reason = self._completion_block_reason()
-                if self._should_block_completion(reason):
-                    self._append_completion_feedback(reason)
-                    continue
-                return self._build_result(
-                    self._completion_response_content(response.content or ""),
-                    iterations,
-                )
 
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
+                return done
+
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
-                policy_error = self._repeat_policy_error(tc.name)
-                budget_error = (
-                    None if policy_error else self._consume_tool_budget(tc.name)
-                )
-                result = (
-                    self._policy_error_result(tc, policy_error)
-                    if policy_error
-                    else (
-                        self._budget_error_result(tc, budget_error)
-                        if budget_error
-                        else await self.tools.aexecute(
-                            tc.id, tc.name, tc.arguments
-                        )
-                    )
-                )
-                if not policy_error and not budget_error:
-                    self._refund_tool_budget_for_input_error(tc.name, result)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
-                if tc.name == "load_skill" and not result.is_error:
-                    self._on_skill_loaded(tc.arguments)
-                    if self.logger:
-                        self.logger.log_skill_loaded(iterations, tc.arguments)
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
-                )
-
-        if self._uses_blocking_completion_guard():
-            return self._max_iterations_result(iterations)
+                result = await self._resolve_tool_call_async(tc)
+                self._record_tool_result(tc, result, iterations)
 
-        self.messages.append(
-            Message(
-                role=Role.USER,
-                content="Maximum iterations reached. Please provide your best answer now.",
-            )
-        )
-        final = await self.llm.achat(
-            self._all_messages(),
-            temperature=self.temperature,
-            iteration=iterations + 1,
-        )
-        self.messages.append(final)
-        return self._build_result(final.content or "", iterations)
+        return await self._nudge_best_answer_async(iterations)
 
     def stream(self) -> Iterator[AgentEvent]:
         iterations = 0
@@ -434,72 +252,49 @@ class AgentLoop:
             except MalformedFunctionCallError:
                 self._append_malformed_tool_feedback()
                 continue
-            self.messages.append(response)
-
-            if not response.tool_calls:
-                reason = self._completion_block_reason()
-                if self._should_block_completion(reason):
-                    self._append_completion_feedback(reason)
-                    continue
-                final_content = self._completion_response_content(
-                    response.content or ""
-                )
+
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
-                    data={"content": final_content},
+                    data={"content": done.content},
                 )
                 yield AgentEvent(
                     type=AgentEventType.DONE,
-                    data=self._build_result(final_content, iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 return
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
-                )
-                policy_error = self._repeat_policy_error(tc.name)
-                budget_error = (
-                    None if policy_error else self._consume_tool_budget(tc.name)
+                    data={
+                        "name": tc.name,
+                        "arguments": tc.arguments,
+                        "id": tc.id,
+                    },
                 )
-                result = (
-                    self._policy_error_result(tc, policy_error)
-                    if policy_error
-                    else (
-                        self._budget_error_result(tc, budget_error)
-                        if budget_error
-                        else self.tools.execute(tc.id, tc.name, tc.arguments)
-                    )
-                )
-                if not policy_error and not budget_error:
-                    self._refund_tool_budget_for_input_error(tc.name, result)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
+                result = self._resolve_tool_call_sync(tc)
+                self._record_tool_result(tc, result, iterations)
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
-                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
-                )
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
+                    data={
+                        "name": result.name,
+                        "content": result.content,
+                        "is_error": result.is_error,
+                    },
                 )
 
+        final = self._nudge_best_answer_sync(iterations)
+        yield AgentEvent(
+            type=AgentEventType.MESSAGE,
+            data={"content": final.content},
+        )
         yield AgentEvent(
             type=AgentEventType.DONE,
-            data=self._iterations_exhausted_result(iterations).model_dump(),
+            data=final.model_dump(),
         )
 
     async def astream(self) -> AsyncIterator[AgentEvent]:
@@ -521,81 +316,47 @@ class AgentLoop:
             except MalformedFunctionCallError:
                 self._append_malformed_tool_feedback()
                 continue
-            self.messages.append(response)
-
-            if not response.tool_calls:
-                reason = self._completion_block_reason()
-                if self._should_block_completion(reason):
-                    self._append_completion_feedback(reason)
-                    continue
-                final_content = self._completion_response_content(
-                    response.content or ""
-                )
+
+            kind, done = self._handle_assistant_message(response, iterations)
+            if kind == "done" and done is not None:
                 yield AgentEvent(
                     type=AgentEventType.MESSAGE,
-                    data={"content": final_content},
+                    data={"content": done.content},
                 )
                 yield AgentEvent(
                     type=AgentEventType.DONE,
-                    data=self._build_result(final_content, iterations).model_dump(),
+                    data=done.model_dump(),
                 )
                 return
 
+            assert response.tool_calls is not None
             for tc in response.tool_calls:
                 self.tool_calls_made += 1
                 yield AgentEvent(
                     type=AgentEventType.TOOL_CALL,
-                    data={"name": tc.name, "arguments": tc.arguments, "id": tc.id},
-                )
-                policy_error = self._repeat_policy_error(tc.name)
-                budget_error = (
-                    None if policy_error else self._consume_tool_budget(tc.name)
+                    data={
+                        "name": tc.name,
+                        "arguments": tc.arguments,
+                        "id": tc.id,
+                    },
                 )
-                result = (
-                    self._policy_error_result(tc, policy_error)
-                    if policy_error
-                    else (
-                        self._budget_error_result(tc, budget_error)
-                        if budget_error
-                        else await self.tools.aexecute(
-                            tc.id, tc.name, tc.arguments
-                        )
-                    )
-                )
-                if not policy_error and not budget_error:
-                    self._refund_tool_budget_for_input_error(tc.name, result)
-                if self.logger:
-                    self.logger.log_tool_call(
-                        iterations,
-                        tc.name,
-                        tc.arguments,
-                        result.content,
-                        result.is_error,
-                        tool_call_id=tc.id,
-                    )
+                result = await self._resolve_tool_call_async(tc)
+                self._record_tool_result(tc, result, iterations)
                 yield AgentEvent(
                     type=AgentEventType.TOOL_RESULT,
-                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
-                )
-                self.messages.append(
-                    Message(
-                        role=Role.TOOL,
-                        content=result.content,
-                        tool_call_id=result.tool_call_id,
-                        name=result.name,
-                    )
+                    data={
+                        "name": result.name,
+                        "content": result.content,
+                        "is_error": result.is_error,
+                    },
                 )
 
+        final = await self._nudge_best_answer_async(iterations)
         yield AgentEvent(
-            type=AgentEventType.DONE,
-            data=self._iterations_exhausted_result(iterations).model_dump(),
+            type=AgentEventType.MESSAGE,
+            data={"content": final.content},
         )
-
-    def _build_result(self, content: str, iterations: int) -> AgentResult:
-        return AgentResult(
-            content=content,
-            messages=self.messages,
-            iterations=iterations,
-            tool_calls_made=self.tool_calls_made,
-            skills_used=list(self.active_skills),
+        yield AgentEvent(
+            type=AgentEventType.DONE,
+            data=final.model_dump(),
         )

+ 0 - 6
supply_agent/config.py

@@ -39,12 +39,6 @@ class Settings(BaseSettings):
     # Agent
     agent_max_iterations: int = Field(default=20, alias="AGENT_MAX_ITERATIONS")
     agent_temperature: float = Field(default=0.7, alias="AGENT_TEMPERATURE")
-    find_agent_timeout_seconds: float = Field(
-        default=1800.0,
-        ge=60.0,
-        le=7200.0,
-        alias="FIND_AGENT_TIMEOUT_SECONDS",
-    )
 
     # Skills
     skills_dir: Path = Field(default=Path("skills"), alias="SKILLS_DIR")

+ 7 - 1
supply_agent/logging/__init__.py

@@ -2,7 +2,11 @@
 
 from supply_agent.logging.logger import AgentLogger, get_agent_logger, slugify_agent_name
 from supply_agent.logging.parser import load_run_events, summarize_run
-from supply_agent.logging.publish import publish_run_artifacts
+from supply_agent.logging.publish import (
+    get_run_artifact_publisher,
+    publish_run_artifacts,
+    set_run_artifact_publisher,
+)
 from supply_agent.logging.visualize import generate_visualization, render_html
 
 __all__ = [
@@ -12,6 +16,8 @@ __all__ = [
     "load_run_events",
     "summarize_run",
     "publish_run_artifacts",
+    "set_run_artifact_publisher",
+    "get_run_artifact_publisher",
     "generate_visualization",
     "render_html",
 ]

+ 22 - 60
supply_agent/logging/publish.py

@@ -1,77 +1,39 @@
-"""Publish agent run logs: HTML visualize → OSS upload → MySQL record."""
+"""Pluggable hook for publishing agent run artifacts after each run.
+
+Framework stays free of infrastructure packages. Infra registers a publisher
+via ``set_run_artifact_publisher`` (see agent_logging under the infra package).
+"""
 
 from __future__ import annotations
 
-import logging
-from pathlib import Path
+from collections.abc import Callable
 from typing import TYPE_CHECKING
 
-from supply_agent.logging.logger import slugify_agent_name
-from supply_agent.logging.visualize import generate_visualization
-from supply_infra.config import get_infra_settings
-from supply_infra.db.repositories.oss_log_repo import OssLogRepository
-from supply_infra.db.session import get_session
-from supply_infra.oss.client import OssClient
-
 if TYPE_CHECKING:
     from supply_agent.logging.logger import AgentLogger
 
-_log = logging.getLogger("supply_agent.publish")
-
-
-def publish_run_artifacts(agent_logger: AgentLogger) -> str | None:
-    """
-    After a run finishes: generate HTML, upload .log/.jsonl/.html to OSS,
-    and insert a row into ``oss_logs``.
-
-    Returns the public HTML URL, or None if skipped / failed.
-    Failures are logged and do not raise (so agent runs are not broken).
-    """
-    settings = get_infra_settings()
-    if not settings.log_oss_upload_enabled:
-        return None
-    if not settings.aliyun_oss_access_key_id or not settings.aliyun_oss_access_key_secret:
-        _log.debug("OSS credentials missing; skip log publish")
-        return None
+RunArtifactPublisher = Callable[["AgentLogger"], str | None]
 
-    log_file = agent_logger.log_file
-    jsonl_file = agent_logger.jsonl_file
-    if not log_file or not log_file.exists():
-        _log.warning("No log file to publish")
-        return None
+_publisher: RunArtifactPublisher | None = None
 
-    try:
-        source = jsonl_file if jsonl_file and jsonl_file.exists() else log_file
-        html_path = generate_visualization(source)
 
-        agent_slug = slugify_agent_name(agent_logger.agent_name) or "agent"
-        log_name = html_path.stem
+def set_run_artifact_publisher(publisher: RunArtifactPublisher | None) -> None:
+    """Install or clear the post-run artifact publisher callback."""
+    global _publisher
+    _publisher = publisher
 
-        client = OssClient(settings)
-        files = [p for p in (log_file, jsonl_file, html_path) if p and Path(p).exists()]
-        html_url: str | None = None
 
-        for path in files:
-            key = client.object_key(agent_slug, Path(path).name)
-            url = client.upload_file(path, key)
-            if Path(path).suffix.lower() == ".html":
-                html_url = url
-            _log.info("Uploaded %s → %s", path.name, url)
+def get_run_artifact_publisher() -> RunArtifactPublisher | None:
+    return _publisher
 
-        if not html_url:
-            raise RuntimeError("HTML was not uploaded")
 
-        with get_session() as session:
-            OssLogRepository(session).create(
-                log_name=log_name,
-                agent_name=agent_logger.agent_name or agent_slug,
-                oss_path=html_url,
-            )
+def publish_run_artifacts(agent_logger: AgentLogger) -> str | None:
+    """
+    Invoke the registered publisher, if any.
 
-        _log.info("oss_logs saved: log_name=%s url=%s", log_name, html_url)
-        print(f"[publish] 可视化已上传: {html_url}")
-        return html_url
-    except Exception:
-        _log.exception("Failed to publish run artifacts")
-        print("[publish] 日志上传/入库失败,详见日志;不影响本次 Agent 结果")
+    Returns the public artifact URL, or None when no publisher is registered
+    or publishing is skipped / failed.
+    """
+    if _publisher is None:
         return None
+    return _publisher(agent_logger)

+ 21 - 0
supply_agent/tools/errors.py

@@ -0,0 +1,21 @@
+"""Tool result helpers."""
+
+from __future__ import annotations
+
+import json
+
+
+def content_indicates_error(content: str) -> bool:
+    """
+    Whether tool output represents an error payload.
+
+    Convention: JSON object with a truthy top-level ``error`` field.
+    Non-JSON / plain text / ``{"error": null}`` are treated as non-errors.
+    """
+    try:
+        payload = json.loads(content)
+    except (TypeError, json.JSONDecodeError):
+        return False
+    if not isinstance(payload, dict):
+        return False
+    return bool(payload.get("error"))

+ 3 - 4
supply_agent/tools/registry.py

@@ -4,6 +4,7 @@ from collections.abc import Callable
 from typing import Any
 
 from supply_agent.tools.base import Tool
+from supply_agent.tools.errors import content_indicates_error
 from supply_agent.types import ToolDefinition, ToolResult
 
 
@@ -52,12 +53,11 @@ class ToolRegistry:
                 is_error=True,
             )
         content = tool.execute(arguments)
-        is_error = content.startswith('{"error"')
         return ToolResult(
             tool_call_id=tool_call_id,
             name=name,
             content=content,
-            is_error=is_error,
+            is_error=content_indicates_error(content),
         )
 
     async def aexecute(self, tool_call_id: str, name: str, arguments: str) -> ToolResult:
@@ -71,12 +71,11 @@ class ToolRegistry:
                 is_error=True,
             )
         content = await tool.aexecute(arguments)
-        is_error = content.startswith('{"error"')
         return ToolResult(
             tool_call_id=tool_call_id,
             name=name,
             content=content,
-            is_error=is_error,
+            is_error=content_indicates_error(content),
         )
 
     def from_decorated(self, *funcs: Callable[..., Any]) -> ToolRegistry:

+ 5 - 0
supply_infra/__init__.py

@@ -1 +1,6 @@
 """SupplyAgent shared infrastructure: database, ODPS, scheduler."""
+
+from supply_infra.agent_logging.register import register_agent_logging_hooks
+
+# Wire OSS/DB log publish into supply_agent without importing infra from the framework.
+register_agent_logging_hooks()

+ 9 - 0
supply_infra/agent_logging/__init__.py

@@ -0,0 +1,9 @@
+"""Infra-side agent run logging (OSS publish, etc.)."""
+
+from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
+from supply_infra.agent_logging.register import register_agent_logging_hooks
+
+__all__ = [
+    "publish_run_artifacts_to_oss",
+    "register_agent_logging_hooks",
+]

+ 77 - 0
supply_infra/agent_logging/publish.py

@@ -0,0 +1,77 @@
+"""Publish agent run logs: HTML visualize → OSS upload → MySQL record."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from supply_agent.logging.logger import slugify_agent_name
+from supply_agent.logging.visualize import generate_visualization
+from supply_infra.config import get_infra_settings
+from supply_infra.db.repositories.oss_log_repo import OssLogRepository
+from supply_infra.db.session import get_session
+from supply_infra.oss.client import OssClient
+
+if TYPE_CHECKING:
+    from supply_agent.logging.logger import AgentLogger
+
+_log = logging.getLogger("supply_infra.agent_logging.publish")
+
+
+def publish_run_artifacts_to_oss(agent_logger: AgentLogger) -> str | None:
+    """
+    After a run finishes: generate HTML, upload .log/.jsonl/.html to OSS,
+    and insert a row into ``oss_logs``.
+
+    Returns the public HTML URL, or None if skipped / failed.
+    Failures are logged and do not raise (so agent runs are not broken).
+    """
+    settings = get_infra_settings()
+    if not settings.log_oss_upload_enabled:
+        return None
+    if not settings.aliyun_oss_access_key_id or not settings.aliyun_oss_access_key_secret:
+        _log.debug("OSS credentials missing; skip log publish")
+        return None
+
+    log_file = agent_logger.log_file
+    jsonl_file = agent_logger.jsonl_file
+    if not log_file or not log_file.exists():
+        _log.warning("No log file to publish")
+        return None
+
+    try:
+        source = jsonl_file if jsonl_file and jsonl_file.exists() else log_file
+        html_path = generate_visualization(source)
+
+        agent_slug = slugify_agent_name(agent_logger.agent_name) or "agent"
+        log_name = html_path.stem
+
+        client = OssClient(settings)
+        files = [p for p in (log_file, jsonl_file, html_path) if p and Path(p).exists()]
+        html_url: str | None = None
+
+        for path in files:
+            key = client.object_key(agent_slug, Path(path).name)
+            url = client.upload_file(path, key)
+            if Path(path).suffix.lower() == ".html":
+                html_url = url
+            _log.info("Uploaded %s → %s", path.name, url)
+
+        if not html_url:
+            raise RuntimeError("HTML was not uploaded")
+
+        with get_session() as session:
+            OssLogRepository(session).create(
+                log_name=log_name,
+                agent_name=agent_logger.agent_name or agent_slug,
+                oss_path=html_url,
+            )
+
+        _log.info("oss_logs saved: log_name=%s url=%s", log_name, html_url)
+        print(f"[publish] 可视化已上传: {html_url}")
+        return html_url
+    except Exception:
+        _log.exception("Failed to publish run artifacts")
+        print("[publish] 日志上传/入库失败,详见日志;不影响本次 Agent 结果")
+        return None

+ 11 - 0
supply_infra/agent_logging/register.py

@@ -0,0 +1,11 @@
+"""Register infra callbacks onto the supply_agent framework hooks."""
+
+from __future__ import annotations
+
+
+def register_agent_logging_hooks() -> None:
+    """Wire OSS publish into Agent._finish_run (safe to call repeatedly)."""
+    from supply_agent.logging.publish import set_run_artifact_publisher
+    from supply_infra.agent_logging.publish import publish_run_artifacts_to_oss
+
+    set_run_artifact_publisher(publish_run_artifacts_to_oss)

+ 20 - 5
supply_infra/db/repositories/video_discovery_repo.py

@@ -72,11 +72,18 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         return self.session.scalar(stmt)
 
     def list_skip_grade_ids(self, biz_dt: str) -> set[int]:
-        """返回指定业务日已成功完成(finished)的 demand_grade.id。"""
-        stmt = select(VideoDiscoveryRun.demand_grade_id).where(
-            VideoDiscoveryRun.biz_dt == biz_dt,
-            VideoDiscoveryRun.demand_grade_id.is_not(None),
-            VideoDiscoveryRun.status == "finished",
+        """返回指定业务日已有候选落库的 demand_grade.id(与 run_outcome 成功口径一致)。"""
+        stmt = (
+            select(VideoDiscoveryRun.demand_grade_id)
+            .join(
+                VideoDiscoveryCandidate,
+                VideoDiscoveryCandidate.run_id == VideoDiscoveryRun.run_id,
+            )
+            .where(
+                VideoDiscoveryRun.biz_dt == biz_dt,
+                VideoDiscoveryRun.demand_grade_id.is_not(None),
+            )
+            .distinct()
         )
         try:
             return {
@@ -313,6 +320,14 @@ class VideoDiscoveryRepository(BaseRepository[VideoDiscoveryRun]):
         ).limit(limit)
         return list(self.session.scalars(stmt).all())
 
+    def has_candidates(self, run_id: str) -> bool:
+        stmt = (
+            select(VideoDiscoveryCandidate.id)
+            .where(VideoDiscoveryCandidate.run_id == run_id)
+            .limit(1)
+        )
+        return self.session.scalar(stmt) is not None
+
     def list_publishable_candidates(
         self,
         *,

+ 1 - 1
supply_infra/scheduler/jobs/demand_pool/tree_weight.py

@@ -28,7 +28,7 @@ from supply_infra.db.repositories.demand_popularity_stats_repo import (
 from supply_infra.db.repositories.global_tree_category_repo import (
     GlobalTreeCategoryRepository,
 )
-from supply_agent.ranking import rank_to_scores
+from supply_infra.scoring.ranking import rank_to_scores
 from supply_infra.db.session import get_session
 
 logger = logging.getLogger(__name__)

+ 20 - 0
supply_infra/scoring/__init__.py

@@ -0,0 +1,20 @@
+"""Demand-pool scoring helpers shared by agents and scheduler jobs."""
+
+from supply_infra.scoring.posterior import (
+    POSTERIOR_EFFECT_ACCEPTABLE_FLOOR,
+    classify_posterior_effect,
+    format_posterior_dim_with_count,
+    format_posterior_pair,
+    format_posterior_value,
+)
+from supply_infra.scoring.ranking import rank_to_scores, rank_with_scores
+
+__all__ = [
+    "POSTERIOR_EFFECT_ACCEPTABLE_FLOOR",
+    "classify_posterior_effect",
+    "format_posterior_dim_with_count",
+    "format_posterior_pair",
+    "format_posterior_value",
+    "rank_to_scores",
+    "rank_with_scores",
+]

+ 0 - 0
supply_agent/posterior.py → supply_infra/scoring/posterior.py


+ 0 - 0
supply_agent/ranking.py → supply_infra/scoring/ranking.py


+ 13 - 4
supply_infra/services/video_discovery_service.py

@@ -132,6 +132,10 @@ class VideoDiscoveryService:
                 return None
             return _serialize_run(run)
 
+    def has_candidates(self, run_id: str) -> bool:
+        with get_session() as session:
+            return VideoDiscoveryRepository(session).has_candidates(run_id)
+
     def create_run(self, values: dict[str, Any]) -> dict[str, Any]:
         with get_session() as session:
             VideoDiscoveryRepository(session).create_run(values)
@@ -263,11 +267,16 @@ class VideoDiscoveryService:
         with get_session() as session:
             repo = VideoDiscoveryRepository(session)
             existing = repo.get_by_biz_dt_and_grade(biz_dt, demand_grade_id)
-            if existing is not None and not force and existing.status in _SKIP_STATUSES:
-                return None, (
-                    f"biz_dt={biz_dt} demand_grade_id={demand_grade_id} "
-                    f"已执行过 run_id={existing.run_id} status={existing.status}"
+            if existing is not None and not force:
+                already_done = (
+                    existing.status in _SKIP_STATUSES
+                    or repo.has_candidates(str(existing.run_id))
                 )
+                if already_done:
+                    return None, (
+                        f"biz_dt={biz_dt} demand_grade_id={demand_grade_id} "
+                        f"已执行过 run_id={existing.run_id} status={existing.status}"
+                    )
             run_id = existing.run_id if existing is not None else str(values["run_id"])
             payload = {**values, "run_id": run_id}
             repo.upsert_scheduled_run(payload)

+ 51 - 189
tests/supply_infra/scheduler/test_discover_videos_from_demands.py

@@ -10,7 +10,6 @@ from sqlalchemy import create_engine
 from sqlalchemy.orm import Session, sessionmaker
 
 from agents.find_agent import create_find_agent
-from agents.find_agent.agent import find_agent_completion_guard
 from agents.find_agent.async_runner import arun_find_agent
 from agents.find_agent.demand_run import (
     FindDemandContext,
@@ -19,7 +18,6 @@ from agents.find_agent.demand_run import (
     prepare_video_discovery_run,
 )
 from agents.find_agent.tools import video_discovery_store
-from supply_agent.types import Message, Role
 from supply_infra.db.models.video_discovery import (
     VideoDiscoveryCandidate,
     VideoDiscoveryRun,
@@ -34,6 +32,7 @@ def _expire_on_commit_session_factory() -> sessionmaker[Session]:
     engine = create_engine("sqlite+pysqlite:///:memory:")
     # SQLite 对 BigInteger PK 不会自增,测试里显式写入 id。
     VideoDiscoveryRun.__table__.create(engine)
+    VideoDiscoveryCandidate.__table__.create(engine)
     return sessionmaker(bind=engine, autoflush=False, autocommit=False)
 
 
@@ -209,191 +208,47 @@ def test_video_discovery_models_exclude_unused_columns() -> None:
     }.isdisjoint(candidate_columns)
 
 
-def test_find_agent_enables_deterministic_completion_control() -> None:
+def test_create_find_agent_registers_discovery_tools() -> None:
     agent = create_find_agent()
 
+    assert agent.name == "find_agent"
     assert "audit_video_discovery_run" in agent.tools.list_tools()
-    assert agent.completion_guard is find_agent_completion_guard
-    assert agent.completion_guard_blocks is False
-    assert (
-        "audit_video_discovery_run"
-        in agent.tool_repeat_requires_change["query_video_discovery_state"]
-    )
+    assert "query_video_discovery_state" in agent.tools.list_tools()
 
 
-def test_find_agent_guard_allows_agent_declared_tool_failure() -> None:
-    messages = [
-        Message(
-            role=Role.ASSISTANT,
-            content=(
-                "任务未完成(工具故障)\n"
-                "失败工具:douyin_search\n"
-                "未产出有效推荐。"
-            ),
+def _seed_candidate(
+    factory: sessionmaker[Session],
+    *,
+    run_id: str,
+    aweme_id: str = "7631830155522179258",
+    row_id: int = 1,
+) -> None:
+    with factory() as session:
+        session.add(
+            VideoDiscoveryCandidate(
+                id=row_id,
+                run_id=run_id,
+                aweme_id=aweme_id,
+                decision_bucket="pending_evaluation",
+            )
         )
-    ]
-
-    assert find_agent_completion_guard(messages) is None
-
-
-def _tool_message(name: str, payload: dict[str, object]) -> Message:
-    return Message(
-        role=Role.TOOL,
-        name=name,
-        content=json.dumps(payload, ensure_ascii=False),
-    )
-
-
-def _primary_state_payload(primary_ids: list[str]) -> dict[str, object]:
-    return {
-        "run": {"primary_count": len(primary_ids), "status": "finished"},
-        "candidates": [
-            {
-                "aweme_id": aweme_id,
-                "decision_bucket": "primary",
-                "detail_verified": True,
-                "content_portrait_attempted": True,
-                "account_portrait_attempted": True,
-                "age_portraits_normalized": True,
-            }
-            for aweme_id in primary_ids
-        ],
-    }
-
-
-def test_find_agent_guard_allows_finish_with_five_primary() -> None:
-    primary_ids = [f"7123456789012345{i}" for i in range(5)]
-    primary_lines = "\n".join(
-        f"{index + 1}. 标题{index} aweme_id={aweme_id}"
-        for index, aweme_id in enumerate(primary_ids)
-    )
-    messages = [
-        _tool_message("create_video_discovery_run", {"run_id": "run-1"}),
-        _tool_message(
-            "batch_save_video_candidate_evaluations",
-            {"status": "finished", "primary_count": 5},
-        ),
-        _tool_message(
-            "query_video_discovery_state",
-            _primary_state_payload(primary_ids),
-        ),
-        Message(
-            role=Role.ASSISTANT,
-            content=f"需求理解:广场舞。\n\n主推荐\n{primary_lines}",
-        ),
-    ]
-
-    assert find_agent_completion_guard(messages) is None
-
-
-def test_find_agent_guard_relaxed_path_skips_audit_and_search_order() -> None:
-    primary_ids = [f"7123456789012345{i}" for i in range(5)]
-    messages = [
-        _tool_message("create_video_discovery_run", {"run_id": "run-1"}),
-        _tool_message(
-            "batch_save_video_candidate_evaluations",
-            {"status": "finished", "primary_count": 5},
-        ),
-        _tool_message(
-            "audit_video_discovery_run",
-            {
-                "can_finish": False,
-                "critical_violations": ["独立根搜索词少于2个"],
-            },
-        ),
-        _tool_message(
-            "query_video_discovery_state",
-            _primary_state_payload(primary_ids),
-        ),
-        Message(
-            role=Role.ASSISTANT,
-            content=(
-                "主推荐\n"
-                + "\n".join(f"- aweme_id={aweme_id}" for aweme_id in primary_ids)
-            ),
-        ),
-    ]
-
-    assert find_agent_completion_guard(messages) is None
-
-
-def test_find_agent_guard_allows_partial_primary_in_report() -> None:
-    primary_ids = [f"7123456789012345{i}" for i in range(5)]
-    reported_ids = primary_ids[:2]
-    messages = [
-        _tool_message("create_video_discovery_run", {"run_id": "run-1"}),
-        _tool_message(
-            "batch_save_video_candidate_evaluations",
-            {"status": "finished", "primary_count": 5},
-        ),
-        _tool_message(
-            "query_video_discovery_state",
-            _primary_state_payload(primary_ids),
-        ),
-        Message(
-            role=Role.ASSISTANT,
-            content="主推荐\n"
-            + "\n".join(f"- aweme_id={aweme_id}" for aweme_id in reported_ids),
-        ),
-    ]
-
-    assert find_agent_completion_guard(messages) is None
-
-
-def test_find_agent_guard_allows_zero_primary_finish() -> None:
-    messages = [
-        _tool_message("create_video_discovery_run", {"run_id": "run-1"}),
-        _tool_message(
-            "batch_save_video_candidate_evaluations",
-            {"status": "finished", "primary_count": 0},
-        ),
-        _tool_message(
-            "query_video_discovery_state",
-            {"run": {"primary_count": 0, "status": "finished"}, "candidates": []},
-        ),
-        Message(
-            role=Role.ASSISTANT,
-            content="未找到满足条件的候选,本轮结束。",
-        ),
-    ]
-
-    assert find_agent_completion_guard(messages) is None
-
-
-def test_find_agent_completion_guard_reports_stale_state_hint() -> None:
-    primary_ids = [f"7123456789012345{i}" for i in range(5)]
-    messages = [
-        _tool_message("create_video_discovery_run", {"run_id": "run-1"}),
-        _tool_message(
-            "query_video_discovery_state",
-            _primary_state_payload(primary_ids),
-        ),
-        _tool_message(
-            "batch_save_video_candidate_evaluations",
-            {"status": "finished", "primary_count": 5},
-        ),
-        Message(
-            role=Role.ASSISTANT,
-            content="主推荐\n" + "\n".join(f"- aweme_id={pid}" for pid in primary_ids),
-        ),
-    ]
-
-    reason = find_agent_completion_guard(messages)
-    assert reason is not None
-    assert "finished 之后" in reason
+        session.commit()
 
 
-def test_list_skip_grade_ids_only_finished(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_list_skip_grade_ids_when_candidates_exist(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
     factory = _expire_on_commit_session_factory()
     _patch_service_session(monkeypatch, factory)
-    _seed_run(factory, run_id="running-run", demand_grade_id=301, status="running")
+    _seed_run(factory, run_id="running-empty", demand_grade_id=301, status="running")
     _seed_run(
         factory,
-        run_id="finished-run",
+        run_id="running-with-candidates",
         demand_grade_id=302,
-        status="finished",
+        status="running",
         row_id=2,
     )
+    _seed_candidate(factory, run_id="running-with-candidates")
 
     from supply_infra.services.video_discovery_service import get_video_discovery_service
 
@@ -401,24 +256,30 @@ def test_list_skip_grade_ids_only_finished(monkeypatch: pytest.MonkeyPatch) -> N
     assert skip_ids == {302}
 
 
-def test_evaluate_find_agent_run_marks_max_iterations_as_failed() -> None:
+def test_evaluate_find_agent_run_succeeds_when_candidates_exist(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
     from agents.find_agent.run_outcome import evaluate_find_agent_run
     from supply_agent.types import AgentResult
 
+    monkeypatch.setattr(
+        "agents.find_agent.run_outcome.get_video_discovery_service",
+        lambda: type(
+            "Svc",
+            (),
+            {"has_candidates": staticmethod(lambda _run_id: True)},
+        )(),
+    )
+
     outcome = evaluate_find_agent_run(
         "run-1",
-        AgentResult(
-            content="Max iterations reached before completion: 尚未保存候选评估",
-            messages=[],
-            iterations=60,
-            tool_calls_made=0,
-        ),
+        AgentResult(content="任意文案", messages=[], iterations=3, tool_calls_made=0),
     )
-    assert outcome.succeeded is False
-    assert outcome.failure_reason == "max_iterations_reached"
+    assert outcome.succeeded is True
+    assert outcome.failure_reason is None
 
 
-def test_evaluate_find_agent_run_requires_finished_status(
+def test_evaluate_find_agent_run_fails_without_candidates(
     monkeypatch: pytest.MonkeyPatch,
 ) -> None:
     from agents.find_agent.run_outcome import evaluate_find_agent_run
@@ -429,20 +290,21 @@ def test_evaluate_find_agent_run_requires_finished_status(
         lambda: type(
             "Svc",
             (),
-            {
-                "lookup_run": staticmethod(
-                    lambda _run_id: {"status": "running", "primary_count": 5}
-                )
-            },
+            {"has_candidates": staticmethod(lambda _run_id: False)},
         )(),
     )
 
     outcome = evaluate_find_agent_run(
         "run-1",
-        AgentResult(content="主推荐\n完成", messages=[], iterations=10, tool_calls_made=0),
+        AgentResult(
+            content="任务未完成(工具故障)",
+            messages=[],
+            iterations=1,
+            tool_calls_made=0,
+        ),
     )
     assert outcome.succeeded is False
-    assert outcome.failure_reason == "run_status_running"
+    assert outcome.failure_reason == "no_candidates"
 
 
 @patch(