feat: add unified strategy project workflow
This commit is contained in:
1 parent
67d12929fc
commit
a26fd32eba
28 files changed
+3382
-191
No files matched your search
@@ -542,6 +542,8 @@ Self-contained product knowledge base. **All knowledge capability lives in this
|
||||
|
||||
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
|
||||
|
||||
Strategy-selection fan-out (`routers/parallel_agents.py`) must inspect its final AI message with the shared `is_llm_error_message` detector and require substantive visible text before constructing a success result. A middleware fallback, empty/thinking-only response, or punctuation-only provider degeneration is a failed seat run: raise so the stream emits `agent_error`; never wrap it in `agent_done`. Regression coverage: `tests/test_roundtable_model_fallback.py`.
|
||||
|
||||
**Listing cache + pagination** ? to keep `GET /api/agents` fast, the gallery's per-card extras (skills + tool_groups) are denormalized into two cache tables instead of reading every agent's `config.yaml` on each request: `agent_skills` (relational, one row per (agent, skill) with `position`) and `agent_extras` (one row per agent: `tool_groups` JSON + `model` + `skills_synced_at`). `config.yaml` remains authoritative for the agent runtime; these tables are a read cache, write-through on create/update and backfilled from disk by `_sync_legacy_agents` (mtime-vs-`skills_synced_at` drift check). The list endpoint enriches via `AgentStore.get_extras_for([ids])` (two batched `IN` queries). Both tables are auto-created by `Base.metadata.create_all` (no migration needed). `GET /api/agents` supports **opt-in server-side pagination**: `scope` (`mine` = owned + favorited / `square` = built-in/published public), `page`, `page_size`, returning `{agents, total, page, page_size}`; omitting `scope`/`page` preserves the legacy "return everything" behavior. Name `search` matches the raw OR desensitized name (the frontend's static term?abbrev map is mirrored server-side in `deerflow/config/agent_name_desensitize.py`); `tag_ids` is an SQL OR-filter; `owner` keeps only agents published by that user id (the card's ??? ? built-in agents never match), powering the gallery's click-???-to-filter. `POST /api/agents/private-square/list` takes the same pagination params (incl. `owner`). Repository entry point: `AgentStore.list_paginated(...)`. Tests: `tests/test_agent_listing_cache.py::test_list_paginated_owner_filter_keeps_one_publisher`.
|
||||
|
||||
**Homepage selector (agent picker on the new-chat page)** ? single admin-curated list backed by `agents.featured_order` (nullable BIGINT). NULL = not featured. Only `published = true` agents can be featured (router enforces on writes); any published agent is eligible regardless of ownership. Cleared with `order: null`. The list rendered to end users is `WHERE featured_order IS NOT NULL` + visibility-filter, sorted by `featured_order ASC`. Admins reorder by sending the desired ids to `PUT /api/agents/featured/order` (assigns indices 0..N-1). BIGINT is used rather than INT so the management UI can seed initial values from `Date.now()` (overflows INT on MySQL).
|
||||
@@ -1000,6 +1002,15 @@ scheduler runs once per deployment. Per run:
|
||||
- Supports `supports_vision` flag for image understanding models
|
||||
- Config values starting with `$` resolved as environment variables
|
||||
- Missing provider modules surface actionable install hints from reflection resolvers (for example `uv add langchain-google-genai`)
|
||||
- Generic `langchain_openai:ChatOpenAI` entries are transparently wrapped by
|
||||
`PatchedChatOpenAI`. Besides preserving reasoning/tool-call metadata, the
|
||||
patch recognizes compatible gateways that encode failures inside HTTP 200
|
||||
payloads (`base_resp.status_code != 0` with null/empty `choices`) in both
|
||||
streaming and non-streaming paths. It raises the stable
|
||||
`OpenAICompatibleUpstreamError`; `LLMErrorHandlingMiddleware` retries
|
||||
transient concurrency/inference failures, while genuine quota/balance
|
||||
failures are classified as non-retriable and surfaced immediately. Embedded
|
||||
vendor failures therefore cannot become empty successful assistant turns.
|
||||
|
||||
### Roundtable Model Fault Tolerance (会商模型自动容错 / 换下一个模型)
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ Custom agents are indexed in the `agents` database table. The `id` column is the
|
||||
|
||||
The built-in `forced-research-responder` (「强制检索输出助手」) is seeded at Gateway startup. Unlike an ordinary skill-enabled custom agent, it has a runtime-enforced per-turn collection gate: the model cannot finish a response until a retrieval/knowledge/MCP tool or a skill Python script returns usable information. It then answers directly in the user's requested chat format rather than generating an artifact. The agent may create or edit only temporary `.py` files below `/mnt/user-data/workspace/` and may execute `.py` skill scripts through `bash`; writes to `outputs`, Markdown/document creation, `present_files`, general shell commands, and skill mutation are denied. Administrators can edit its model and skill allowlist from agent management; the runtime safety policy remains fixed. Regression coverage: `tests/test_forced_research_agent.py`.
|
||||
|
||||
The strategy-selection workbench owns nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. The angle agents use the existing `policy-knowledge` skill and read/search tool groups, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation. Regression coverage: `tests/test_strategy_selection_seed.py`.
|
||||
The strategy-selection workbench owns nine folder-backed functional agents: one intent agent, six independent angle agents (governance, diplomacy, economy/supply-chain, security risk, public opinion, and law/policy), one judge, and one final solution editor. Defaults ship in `app/gateway/routers/_strategy_selection_seed_assets/` and are copied only when an agent directory is absent, before `_sync_legacy_agents`; existing administrator edits are never overwritten. The angle agents use the existing `policy-knowledge` skill and read/search tool groups, intent uses `intent-clarification-policy`, judging is input-only, and the solution editor has file-write access for `/mnt/user-data/outputs/综合策略方案.md`. Every default SOUL requires neutral evidence handling, de-escalation and civilian protection, and prohibits operational combat guidance or targeted political manipulation. The parallel strategy stream treats a final `is_llm_error_message` fallback, empty/thinking-only response, or punctuation-only provider degeneration as `agent_error`, never as a successful `agent_done`. Regression coverage: `tests/test_strategy_selection_seed.py` and `tests/test_roundtable_model_fallback.py`.
|
||||
|
||||
### AI Writing Intranet Retrieval
|
||||
|
||||
@@ -545,6 +545,13 @@ the configured model has an explicit, proxy-supported thinking control. This
|
||||
prevents DeerFlow's connection check from sending vendor-specific `thinking`
|
||||
fields that a generic OpenAI-compatible gateway may reject.
|
||||
|
||||
OpenAI-compatible gateways that return vendor errors inside an HTTP 200
|
||||
response (for example, a non-zero `base_resp.status_code` with empty/null
|
||||
`choices`) are treated as upstream failures in both streaming and
|
||||
non-streaming calls. DeerFlow retries transient upstream responses instead of
|
||||
accepting an empty assistant turn, while genuine quota/balance failures are
|
||||
classified as non-retriable and surfaced immediately instead of being retried.
|
||||
|
||||
### Running
|
||||
|
||||
**Full Application** (from project root):
|
||||
|
||||
@@ -15,7 +15,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.auth.password import verify_password_async
|
||||
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
||||
from app.gateway.deps import get_agent_store, get_config, get_skill_store, get_tag_store
|
||||
from app.gateway.routers._strategy_selection_seed import required_agent_ids as required_strategy_agent_ids
|
||||
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||
from deerflow.config.agents_api_config import get_agents_api_config
|
||||
from deerflow.config.agents_config import CommonQuestion, list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||
@@ -23,13 +24,17 @@ from deerflow.config.app_config import AppConfig
|
||||
from deerflow.config.paths import get_paths
|
||||
from deerflow.config.system_settings import load_system_settings, normalize_square_id, resolve_default_square_id
|
||||
from deerflow.persistence.agents import AgentStore
|
||||
from deerflow.persistence.skills import SkillStore
|
||||
from deerflow.persistence.tags import TagStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.skills.cascade import sync_agent_skills_to_available
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.types import SkillCategory
|
||||
from deerflow.skills.usage import all_entries as get_skill_usage_entries
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api", tags=["agents"])
|
||||
_STRATEGY_SELECTION_AGENT_IDS = frozenset(required_strategy_agent_ids())
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
@@ -276,6 +281,109 @@ def _validate_storage_id(agent_id: str) -> str:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
async def _validate_strategy_agent_skills(
|
||||
requested_skills: list[str] | None,
|
||||
*,
|
||||
app_config: AppConfig,
|
||||
skill_store: SkillStore,
|
||||
) -> list[str] | None:
|
||||
"""Validate a global strategy role's skill allowlist.
|
||||
|
||||
Strategy-selection agents are built-in roles shared by every project and
|
||||
every user. Their allowlists may therefore reference only canonical skill
|
||||
names that exist on disk, are enabled/active, and are globally visible:
|
||||
deployment-owned public skills, explicit built-in rows, or custom skills
|
||||
currently published by their owner. In particular, an administrator must
|
||||
not be able to attach a different user's unpublished skill merely because
|
||||
``?admin=true`` exposes it in the management catalog.
|
||||
"""
|
||||
|
||||
if requested_skills is None:
|
||||
return None
|
||||
|
||||
normalized: list[str] = []
|
||||
for raw_name in requested_skills:
|
||||
name = raw_name.strip()
|
||||
if not name:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Strategy agent skill names cannot be blank",
|
||||
)
|
||||
if name not in normalized:
|
||||
normalized.append(name)
|
||||
|
||||
try:
|
||||
skills = get_or_new_skill_storage(app_config=app_config).load_skills(
|
||||
enabled_only=False
|
||||
)
|
||||
ownership = {
|
||||
str(record.get("name") or ""): record
|
||||
for record in await skill_store.list_all()
|
||||
if record.get("name")
|
||||
}
|
||||
usage_entries = get_skill_usage_entries()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - stable API response
|
||||
logger.exception("Failed to validate strategy-agent skill allowlist")
|
||||
raise HTTPException(status_code=503, detail="Skill catalog is unavailable") from exc
|
||||
|
||||
by_name = {skill.name: skill for skill in skills}
|
||||
unknown = [name for name in normalized if name not in by_name]
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown strategy agent skills: {', '.join(unknown)}",
|
||||
)
|
||||
|
||||
unavailable = [
|
||||
name
|
||||
for name in normalized
|
||||
if not by_name[name].enabled
|
||||
or str((usage_entries.get(name) or {}).get("state") or "active") != "active"
|
||||
]
|
||||
if unavailable:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Disabled or archived strategy agent skills: {', '.join(unavailable)}",
|
||||
)
|
||||
|
||||
not_global: list[str] = []
|
||||
for name in normalized:
|
||||
skill = by_name[name]
|
||||
if skill.category != SkillCategory.CUSTOM:
|
||||
continue
|
||||
record = ownership.get(name)
|
||||
if record is not None and (
|
||||
record.get("owner_user_id") is None or bool(record.get("published"))
|
||||
):
|
||||
continue
|
||||
not_global.append(name)
|
||||
if not_global:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"Strategy agent skills must be globally available; "
|
||||
f"unpublished or unregistered custom skills: {', '.join(not_global)}"
|
||||
),
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _ensure_strategy_agent_deletable(agent_id: str) -> None:
|
||||
"""Keep fixed strategy role ids alive; their SOUL/skills remain editable."""
|
||||
|
||||
if agent_id in _STRATEGY_SELECTION_AGENT_IDS:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"Strategy role agent '{agent_id}' cannot be deleted; "
|
||||
"edit its SOUL and skills instead"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write_agent_config(
|
||||
agent_id: str,
|
||||
*,
|
||||
@@ -953,7 +1061,14 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
||||
next_description = update_data.get("description", current.get("description") or "")
|
||||
|
||||
existing_extras = _load_config_extras(agent_id)
|
||||
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
||||
if "skills" in fields_set and agent_id in _STRATEGY_SELECTION_AGENT_IDS:
|
||||
next_skills = await _validate_strategy_agent_skills(
|
||||
request.skills,
|
||||
app_config=get_config(http_request),
|
||||
skill_store=get_skill_store(http_request),
|
||||
)
|
||||
else:
|
||||
next_skills = request.skills if "skills" in fields_set else existing_extras["skills"]
|
||||
next_model = request.model if "model" in fields_set else existing_extras["model"]
|
||||
next_tool_groups = request.tool_groups if "tool_groups" in fields_set else existing_extras["tool_groups"]
|
||||
next_common_questions = request.common_questions if "common_questions" in fields_set else existing_extras["common_questions"]
|
||||
@@ -1079,6 +1194,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
||||
_, edit_mode = await _get_editable_or_raise(
|
||||
store, agent_id, user_id, is_admin=is_admin
|
||||
)
|
||||
_ensure_strategy_agent_deletable(agent_id)
|
||||
|
||||
if edit_mode == "admin":
|
||||
deleted = await store.delete_any(agent_id)
|
||||
|
||||
@@ -31,13 +31,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, AsyncIterator
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.roundtable_model_fallback import is_llm_error_message, reason_text
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_ROUNDTABLE_THREAD_METADATA,
|
||||
_auth_headers,
|
||||
@@ -50,6 +52,7 @@ from app.gateway.routers.multi_agent import (
|
||||
_stream_upstream,
|
||||
_validate_agent_id,
|
||||
)
|
||||
from deerflow.agents.roundtable_orchestrator.delivery import visible_seat_text
|
||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -58,6 +61,17 @@ router = APIRouter(prefix="/api/parallel-agents", tags=["parallel-agents"])
|
||||
# Soft cap on parallel degree — protects remote model concurrency + sandbox.
|
||||
MAX_PARALLEL_AGENTS = 8
|
||||
|
||||
# Strategy roles are expected to return a substantive task/strategy/evaluation,
|
||||
# not merely punctuation or a broken provider placeholder. Counting Unicode
|
||||
# alphanumerics keeps the check language-neutral while rejecting outputs such as
|
||||
# a long run of "!!!", which otherwise look non-empty to the UI.
|
||||
MIN_MEANINGFUL_CONTENT_CHARS = 20
|
||||
|
||||
|
||||
def _has_substantive_content(content: str) -> bool:
|
||||
visible = visible_seat_text(content)
|
||||
return sum(character.isalnum() for character in visible) >= MIN_MEANINGFUL_CONTENT_CHARS
|
||||
|
||||
# 禁两类工具:①**写文件**(write_file / str_replace)—— 并行席位互相写文件会打架;
|
||||
# ②**派活 / 脚本逃逸**(agent_orchestration / bash)—— 本面板里每个智能体只做自己那
|
||||
# 份分析交付,绝不该扮演「总控」去调度别人。弱模型(deepseek 等)拿到带「总控 / 业务
|
||||
@@ -231,6 +245,9 @@ async def _run_one_agent(
|
||||
if isinstance(msg, dict) and (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_ai = msg
|
||||
break
|
||||
failure_reason = is_llm_error_message(last_ai)
|
||||
if failure_reason:
|
||||
raise RuntimeError(f"模型调用失败:{reason_text(failure_reason)}")
|
||||
content = _flatten_content(last_ai.get("content", ""))
|
||||
# 收集**所有** AI 消息的 tool_calls(全量 args,按顺序)—— 用于在收口时补发所有步骤
|
||||
# (含 ls / skill_list 这类无参/中间步骤),保证步骤条不丢步、且 args 完整。
|
||||
@@ -250,6 +267,8 @@ async def _run_one_agent(
|
||||
"tool_calls": final_tool_calls,
|
||||
}
|
||||
else:
|
||||
if not _has_substantive_content(content):
|
||||
raise RuntimeError("智能体未生成有效正文,请重试或更换全局模型")
|
||||
yield {
|
||||
"status": f"子智能体 {agent_id} 完成",
|
||||
"content": content,
|
||||
@@ -408,8 +427,24 @@ async def run_parallel_agents_stream(request: Request, payload: dict = Body(...)
|
||||
await _emit_tool(tc)
|
||||
if not done_sent:
|
||||
# 流被抢占/截断,没收到终态帧 —— 用已累积正文兜底收口本席位。
|
||||
buffered_content = "".join(buffer)
|
||||
failure_reason = is_llm_error_message(
|
||||
{"type": "ai", "content": buffered_content}
|
||||
)
|
||||
if failure_reason:
|
||||
raise RuntimeError(
|
||||
f"模型调用失败:{reason_text(failure_reason)}"
|
||||
)
|
||||
if not _has_substantive_content(buffered_content):
|
||||
raise RuntimeError(
|
||||
"智能体未生成有效正文,请重试或更换全局模型"
|
||||
)
|
||||
await queue.put(
|
||||
{"type": "agent_done", "agent_id": agent_id, "content": "".join(buffer)}
|
||||
{
|
||||
"type": "agent_done",
|
||||
"agent_id": agent_id,
|
||||
"content": buffered_content,
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 单个 agent 失败不拖垮其它
|
||||
logger.warning("[parallel-agents] %s failed: %r", agent_id, exc)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"policy-knowledge": {
|
||||
"enabled": false
|
||||
"enabled": true
|
||||
},
|
||||
"html-page-builder": {
|
||||
"enabled": true
|
||||
|
||||
+9
-5
@@ -96,9 +96,11 @@ _BUSY_PATTERNS = (
|
||||
"稍后重试",
|
||||
"请稍后重试",
|
||||
)
|
||||
_QUOTA_PATTERNS = (
|
||||
"insufficient_quota",
|
||||
"quota",
|
||||
_QUOTA_PATTERNS = (
|
||||
"insufficient_quota",
|
||||
"insufficient balance",
|
||||
"token plan limit",
|
||||
"quota",
|
||||
"billing",
|
||||
"credit",
|
||||
"payment",
|
||||
@@ -122,8 +124,10 @@ _AUTH_PATTERNS = (
|
||||
# error wrapped as `bad_response_status_code`, or a truncated/garbled inference
|
||||
# response). These are almost always transient inference-side issues and safe
|
||||
# to retry.
|
||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||
"bad_response_status_code",
|
||||
_UPSTREAM_GARBAGE_PATTERNS = (
|
||||
"openai-compatible upstream application error",
|
||||
"null value for 'choices'",
|
||||
"bad_response_status_code",
|
||||
"bad response status",
|
||||
"expecting ',' delimiter",
|
||||
"expecting value",
|
||||
|
||||
+81
-16
@@ -25,7 +25,8 @@ message that originally carried it:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
@@ -34,6 +35,65 @@ from langchain_openai import ChatOpenAI
|
||||
|
||||
from deerflow.models.message_ordering import normalize_chat_payload_messages
|
||||
|
||||
|
||||
class OpenAICompatibleUpstreamError(RuntimeError):
|
||||
"""An OpenAI-compatible gateway reported an error inside a HTTP 200 body.
|
||||
|
||||
Several compatible gateways do not use an HTTP error status for quota,
|
||||
concurrency, or inference failures. Instead they return ``choices=null``
|
||||
(or an empty streamed chunk) together with a vendor ``base_resp`` object.
|
||||
LangChain raises a generic ``TypeError`` for the non-streaming shape and
|
||||
treats the streaming shape as an empty successful response. Raising one
|
||||
stable exception here lets DeerFlow's model middleware retry both paths.
|
||||
"""
|
||||
|
||||
def __init__(self, *, code: Any, message: str | None = None) -> None:
|
||||
self.code = code
|
||||
detail = _bounded_error_message(message)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
super().__init__(f"OpenAI-compatible upstream application error (code={code}){suffix}")
|
||||
|
||||
|
||||
def _bounded_error_message(value: Any, *, limit: int = 500) -> str:
|
||||
"""Return a log-safe, bounded one-line provider message."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
normalized = " ".join(value.split())
|
||||
return normalized[:limit]
|
||||
|
||||
|
||||
def _application_error(payload: Mapping[str, Any]) -> tuple[Any, str | None] | None:
|
||||
"""Extract a non-success ``base_resp`` error from a compatible payload."""
|
||||
raw = payload.get("base_resp") or payload.get("baseResp")
|
||||
if not isinstance(raw, Mapping):
|
||||
return None
|
||||
|
||||
code = raw.get("status_code")
|
||||
if code is None:
|
||||
code = raw.get("statusCode")
|
||||
if code is None:
|
||||
code = raw.get("code")
|
||||
|
||||
# MiniMax-compatible responses use 0 for success. Be conservative for
|
||||
# gateways that put informational metadata in ``base_resp``.
|
||||
normalized_code = str(code).strip().lower() if code is not None else ""
|
||||
if normalized_code in {"", "0", "200", "ok", "success"}:
|
||||
return None
|
||||
|
||||
message = raw.get("status_msg")
|
||||
if message is None:
|
||||
message = raw.get("statusMsg")
|
||||
if message is None:
|
||||
message = raw.get("message")
|
||||
return code, message if isinstance(message, str) else None
|
||||
|
||||
|
||||
def _raise_for_application_error(payload: Mapping[str, Any]) -> None:
|
||||
error = _application_error(payload)
|
||||
if error is not None:
|
||||
code, message = error
|
||||
raise OpenAICompatibleUpstreamError(code=code, message=message)
|
||||
|
||||
|
||||
class PatchedChatOpenAI(ChatOpenAI):
|
||||
"""ChatOpenAI with ``thought_signature`` preservation for Gemini thinking via OpenAI gateway.
|
||||
@@ -100,16 +160,20 @@ class PatchedChatOpenAI(ChatOpenAI):
|
||||
normalize_chat_payload_messages(payload)
|
||||
return payload
|
||||
|
||||
def _convert_chunk_to_generation_chunk(
|
||||
self,
|
||||
chunk: dict,
|
||||
default_chunk_class: type,
|
||||
base_generation_info: dict | None,
|
||||
) -> ChatGenerationChunk | None:
|
||||
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
|
||||
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
||||
chunk,
|
||||
default_chunk_class,
|
||||
def _convert_chunk_to_generation_chunk(
|
||||
self,
|
||||
chunk: dict,
|
||||
default_chunk_class: type,
|
||||
base_generation_info: dict | None,
|
||||
) -> ChatGenerationChunk | None:
|
||||
"""Preserve streamed ``reasoning_content`` in ``additional_kwargs``."""
|
||||
# Some compatible gateways return a vendor error event followed by a
|
||||
# normal-looking usage chunk, all under HTTP 200. Detect the first
|
||||
# event before LangChain converts it into an empty AIMessageChunk.
|
||||
_raise_for_application_error(chunk)
|
||||
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
||||
chunk,
|
||||
default_chunk_class,
|
||||
base_generation_info,
|
||||
)
|
||||
if generation_chunk is None:
|
||||
@@ -130,11 +194,12 @@ class PatchedChatOpenAI(ChatOpenAI):
|
||||
self,
|
||||
response: dict | Any,
|
||||
generation_info: dict | None = None,
|
||||
) -> ChatResult:
|
||||
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
|
||||
result = super()._create_chat_result(response, generation_info)
|
||||
response_dict = response if isinstance(response, dict) else response.model_dump()
|
||||
choices = response_dict.get("choices", [])
|
||||
) -> ChatResult:
|
||||
"""Preserve non-stream ``reasoning_content`` in ``additional_kwargs``."""
|
||||
response_dict = response if isinstance(response, dict) else response.model_dump()
|
||||
_raise_for_application_error(response_dict)
|
||||
result = super()._create_chat_result(response, generation_info)
|
||||
choices = response_dict.get("choices", [])
|
||||
|
||||
generations: list[ChatGeneration] = []
|
||||
for index, generation in enumerate(result.generations):
|
||||
|
||||
@@ -11,9 +11,11 @@ import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
from langchain_core.messages import AIMessage
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
|
||||
from deerflow.agents.middlewares.llm_error_handling_middleware import LLM_ERROR_MARKER, LLMErrorHandlingMiddleware
|
||||
from deerflow.models.patched_openai import OpenAICompatibleUpstreamError, PatchedChatOpenAI
|
||||
|
||||
|
||||
def _middleware() -> LLMErrorHandlingMiddleware:
|
||||
@@ -26,6 +28,10 @@ def _middleware() -> LLMErrorHandlingMiddleware:
|
||||
return middleware
|
||||
|
||||
|
||||
def _openai_model() -> PatchedChatOpenAI:
|
||||
return PatchedChatOpenAI(model="test-model", api_key="test-key")
|
||||
|
||||
|
||||
def test_httpx_read_timeout_is_transient() -> None:
|
||||
assert _middleware()._classify_error(httpx.ReadTimeout("read timed out")) == (True, "transient")
|
||||
|
||||
@@ -46,6 +52,98 @@ def test_value_error_stays_generic() -> None:
|
||||
assert _middleware()._classify_error(ValueError("boom")) == (False, "generic")
|
||||
|
||||
|
||||
def test_openai_compatible_embedded_error_is_retriable_upstream() -> None:
|
||||
error = RuntimeError("OpenAI-compatible upstream application error (code=2056)")
|
||||
assert _middleware()._classify_error(error) == (True, "upstream")
|
||||
|
||||
|
||||
def test_openai_compatible_null_choices_error_is_retriable_upstream() -> None:
|
||||
error = TypeError("Received response with null value for 'choices'.")
|
||||
assert _middleware()._classify_error(error) == (True, "upstream")
|
||||
|
||||
|
||||
def test_insufficient_balance_stays_non_retriable_quota() -> None:
|
||||
"""The 200-body compatibility fix must not hide a real billing failure."""
|
||||
|
||||
class PaymentError(RuntimeError):
|
||||
status_code = 402
|
||||
|
||||
error = PaymentError("Insufficient Balance")
|
||||
assert _middleware()._classify_error(error) == (False, "quota")
|
||||
|
||||
|
||||
def test_minimax_token_plan_limit_stays_non_retriable_quota() -> None:
|
||||
error = OpenAICompatibleUpstreamError(
|
||||
code=2056,
|
||||
message="Token Plan limit reached",
|
||||
)
|
||||
|
||||
assert _middleware()._classify_error(error) == (False, "quota")
|
||||
|
||||
|
||||
def test_non_stream_embedded_application_error_is_raised() -> None:
|
||||
response = {
|
||||
"id": "failed-response",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "test-model",
|
||||
"choices": None,
|
||||
"base_resp": {
|
||||
"status_code": 2056,
|
||||
"status_msg": "Token Plan limit reached",
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(OpenAICompatibleUpstreamError, match=r"code=2056") as exc_info:
|
||||
_openai_model()._create_chat_result(response)
|
||||
|
||||
assert exc_info.value.code == 2056
|
||||
assert "Token Plan limit reached" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_stream_embedded_application_error_is_raised() -> None:
|
||||
chunk = {
|
||||
"id": "failed-response",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": "test-model",
|
||||
"choices": [],
|
||||
"base_resp": {
|
||||
"status_code": 2056,
|
||||
"status_msg": "Token Plan limit reached",
|
||||
},
|
||||
}
|
||||
|
||||
with pytest.raises(OpenAICompatibleUpstreamError, match=r"code=2056"):
|
||||
_openai_model()._convert_chunk_to_generation_chunk(chunk, AIMessageChunk, None)
|
||||
|
||||
|
||||
def test_normal_empty_usage_chunk_is_not_treated_as_error() -> None:
|
||||
chunk = {
|
||||
"id": "usage-response",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": "test-model",
|
||||
"choices": [],
|
||||
"usage": {
|
||||
"prompt_tokens": 3,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
},
|
||||
}
|
||||
|
||||
result = _openai_model()._convert_chunk_to_generation_chunk(chunk, AIMessageChunk, None)
|
||||
|
||||
assert result is not None
|
||||
assert result.message.usage_metadata == {
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 2,
|
||||
"total_tokens": 5,
|
||||
"input_token_details": {},
|
||||
"output_token_details": {},
|
||||
}
|
||||
|
||||
|
||||
def test_read_timeout_is_retried_and_recovers() -> None:
|
||||
"""第一次 ReadTimeout、第二次成功:必须重试并返回真实结果。"""
|
||||
middleware = _middleware()
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import app.gateway.roundtable_model_fallback as mf
|
||||
import app.gateway.routers.parallel_agents as parallel_agents
|
||||
from app.gateway.roundtable_model_fallback import (
|
||||
fallback_model_chain,
|
||||
is_llm_error_message,
|
||||
@@ -56,6 +61,181 @@ def test_is_llm_error_message_normal_is_none() -> None:
|
||||
assert is_llm_error_message({"type": "ai", "content": ""}) is None
|
||||
|
||||
|
||||
def test_parallel_agent_run_raises_instead_of_completing_llm_fallback(monkeypatch) -> None:
|
||||
fallback_message = {
|
||||
"type": "ai",
|
||||
"content": "大模型本次调用未能成功完成,请稍后再试或更换模型。",
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [fallback_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-intent",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
asyncio.run(collect())
|
||||
except RuntimeError as exc:
|
||||
assert "模型调用失败" in str(exc)
|
||||
else:
|
||||
raise AssertionError("LLM fallback message must not be emitted as agent_done")
|
||||
|
||||
|
||||
def test_parallel_agent_run_rejects_punctuation_only_delivery(monkeypatch) -> None:
|
||||
invalid_message = {"type": "ai", "content": "!" * 500}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [f"data: {json.dumps({'messages': [invalid_message]}, ensure_ascii=False)}"]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-angle-security",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
try:
|
||||
asyncio.run(collect())
|
||||
except RuntimeError as exc:
|
||||
assert "未生成有效正文" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Punctuation-only output must not be emitted as agent_done")
|
||||
|
||||
|
||||
def test_parallel_agent_run_allows_empty_content_clarification(monkeypatch) -> None:
|
||||
clarification_message = {
|
||||
"type": "ai",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "clarify-1",
|
||||
"name": "ask_clarification",
|
||||
"args": {
|
||||
"question": "请确认策略时间范围。",
|
||||
"clarification_type": "single_choice",
|
||||
"options": ["短期", "长期"],
|
||||
"allow_custom": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def fake_stream_upstream(*_args, **_kwargs):
|
||||
yield None, [
|
||||
f"data: {json.dumps({'messages': [clarification_message]}, ensure_ascii=False)}"
|
||||
]
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_stream_upstream", fake_stream_upstream)
|
||||
|
||||
async def collect() -> list[object]:
|
||||
return [
|
||||
item
|
||||
async for item in parallel_agents._run_one_agent(
|
||||
None, # type: ignore[arg-type]
|
||||
"http://gateway.test",
|
||||
{"Authorization": "Bearer test"},
|
||||
"strategy-selection-intent",
|
||||
"thread-id",
|
||||
"test task",
|
||||
"",
|
||||
)
|
||||
]
|
||||
|
||||
events = asyncio.run(collect())
|
||||
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert isinstance(event, dict)
|
||||
assert event["type"] == "clarification"
|
||||
assert event["content"] == ""
|
||||
assert event["clarification"]["question"] == "请确认策略时间范围。"
|
||||
|
||||
|
||||
def test_parallel_agent_substantive_content_accepts_chinese_and_english() -> None:
|
||||
assert parallel_agents._has_substantive_content("这是一份包含目标、前置条件、阶段路径和验收标准的完整策略。")
|
||||
assert parallel_agents._has_substantive_content(
|
||||
"A complete strategy with objectives, prerequisites, milestones, and measurable outcomes."
|
||||
)
|
||||
assert not parallel_agents._has_substantive_content("<think>internal reasoning only</think>!!!")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("buffered_content", "expected_error"),
|
||||
[
|
||||
("大模型本次调用未能成功完成,请稍后再试或更换模型。", "模型调用失败"),
|
||||
("", "未生成有效正文"),
|
||||
("<think>internal reasoning only</think>", "未生成有效正文"),
|
||||
("!" * 500, "未生成有效正文"),
|
||||
],
|
||||
)
|
||||
def test_parallel_agent_truncated_stream_rejects_invalid_buffer(
|
||||
monkeypatch,
|
||||
buffered_content: str,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
async def fake_run_one_agent(*_args, **_kwargs):
|
||||
yield "event: messages"
|
||||
yield f"data: {json.dumps([{'type': 'ai', 'content': buffered_content}], ensure_ascii=False)}"
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_client(*_args, **_kwargs):
|
||||
yield object()
|
||||
|
||||
async def fake_create_thread(*_args, **_kwargs):
|
||||
return "thread-id"
|
||||
|
||||
monkeypatch.setattr(parallel_agents, "_run_one_agent", fake_run_one_agent)
|
||||
monkeypatch.setattr(parallel_agents, "_make_loopback_client", fake_client)
|
||||
monkeypatch.setattr(parallel_agents, "_create_thread", fake_create_thread)
|
||||
monkeypatch.setattr(parallel_agents, "_loopback_base", lambda _request: "http://gateway.test")
|
||||
monkeypatch.setattr(parallel_agents, "_auth_headers", lambda _request: {})
|
||||
|
||||
async def collect() -> list[dict[str, object]]:
|
||||
response = await parallel_agents.run_parallel_agents_stream(
|
||||
SimpleNamespace(),
|
||||
{
|
||||
"task_name": "test task",
|
||||
"brief": "test brief",
|
||||
"agent_ids": ["strategy-selection-intent"],
|
||||
},
|
||||
)
|
||||
events: list[dict[str, object]] = []
|
||||
async for chunk in response.body_iterator:
|
||||
text = chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
for line in text.splitlines():
|
||||
if line.startswith("data:"):
|
||||
events.append(json.loads(line[5:].strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect())
|
||||
assert not any(event.get("type") == "agent_done" for event in events)
|
||||
errors = [event for event in events if event.get("type") == "agent_error"]
|
||||
assert len(errors) == 1
|
||||
assert expected_error in str(errors[0].get("message"))
|
||||
|
||||
|
||||
# ── 中间件给兜底 AIMessage 打标记 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers import _strategy_selection_seed as seed
|
||||
from app.gateway.routers import agents as agents_router
|
||||
from deerflow.config.paths import Paths
|
||||
from deerflow.persistence.skills.memory import MemorySkillStore
|
||||
from deerflow.skills.types import Skill, SkillCategory
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -73,6 +77,18 @@ def test_seed_assets_use_role_appropriate_existing_skills_and_tools() -> None:
|
||||
assert solution["tool_groups"] == ["file:write"]
|
||||
|
||||
|
||||
def test_default_strategy_skills_are_enabled_in_the_system_catalog() -> None:
|
||||
extensions_path = Path(__file__).resolve().parents[1] / "extensions_config.json"
|
||||
extensions = yaml.safe_load(extensions_path.read_text(encoding="utf-8")) or {}
|
||||
configured = extensions.get("skills") or {}
|
||||
|
||||
# A missing entry means enabled by default. An explicit false would make
|
||||
# the seeded allowlist impossible to save under the server-side validator
|
||||
# and silently remove the skill from strategy runs.
|
||||
for skill_name in ("intent-clarification-policy", "policy-knowledge"):
|
||||
assert (configured.get(skill_name) or {}).get("enabled", True) is True
|
||||
|
||||
|
||||
def test_seed_assets_define_machine_readable_handoff_contracts() -> None:
|
||||
intent_soul = _asset(seed.STRATEGY_INTENT_AGENT_ID, "SOUL.md").read_text(encoding="utf-8")
|
||||
judge_soul = _asset(seed.STRATEGY_JUDGE_AGENT_ID, "SOUL.md").read_text(encoding="utf-8")
|
||||
@@ -113,3 +129,120 @@ def test_seed_creates_once_and_never_overwrites_admin_changes(tmp_paths: Paths)
|
||||
|
||||
assert seed.ensure_strategy_selection_agents() == []
|
||||
assert customized_soul.read_text(encoding="utf-8") == "管理员现场修改"
|
||||
|
||||
|
||||
class _SkillStorage:
|
||||
def __init__(self, skills: list[Skill]) -> None:
|
||||
self._skills = skills
|
||||
|
||||
def load_skills(self, *, enabled_only: bool) -> list[Skill]:
|
||||
if enabled_only:
|
||||
return [skill for skill in self._skills if skill.enabled]
|
||||
return list(self._skills)
|
||||
|
||||
|
||||
def _skill(name: str, *, category: SkillCategory, enabled: bool = True) -> Skill:
|
||||
directory = Path("skills") / category.value / name
|
||||
return Skill(
|
||||
name=name,
|
||||
description=name,
|
||||
license=None,
|
||||
skill_dir=directory,
|
||||
skill_file=directory / "SKILL.md",
|
||||
relative_path=Path(name),
|
||||
category=category,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strategy_skill_validation_accepts_only_global_catalog_names(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
skill_store = MemorySkillStore()
|
||||
await skill_store.create(
|
||||
{
|
||||
"name": "published-custom",
|
||||
"owner_user_id": "publisher",
|
||||
"published": True,
|
||||
}
|
||||
)
|
||||
storage = _SkillStorage(
|
||||
[
|
||||
_skill("public-skill", category=SkillCategory.PUBLIC),
|
||||
_skill("published-custom", category=SkillCategory.CUSTOM),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
|
||||
monkeypatch.setattr(
|
||||
agents_router,
|
||||
"get_skill_usage_entries",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
result = await agents_router._validate_strategy_agent_skills(
|
||||
[" public-skill ", "published-custom", "public-skill"],
|
||||
app_config=object(), # type: ignore[arg-type]
|
||||
skill_store=skill_store,
|
||||
)
|
||||
|
||||
assert result == ["public-skill", "published-custom"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("requested", "detail_fragment"),
|
||||
[
|
||||
("missing", "Unknown strategy agent skills"),
|
||||
("disabled", "Disabled or archived strategy agent skills"),
|
||||
("stale", "Disabled or archived strategy agent skills"),
|
||||
("private-custom", "must be globally available"),
|
||||
("orphan-custom", "must be globally available"),
|
||||
],
|
||||
)
|
||||
async def test_strategy_skill_validation_rejects_unselectable_names(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
requested: str,
|
||||
detail_fragment: str,
|
||||
) -> None:
|
||||
skill_store = MemorySkillStore()
|
||||
await skill_store.create(
|
||||
{
|
||||
"name": "private-custom",
|
||||
"owner_user_id": "owner",
|
||||
"published": False,
|
||||
}
|
||||
)
|
||||
storage = _SkillStorage(
|
||||
[
|
||||
_skill("disabled", category=SkillCategory.PUBLIC, enabled=False),
|
||||
_skill("stale", category=SkillCategory.PUBLIC),
|
||||
_skill("private-custom", category=SkillCategory.CUSTOM),
|
||||
_skill("orphan-custom", category=SkillCategory.CUSTOM),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(agents_router, "get_or_new_skill_storage", lambda **_: storage)
|
||||
monkeypatch.setattr(
|
||||
agents_router,
|
||||
"get_skill_usage_entries",
|
||||
lambda: {"stale": {"state": "stale"}},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await agents_router._validate_strategy_agent_skills(
|
||||
[requested],
|
||||
app_config=object(), # type: ignore[arg-type]
|
||||
skill_store=skill_store,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert detail_fragment in str(exc_info.value.detail)
|
||||
|
||||
|
||||
def test_fixed_strategy_role_agents_cannot_be_deleted() -> None:
|
||||
for agent_id in seed.required_agent_ids():
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
agents_router._ensure_strategy_agent_deletable(agent_id)
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
agents_router._ensure_strategy_agent_deletable("ordinary-custom-agent")
|
||||
Reference in new issue
Block a user