This commit is contained in:
Wenjie Zhang 2026-03-08 22:36:36 +08:00
commit 4ea9f8b15e
16 changed files with 582 additions and 613 deletions

View File

@ -1,5 +1,6 @@
import traceback import traceback
import uuid import uuid
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@ -502,19 +503,57 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren
async def resume_agent_chat( async def resume_agent_chat(
agent_id: str, agent_id: str,
thread_id: str = Body(...), thread_id: str = Body(...),
approved: bool = Body(...), approved: bool | None = Body(None),
answer: dict | list | str | None = Body(None),
config: dict = Body({}), config: dict = Body({}),
current_user: User = Depends(get_required_user), current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""恢复被人工审批中断的对话(需要登录)""" """恢复被人工审批中断的对话(需要登录)"""
logger.info(f"Resuming agent_id: {agent_id}, thread_id: {thread_id}, approved: {approved}") def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any:
if raw_answer is not None:
if isinstance(raw_answer, str):
normalized = raw_answer.strip()
if not normalized:
raise HTTPException(status_code=422, detail="answer 不能为空")
return normalized
if isinstance(raw_answer, list):
if len(raw_answer) == 0:
raise HTTPException(status_code=422, detail="answer 不能为空")
return raw_answer
if isinstance(raw_answer, dict):
if raw_answer.get("type") == "other":
text = raw_answer.get("text")
if not isinstance(text, str) or not text.strip():
raise HTTPException(status_code=422, detail="other 文本不能为空")
return raw_answer
raise HTTPException(status_code=422, detail="answer 类型不支持")
if raw_approved is not None:
return "approve" if raw_approved else "reject"
raise HTTPException(status_code=422, detail="approved 或 answer 至少提供一个")
resume_input = normalize_resume_input(answer, approved)
logger.info(
"Resuming agent_id: %s, thread_id: %s, approved: %s, answer_type: %s",
agent_id,
thread_id,
approved,
type(answer).__name__ if answer is not None else "None",
)
meta = { meta = {
"agent_id": agent_id, "agent_id": agent_id,
"thread_id": thread_id, "thread_id": thread_id,
"user_id": current_user.id, "user_id": current_user.id,
"approved": approved, "approved": approved,
"answer": answer,
"resume_input": resume_input,
} }
if "request_id" not in meta or not meta.get("request_id"): if "request_id" not in meta or not meta.get("request_id"):
meta["request_id"] = str(uuid.uuid4()) meta["request_id"] = str(uuid.uuid4())
@ -522,7 +561,7 @@ async def resume_agent_chat(
stream_agent_resume( stream_agent_resume(
agent_id=agent_id, agent_id=agent_id,
thread_id=thread_id, thread_id=thread_id,
approved=approved, resume_input=resume_input,
meta=meta, meta=meta,
config=config, config=config,
current_user=current_user, current_user=current_user,

View File

@ -1,7 +1,8 @@
# buildin 工具包 # buildin 工具包
from .tools import calculator, query_knowledge_graph, text_to_img_qwen_image from .tools import ask_user_question, calculator, query_knowledge_graph, text_to_img_qwen_image
__all__ = [ __all__ = [
"ask_user_question",
"calculator", "calculator",
"query_knowledge_graph", "query_knowledge_graph",
"text_to_img_qwen_image", "text_to_img_qwen_image",

View File

@ -4,6 +4,7 @@ import uuid
from typing import Annotated, Any from typing import Annotated, Any
import requests import requests
from langgraph.types import interrupt
from src import config, graph_base from src import config, graph_base
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool
@ -68,6 +69,73 @@ def calculator(a: float, b: float, operation: str) -> float:
raise raise
ASK_USER_QUESTION_DESCRIPTION = """
在执行过程中当你需要用户做决定或补充需求时使用这个工具向用户提问
适用场景
1. 收集用户偏好或需求例如风格范围优先级
2. 澄清模糊指令存在多种合理解释时
3. 在实现过程中让用户选择方案方向
4. 在有明显权衡时让用户做取舍
使用规范
1. 问题应当简短具体可回答避免开放式长问句
2. options 提供 2-5 个有区分度的选项每项包含 label value
3. 若有推荐选项把推荐项放在第一位并在 label 末尾加 "(Recommended)"
4. 若需要多选 multi_select 设为 true
5. allow_other 通常保持 true用户可通过 Other 输入自定义答案
注意事项
1. 不要用这个工具询问是否继续执行计划是否准备好这类流程控制问题
2. 不要在信息已充分无需用户决策时滥用该工具
3. 先基于现有上下文自行决策只有关键不确定性时才提问
返回结果
answer 可能是 string单选list多选 objectOther 文本
"""
@tool(
category="buildin",
tags=["交互"],
display_name="向用户提问",
description=ASK_USER_QUESTION_DESCRIPTION,
)
def ask_user_question(
question: Annotated[str, "向用户展示的问题"],
options: Annotated[list[dict], "候选项列表,格式 [{label, value}],推荐项请在 label 后追加 (Recommended)"],
multi_select: Annotated[bool, "是否允许多选"] = False,
allow_other: Annotated[bool, "是否允许用户输入 Other 自定义答案"] = True,
) -> dict:
"""向用户发起问题并等待回答。"""
normalized_options: list[dict[str, str]] = []
for item in options or []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or item.get("value") or "").strip()
value = str(item.get("value") or item.get("label") or "").strip()
if label and value:
normalized_options.append({"label": label, "value": value})
interrupt_payload = {
"question": question,
"question_id": str(uuid.uuid4()),
"options": normalized_options,
"multi_select": multi_select,
"allow_other": allow_other,
"source": "ask_user_question",
}
answer = interrupt(interrupt_payload)
return {
"question": question,
"question_id": interrupt_payload["question_id"],
"answer": answer,
"multi_select": multi_select,
"allow_other": allow_other,
}
KG_QUERY_DESCRIPTION = """ KG_QUERY_DESCRIPTION = """
使用这个工具可以查询知识图谱中包含的三元组信息 使用这个工具可以查询知识图谱中包含的三元组信息
关键词query使用可能帮助回答这个问题的关键词进行查询不要直接使用用户的原始输入去查询 关键词query使用可能帮助回答这个问题的关键词进行查询不要直接使用用户的原始输入去查询

View File

@ -22,7 +22,25 @@ def get_approved_user_goal(
} }
# 触发人工审批 # 触发人工审批
is_approved = interrupt(interrupt_info) interrupt_result = interrupt(interrupt_info)
if isinstance(interrupt_result, bool):
is_approved = interrupt_result
elif isinstance(interrupt_result, str):
is_approved = interrupt_result.strip().lower() in {"approve", "approved", "true", "yes", "1"}
elif isinstance(interrupt_result, list):
lowered = {str(item).strip().lower() for item in interrupt_result}
is_approved = "approve" in lowered or "approved" in lowered
elif isinstance(interrupt_result, dict):
selected = interrupt_result.get("selected")
if isinstance(selected, list):
lowered = {str(item).strip().lower() for item in selected}
is_approved = "approve" in lowered or "approved" in lowered
else:
text = str(interrupt_result.get("text") or "").strip().lower()
is_approved = text in {"approve", "approved", "true", "yes", "1"}
else:
is_approved = bool(interrupt_result)
# 返回审批结果 # 返回审批结果
if is_approved: if is_approved:

View File

@ -4,6 +4,7 @@ import traceback
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
from langgraph.types import Command from langgraph.types import Command
@ -193,7 +194,7 @@ async def save_messages_from_langgraph_state(
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def _extract_interrupt_info(state) -> dict | None: def _extract_interrupt_info(state) -> Any | None:
"""从 LangGraph state 中提取中断信息""" """从 LangGraph state 中提取中断信息"""
if hasattr(state, "tasks") and state.tasks: if hasattr(state, "tasks") and state.tasks:
for task in state.tasks: for task in state.tasks:
@ -207,12 +208,73 @@ def _extract_interrupt_info(state) -> dict | None:
return None return None
def _get_interrupt_fields(info) -> tuple[str, str]: def _coerce_interrupt_payload(info: Any) -> dict:
"""从中断信息中提取 question 和 operation""" """将 LangGraph interrupt 对象转换为 dict 结构。"""
defaults = ("是否批准以下操作?", "需要人工审批的操作")
if isinstance(info, dict): if isinstance(info, dict):
return info.get("question", defaults[0]), info.get("operation", defaults[1]) return info
return getattr(info, "question", defaults[0]), getattr(info, "operation", defaults[1])
payload = getattr(info, "value", None)
if isinstance(payload, dict):
return payload
question = getattr(info, "question", None)
operation = getattr(info, "operation", None)
result: dict[str, Any] = {}
if isinstance(question, str) and question.strip():
result["question"] = question
if isinstance(operation, str) and operation.strip():
result["operation"] = operation
return result
def _normalize_interrupt_options(raw_options: Any) -> list[dict[str, str]]:
if not isinstance(raw_options, list):
return []
options: list[dict[str, str]] = []
for item in raw_options:
if isinstance(item, dict):
label = str(item.get("label") or item.get("value") or "").strip()
value = str(item.get("value") or item.get("label") or "").strip()
else:
label = str(item).strip()
value = label
if label and value:
options.append({"label": label, "value": value})
return options
def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]:
"""将 interrupt 信息标准化为 ask_user_question_required 载荷。"""
payload = _coerce_interrupt_payload(info)
question = str(payload.get("question") or "请选择一个选项").strip()
question_id = str(payload.get("question_id") or uuid.uuid4())
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
multi_select = bool(payload.get("multi_select", False))
allow_other = bool(payload.get("allow_other", True))
operation = payload.get("operation")
options = _normalize_interrupt_options(payload.get("options"))
if not options and isinstance(operation, str) and operation.strip():
# 兼容旧版 get_approved_user_goal 的 interrupt 结构
options = [
{"label": "批准 (Recommended)", "value": "approve"},
{"label": "拒绝", "value": "reject"},
]
source = "get_approved_user_goal"
allow_other = False
return {
"question_id": question_id,
"question": question,
"options": options,
"multi_select": multi_select,
"allow_other": allow_other,
"source": source,
"operation": operation if isinstance(operation, str) else "",
"thread_id": thread_id,
}
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None: def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
@ -262,13 +324,9 @@ async def check_and_handle_interrupts(
interrupt_info = _extract_interrupt_info(state) interrupt_info = _extract_interrupt_info(state)
if interrupt_info: if interrupt_info:
question, operation = _get_interrupt_fields(interrupt_info) question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
meta["interrupt"] = { meta["interrupt"] = question_payload
"question": question, yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
"operation": operation,
"thread_id": thread_id,
}
yield make_chunk(status="interrupted", message=question, meta=meta)
except Exception as e: except Exception as e:
logger.error(f"Error checking interrupts: {e}") logger.error(f"Error checking interrupts: {e}")
@ -357,6 +415,8 @@ async def stream_agent_chat(
"agent_config_id": agent_config_id, "agent_config_id": agent_config_id,
"agent_config": agent_config, "agent_config": agent_config,
} }
full_msg = None
accumulated_content: list[str] = []
try: try:
conv_repo = ConversationRepository(db) conv_repo = ConversationRepository(db)
@ -489,7 +549,7 @@ async def stream_agent_resume(
*, *,
agent_id: str, agent_id: str,
thread_id: str, thread_id: str,
approved: bool, resume_input: Any,
meta: dict, meta: dict,
config: dict, config: dict,
current_user, current_user,
@ -515,10 +575,10 @@ async def stream_agent_resume(
) )
return return
init_msg = {"type": "system", "content": f"Resume with approved: {approved}"} init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"}
yield make_resume_chunk(status="init", meta=meta, msg=init_msg) yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
resume_command = Command(resume=approved) resume_command = Command(resume=resume_input)
graph = await agent.get_graph() graph = await agent.get_graph()
user_id = str(current_user.id) user_id = str(current_user.id)

View File

@ -275,6 +275,14 @@ async def process_agent_run(ctx, run_id: str):
error_message=chunk.get("message"), error_message=chunk.get("message"),
) )
terminal_set = True terminal_set = True
elif status == "ask_user_question_required":
await mark_run_terminal(
run_id,
"interrupted",
error_type="ask_user_question_required",
error_message=chunk.get("question") or "需要用户回答问题",
)
terminal_set = True
if await run_ctx.is_cancelled(): if await run_ctx.is_cancelled():
raise asyncio.CancelledError(f"run {run_id} cancelled") raise asyncio.CancelledError(f"run {run_id} cancelled")

View File

@ -171,6 +171,7 @@ class PostgresManager(metaclass=SingletonMeta):
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb", "ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB", "ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
""" """
CREATE TABLE IF NOT EXISTS agent_runs ( CREATE TABLE IF NOT EXISTS agent_runs (
@ -192,6 +193,7 @@ class PostgresManager(metaclass=SingletonMeta):
"CREATE INDEX IF NOT EXISTS idx_agent_runs_user_created ON agent_runs(user_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_user_created ON agent_runs(user_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)",
"CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)", "CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)",
"CREATE INDEX IF NOT EXISTS ix_conversations_is_pinned ON conversations(is_pinned)",
] ]
async with self.async_engine.begin() as conn: async with self.async_engine.begin() as conn:
for stmt in stmts: for stmt in stmts:

View File

@ -6,7 +6,7 @@ import sys
# Add project root to path # Add project root to path
sys.path.append(os.getcwd()) sys.path.append(os.getcwd())
from src.knowledge.services.upload_graph_service import UploadGraphService from src.knowledge.graphs.upload_graph_service import UploadGraphService
# For backward compatibility with the existing test # For backward compatibility with the existing test
GraphDatabase = UploadGraphService GraphDatabase = UploadGraphService
@ -40,64 +40,68 @@ async def test_txt_add_vector_entity_parsing():
mock_embed_model = MagicMock() mock_embed_model = MagicMock()
mock_select_model.return_value = mock_embed_model mock_select_model.return_value = mock_embed_model
# Instantiate GraphDatabase with mocked driver # Create a mock connection object with driver and status as attributes
# We also need to patch Neo4jConnectionManager in the init # (not a ConnectionManager class, just a simple mock object)
with patch("src.knowledge.services.upload_graph_service.Neo4jConnectionManager") as mock_connection_manager: mock_connection = MagicMock()
# Mock the connection manager to return our mocked driver mock_connection.driver = mock_driver
mock_connection_manager.return_value.driver = mock_driver mock_connection.status = "open"
mock_connection_manager.return_value.status = "open"
gd = GraphDatabase(mock_connection_manager.return_value) # Instantiate GraphDatabase with mocked connection
# Manually set properties just in case init didn't work as expected due to other mocks gd = GraphDatabase(mock_connection)
gd.driver = mock_driver # Set embed_model_name directly (this is a settable attribute)
gd.status = "open" gd.embed_model_name = "test_model"
gd.embed_model_name = "test_model" # avoid config check issues if possible
# Mock config to match # Mock config where it's imported in upload_graph_service.py
with patch("src.config.embed_model", "test_model"): # The import is `from src import config`, so patch at the usage location
with patch("src.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}): mock_config = MagicMock()
# Test data: Mixed format mock_config.embed_model = "test_model"
triples = [ mock_embed_info = MagicMock()
# Legacy format mock_embed_info.dimension = 1024
{"h": "A", "r": "KNOWS", "t": "B"}, mock_config.embed_model_names = {"test_model": mock_embed_info}
# Extended format
{
"h": {"name": "C", "age": 30},
"r": {"type": "LIKES", "weight": 0.8},
"t": {"name": "D", "role": "User"},
},
]
# Run the method with patch("src.knowledge.graphs.upload_graph_service.config", mock_config):
await gd.txt_add_vector_entity(triples) # Test data: Mixed format
triples = [
# Legacy format
{"h": "A", "r": "KNOWS", "t": "B"},
# Extended format
{
"h": {"name": "C", "age": 30},
"r": {"type": "LIKES", "weight": 0.8},
"t": {"name": "D", "role": "User"},
},
]
# Verify calls to mock_tx.run # Run the method
merge_calls = [] await gd.txt_add_vector_entity(triples)
for call in mock_tx.run.call_args_list:
args, kwargs = call
query = args[0] if args else kwargs.get("query", "")
if "MERGE (h:Entity:Upload" in query:
# The args are passed as kwargs to run: h_name=..., etc.
merge_calls.append(kwargs)
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}" # Verify calls to mock_tx.run
merge_calls = []
for call in mock_tx.run.call_args_list:
args, kwargs = call
query = args[0] if args else kwargs.get("query", "")
if "MERGE (h:Entity:Upload" in query:
# The args are passed as kwargs to run: h_name=..., etc.
merge_calls.append(kwargs)
# Call 1 (Legacy) assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
call1 = merge_calls[0]
assert call1["h_name"] == "A"
assert call1["h_props"] == {}
assert call1["t_name"] == "B"
assert call1["t_props"] == {}
assert call1["r_type"] == "KNOWS"
assert call1["r_props"] == {}
# Call 2 (Extended) # Call 1 (Legacy)
call2 = merge_calls[1] call1 = merge_calls[0]
assert call2["h_name"] == "C" assert call1["h_name"] == "A"
assert call2["h_props"] == {"age": 30} assert call1["h_props"] == {}
assert call2["t_name"] == "D" assert call1["t_name"] == "B"
assert call2["t_props"] == {"role": "User"} assert call1["t_props"] == {}
assert call2["r_type"] == "LIKES" assert call1["r_type"] == "KNOWS"
assert call2["r_props"] == {"weight": 0.8} assert call1["r_props"] == {}
print("Verification passed!") # Call 2 (Extended)
call2 = merge_calls[1]
assert call2["h_name"] == "C"
assert call2["h_props"] == {"age": 30}
assert call2["t_name"] == "D"
assert call2["t_props"] == {"role": "User"}
assert call2["r_type"] == "LIKES"
assert call2["r_props"] == {"weight": 0.8}
print("Verification passed!")

View File

@ -1,286 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from typing import Any
import pytest
from langchain_core.messages import SystemMessage, ToolMessage
from langgraph.types import Command
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
@dataclass
class _FakeTool:
name: str
@dataclass
class _FakeRequest:
runtime: Any
tools: list[Any]
system_message: SystemMessage
state: dict[str, Any]
def override(self, **kwargs):
return _FakeRequest(
runtime=kwargs.get("runtime", self.runtime),
tools=kwargs.get("tools", self.tools),
system_message=kwargs.get("system_message", self.system_message),
state=kwargs.get("state", self.state),
)
@dataclass
class _FakeToolCallRequest:
tool_call: dict[str, Any]
runtime: Any
state: dict[str, Any]
async def _echo_handler(request):
return request
def _build_request(
*,
skills: list[str],
tools: list[str],
system_prompt: str = "你是助手",
state: dict[str, Any] | None = None,
skill_snapshot: dict[str, Any] | None = None,
) -> _FakeRequest:
context = SimpleNamespace(
system_prompt=system_prompt,
skills=skills,
tools=[],
knowledges=[],
mcps=[],
)
if skill_snapshot is not None:
context.skill_session_snapshot = skill_snapshot
runtime = SimpleNamespace(context=context)
return _FakeRequest(
runtime=runtime,
tools=[_FakeTool(name=name) for name in tools],
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
state=state or {},
)
def _build_tool_request(*, skills: list[str], visible_skills: list[str], file_path: str) -> _FakeToolCallRequest:
return _FakeToolCallRequest(
tool_call={"name": "read_file", "args": {"file_path": file_path}},
runtime=SimpleNamespace(
context=SimpleNamespace(
skills=skills,
skill_session_snapshot=_build_snapshot(selected=skills, visible=visible_skills),
)
),
state={},
)
def _extract_appended_prompt(request: _FakeRequest) -> str:
return request.system_message.content_blocks[-1]["text"]
def _build_middleware() -> RuntimeConfigMiddleware:
return RuntimeConfigMiddleware(
enable_model_override=False,
enable_tools_override=False,
enable_system_prompt_override=True,
enable_skills_prompt_override=True,
)
def _build_snapshot(
selected: list[str],
visible: list[str] | None = None,
metadata: dict[str, dict[str, str]] | None = None,
dependency_map: dict[str, dict[str, list[str]]] | None = None,
) -> dict[str, Any]:
return {
"selected_skills": selected,
"visible_skills": visible if visible is not None else selected,
"prompt_metadata": metadata or {},
"dependency_map": dependency_map or {},
}
@pytest.mark.asyncio
async def test_abefore_agent_resolves_visible_skills_and_preinjects_prompt(monkeypatch: pytest.MonkeyPatch):
async def fake_resolve(selected):
assert selected == ["deliver-prd"]
return _build_snapshot(
selected=["deliver-prd"],
visible=["deliver-prd", "brainstorming"],
metadata={
"deliver-prd": {
"name": "deliver-prd",
"description": "deliver prd",
"path": "/skills/deliver-prd/SKILL.md",
},
"brainstorming": {
"name": "brainstorming",
"description": "brainstorming desc",
"path": "/skills/brainstorming/SKILL.md",
},
},
dependency_map={
"deliver-prd": {"tools": [], "mcps": [], "skills": ["brainstorming"]},
"brainstorming": {"tools": [], "mcps": [], "skills": []},
},
)
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware()
request = _build_request(skills=["deliver-prd"], tools=["read_file"], system_prompt="你是助手")
await middleware.abefore_agent(request.state, request.runtime)
snapshot = request.runtime.context.skill_session_snapshot
assert snapshot["selected_skills"] == ["deliver-prd"]
assert snapshot["visible_skills"] == ["deliver-prd", "brainstorming"]
assert snapshot["dependency_map"]["deliver-prd"]["skills"] == ["brainstorming"]
assert request.runtime.context._skills_prompt_injected is True
assert "## Skills System" in request.runtime.context.system_prompt
assert "- **deliver-prd**: deliver prd" in request.runtime.context.system_prompt
assert "- **brainstorming**: brainstorming desc" in request.runtime.context.system_prompt
@pytest.mark.asyncio
async def test_abefore_agent_injection_is_idempotent(monkeypatch: pytest.MonkeyPatch):
async def fake_resolve(_selected):
return _build_snapshot(
selected=["alpha"],
metadata={"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"}},
)
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware()
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
await middleware.abefore_agent(request.state, request.runtime)
await middleware.abefore_agent(request.state, request.runtime)
assert request.runtime.context.system_prompt.count("## Skills System") == 1
@pytest.mark.asyncio
async def test_awrap_model_call_keeps_preinjected_skills_prompt(monkeypatch: pytest.MonkeyPatch):
middleware = _build_middleware()
request = _build_request(
skills=["alpha"],
tools=["read_file"],
system_prompt="base prompt\n\n## Skills System\n- **alpha**: alpha desc",
)
def raise_if_called(_skills_meta):
raise AssertionError("_build_skills_section should not be called in awrap_model_call")
monkeypatch.setattr(middleware, "_build_skills_section", raise_if_called)
result = await middleware.awrap_model_call(request, _echo_handler)
prompt = _extract_appended_prompt(result)
assert "当前时间:" in prompt
assert "## Skills System" in prompt
assert "- **alpha**: alpha desc" in prompt
@pytest.mark.asyncio
async def test_abefore_agent_degrades_when_resolver_fails(monkeypatch: pytest.MonkeyPatch):
async def fake_resolve(_selected):
raise RuntimeError("boom")
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
middleware = _build_middleware()
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
await middleware.abefore_agent(request.state, request.runtime)
snapshot = request.runtime.context.skill_session_snapshot
assert snapshot["selected_skills"] == ["alpha"]
assert snapshot["visible_skills"] == []
assert "## Skills System" not in request.runtime.context.system_prompt
@pytest.mark.asyncio
async def test_awrap_tool_call_activates_skill_when_visible_in_context():
middleware = _build_middleware()
request = _build_tool_request(
skills=["deliver-prd"],
visible_skills=["deliver-prd", "brainstorming"],
file_path="/skills/brainstorming/SKILL.md",
)
async def _handler(_request):
return ToolMessage(content="ok", tool_call_id="tc-1")
result = await middleware.awrap_tool_call(request, _handler)
assert isinstance(result, Command)
assert result.update["activated_skills"] == ["brainstorming"]
@pytest.mark.asyncio
async def test_awrap_tool_call_denies_invisible_skill():
middleware = _build_middleware()
request = _build_tool_request(
skills=["deliver-prd"],
visible_skills=["deliver-prd"],
file_path="/skills/brainstorming/SKILL.md",
)
async def _handler(_request):
return ToolMessage(content="ok", tool_call_id="tc-1")
result = await middleware.awrap_tool_call(request, _handler)
assert isinstance(result, ToolMessage)
@pytest.mark.asyncio
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
snapshot = _build_snapshot(
selected=["alpha"],
visible=["alpha", "beta"],
dependency_map={"alpha": {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["beta"]}},
)
def fake_build_dependency_bundle(received_snapshot, activated):
assert received_snapshot == snapshot
assert activated == ["alpha"]
return {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["alpha", "beta"]}
monkeypatch.setattr(runtime_middleware, "build_dependency_bundle", fake_build_dependency_bundle)
async def fake_get_enabled_mcp_tools(server_name: str):
if server_name == "mcp-a":
return [_FakeTool(name="mcp_tool")]
return []
monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools)
middleware = RuntimeConfigMiddleware(
extra_tools=[_FakeTool(name="mcp_tool")],
enable_model_override=False,
enable_tools_override=True,
enable_system_prompt_override=False,
enable_skills_prompt_override=False,
)
request = _build_request(
skills=["alpha"],
tools=["calculator", "dep-tool", "mcp_tool", "read_file"],
state={"activated_skills": ["alpha"]},
skill_snapshot=snapshot,
)
result = await middleware.awrap_model_call(request, _echo_handler)
tool_names = [t.name for t in result.tools]
assert "dep-tool" in tool_names
assert "mcp_tool" in tool_names
assert "calculator" not in tool_names

View File

@ -1,85 +0,0 @@
from __future__ import annotations
import pytest
from src.services import skill_resolver as resolver
from src.storage.postgres.models_business import Skill
def test_expand_skill_closure_and_dependency_bundle():
dependency_map = {
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
}
snapshot: resolver.SkillSessionSnapshot = {
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta", "gamma"],
"prompt_metadata": {},
"dependency_map": dependency_map,
}
closure = resolver.expand_skill_closure(["alpha"], dependency_map)
assert closure == ["alpha", "beta", "gamma"]
bundle = resolver.build_dependency_bundle(snapshot, ["alpha"])
assert bundle["skills"] == ["alpha", "beta", "gamma"]
assert bundle["tools"] == ["t1", "t2", "t3"]
assert bundle["mcps"] == ["m1", "m2"]
def test_expand_skill_closure_cycle():
dependency_map = {
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
}
assert resolver.expand_skill_closure(["alpha"], dependency_map) == ["alpha", "beta"]
def test_collect_prompt_metadata_order_and_dedup():
snapshot: resolver.SkillSessionSnapshot = {
"selected_skills": ["beta", "alpha"],
"visible_skills": ["beta", "alpha"],
"prompt_metadata": {
"beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
"alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
},
"dependency_map": {},
}
result = resolver.collect_prompt_metadata(snapshot, ["beta", "missing", "alpha", "beta"])
assert [item["name"] for item in result] == ["beta", "alpha"]
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
@pytest.mark.asyncio
async def test_resolve_session_snapshot_and_selected_change(monkeypatch: pytest.MonkeyPatch):
async def fake_list_skills(_db=None):
return [
Skill(
slug="alpha",
name="alpha",
description="a",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=["beta"],
dir_path="skills/alpha",
),
Skill(
slug="beta",
name="beta",
description="b",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
dir_path="skills/beta",
),
]
monkeypatch.setattr(resolver, "_list_skills_from_db", fake_list_skills)
snapshot = await resolver.resolve_session_snapshot([" alpha ", "alpha"])
assert snapshot["selected_skills"] == ["alpha"]
assert snapshot["visible_skills"] == ["alpha", "beta"]
assert resolver.is_snapshot_match_selected_skills(snapshot, ["alpha"]) is True
assert resolver.is_snapshot_match_selected_skills(snapshot, ["beta"]) is False

View File

@ -7,6 +7,7 @@ from pathlib import Path
import pytest import pytest
from src.services import skill_service as svc from src.services import skill_service as svc
from src.services import tool_service
from src.storage.postgres.models_business import Skill from src.storage.postgres.models_business import Skill
@ -31,15 +32,26 @@ def test_parse_skill_markdown_requires_frontmatter():
svc._parse_skill_markdown("# missing") svc._parse_skill_markdown("# missing")
def test_validate_skill_slug(): def test_is_valid_skill_slug():
assert svc.validate_skill_slug("demo-skill") == "demo-skill" # Test valid slugs
with pytest.raises(ValueError, match="无效 skill slug"): assert svc.is_valid_skill_slug("demo-skill") is True
svc.validate_skill_slug("../bad") assert svc.is_valid_skill_slug("valid-name-123") is True
# Test invalid slugs
assert svc.is_valid_skill_slug("../bad") is False
assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed
assert svc.is_valid_skill_slug("") is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch): async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"]) # Mock get_tool_metadata to return tool list
def fake_get_tool_metadata(category=None):
return [
{"id": "calculator", "name": "Calculator"},
{"id": "search", "name": "Search"},
]
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"]) monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
class FakeRepo: class FakeRepo:
@ -55,7 +67,7 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc, "SkillRepository", FakeRepo) monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
result = await svc.get_skill_dependency_options(None) result = await svc.get_skill_dependency_options(None)
assert result["tools"] == ["calculator", "search"] assert result["tools"] == [{"id": "calculator", "name": "Calculator"}, {"id": "search", "name": "Search"}]
assert result["mcps"] == ["mcp-a", "mcp-b"] assert result["mcps"] == ["mcp-a", "mcp-b"]
assert result["skills"] == ["alpha", "beta"] assert result["skills"] == ["alpha", "beta"]
@ -202,7 +214,12 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
mcp_dependencies=[], mcp_dependencies=[],
skill_dependencies=[], skill_dependencies=[],
) )
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
# Mock get_tool_metadata to return tool list
def fake_get_tool_metadata(category=None):
return [{"id": "calculator", "name": "Calculator"}]
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"]) monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
async def fake_get_skill_or_raise(_db, slug: str): async def fake_get_skill_or_raise(_db, slug: str):

View File

@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from src.agents.common.backends import skills_backend from src.agents.common.backends import skills_backend
@ -44,69 +43,3 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
upload_result = backend.upload_files([("/alpha/a.txt", b"a")]) upload_result = backend.upload_files([("/alpha/a.txt", b"a")])
assert len(upload_result) == 1 assert len(upload_result) == 1
assert upload_result[0].error == "permission_denied" assert upload_result[0].error == "permission_denied"
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
runtime = SimpleNamespace(
context=SimpleNamespace(
skills=["alpha"],
skill_session_snapshot={
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta"],
"prompt_metadata": {},
"dependency_map": {},
},
),
state={},
)
composite = skills_backend.create_agent_composite_backend(runtime)
root = composite.ls_info("/")
all_paths = [entry.get("path") for entry in root]
assert "/skills/" in all_paths
skills_root = composite.ls_info("/skills/")
skill_paths = sorted(entry.get("path") for entry in skills_root)
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
denied = composite.write("/skills/alpha/new.md", "x")
assert denied.error and "read-only" in denied.error
def test_composite_backend_fallbacks_to_context_skills_when_snapshot_missing(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
runtime = SimpleNamespace(
context=SimpleNamespace(skills=["alpha"]),
state={},
)
composite = skills_backend.create_agent_composite_backend(runtime)
skills_root = composite.ls_info("/skills/")
skill_paths = sorted(entry.get("path") for entry in skills_root)
assert skill_paths == ["/skills/alpha/"]
def test_composite_backend_reads_visible_skills_from_runtime_context(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
runtime = SimpleNamespace(
context=SimpleNamespace(
skills=["alpha"],
skill_session_snapshot={
"selected_skills": ["alpha"],
"visible_skills": ["alpha", "beta"],
"prompt_metadata": {},
"dependency_map": {},
},
),
state=None,
)
composite = skills_backend.create_agent_composite_backend(runtime)
skills_root = composite.ls_info("/skills/")
skill_paths = sorted(entry.get("path") for entry in skills_root)
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]

View File

@ -112,8 +112,11 @@
:visible="approvalState.showModal" :visible="approvalState.showModal"
:question="approvalState.question" :question="approvalState.question"
:operation="approvalState.operation" :operation="approvalState.operation"
@approve="handleApprove" :options="approvalState.options"
@reject="handleReject" :multi-select="approvalState.multiSelect"
:allow-other="approvalState.allowOther"
@submit="handleQuestionSubmit"
@cancel="handleQuestionCancel"
/> />
<div class="message-input-wrapper"> <div class="message-input-wrapper">
@ -1113,7 +1116,12 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => {
} }
} }
if (event === 'finished' || event === 'error' || event === 'interrupted') { if (
event === 'finished' ||
event === 'error' ||
event === 'interrupted' ||
event === 'ask_user_question_required'
) {
flushTypingQueueForThread(threadId) flushTypingQueueForThread(threadId)
ts.isStreaming = false ts.isStreaming = false
ts.activeRunId = null ts.activeRunId = null
@ -1541,10 +1549,10 @@ const handleSendOrStop = async (payload) => {
} }
// ==================== ==================== // ==================== ====================
const handleApprovalWithStream = async (approved) => { const handleApprovalWithStream = async (answer) => {
const threadId = approvalState.threadId const threadId = approvalState.threadId
if (!threadId) { if (!threadId) {
message.error('无效的审批请求') message.error('无效的提问请求')
approvalState.showModal = false approvalState.showModal = false
return return
} }
@ -1558,11 +1566,7 @@ const handleApprovalWithStream = async (approved) => {
try { try {
// 使 composable // 使 composable
const response = await handleApproval( const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
approved,
currentAgentId.value,
selectedAgentConfigId.value
)
if (!response) return // handleApproval if (!response) return // handleApproval
@ -1586,12 +1590,12 @@ const handleApprovalWithStream = async (approved) => {
} }
} }
const handleApprove = () => { const handleQuestionSubmit = (answer) => {
handleApprovalWithStream(true) handleApprovalWithStream(answer)
} }
const handleReject = () => { const handleQuestionCancel = () => {
handleApprovalWithStream(false) handleApprovalWithStream('reject')
} }
// //

View File

@ -6,19 +6,49 @@
<h4>{{ question }}</h4> <h4>{{ question }}</h4>
</div> </div>
<div class="approval-operation"> <div v-if="operation" class="approval-operation">
<span class="label">操作</span> <span class="label">操作</span>
<span class="operation-text">{{ operation }}</span> <span class="operation-text">{{ operation }}</span>
</div> </div>
<div class="question-options">
<label v-for="(item, index) in options" :key="`${item.value}-${index}`" class="option-item">
<input
v-if="multiSelect"
type="checkbox"
:value="item.value"
:checked="selectedValues.includes(item.value)"
:disabled="isProcessing"
@change="toggleSelect(item.value)"
/>
<input
v-else
type="radio"
name="approval-option"
:value="item.value"
:checked="selectedValues[0] === item.value"
:disabled="isProcessing"
@change="setSingle(item.value)"
/>
<span :class="{ recommended: index === 0 && String(item.label).includes('(Recommended)') }">
{{ item.label }}
</span>
</label>
</div>
<div v-if="allowOther" class="other-input">
<input
v-model.trim="otherText"
type="text"
:disabled="isProcessing"
placeholder="Other: 输入自定义答案"
/>
</div>
</div> </div>
<div class="approval-actions"> <div class="approval-actions">
<button class="btn btn-reject" @click="handleReject" :disabled="isProcessing"> <button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
拒绝 <button class="btn btn-approve" @click="handleSubmit" :disabled="isSubmitDisabled">提交</button>
</button>
<button class="btn btn-approve" @click="handleApprove" :disabled="isProcessing">
批准
</button>
</div> </div>
<div v-if="isProcessing" class="approval-processing"> <div v-if="isProcessing" class="approval-processing">
@ -30,47 +60,114 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
const props = defineProps({ const props = defineProps({
visible: { visible: { type: Boolean, default: false },
type: Boolean, question: { type: String, default: '请选择一个选项' },
default: false operation: { type: String, default: '' },
}, options: { type: Array, default: () => [] },
question: { multiSelect: { type: Boolean, default: false },
type: String, allowOther: { type: Boolean, default: true }
default: '是否批准此操作?'
},
operation: {
type: String,
default: ''
}
}) })
const emit = defineEmits(['approve', 'reject']) const emit = defineEmits(['submit', 'cancel'])
const isProcessing = ref(false) const isProcessing = ref(false)
const selectedValues = ref([])
const otherText = ref('')
const resetForm = () => {
isProcessing.value = false
selectedValues.value = []
otherText.value = ''
}
//
watch( watch(
() => props.visible, () => props.visible,
(newVal) => { (newVal) => {
if (!newVal) { if (!newVal) {
isProcessing.value = false resetForm()
} }
} }
) )
const handleApprove = () => { watch(
() => props.options,
(next) => {
if (!Array.isArray(next) || next.length === 0) {
selectedValues.value = []
return
}
if (props.multiSelect) {
selectedValues.value = selectedValues.value.filter((value) =>
next.some((item) => item?.value === value)
)
return
}
const current = selectedValues.value[0]
if (current && next.some((item) => item?.value === current)) return
selectedValues.value = [next[0].value]
},
{ immediate: true, deep: true }
)
watch(
() => props.multiSelect,
(isMulti) => {
if (isMulti) return
if (selectedValues.value.length > 1) {
selectedValues.value = selectedValues.value.slice(0, 1)
}
}
)
const toggleSelect = (value) => {
if (isProcessing.value) return if (isProcessing.value) return
isProcessing.value = true if (selectedValues.value.includes(value)) {
emit('approve') selectedValues.value = selectedValues.value.filter((item) => item !== value)
} else {
selectedValues.value = [...selectedValues.value, value]
}
} }
const handleReject = () => { const setSingle = (value) => {
if (isProcessing.value) return if (isProcessing.value) return
selectedValues.value = [value]
}
const isSubmitDisabled = computed(() => {
if (isProcessing.value) return true
const hasSelected = selectedValues.value.length > 0
const hasOther = props.allowOther && Boolean(otherText.value)
return !hasSelected && !hasOther
})
const buildAnswer = () => {
const selected = selectedValues.value
const other = otherText.value
if (props.allowOther && other) {
return {
type: 'other',
text: other,
selected: selected
}
}
if (props.multiSelect) {
return selected
}
return selected[0]
}
const handleSubmit = () => {
if (isSubmitDisabled.value) return
isProcessing.value = true isProcessing.value = true
emit('reject') emit('submit', buildAnswer())
}
const handleCancel = () => {
if (isProcessing.value) return
emit('cancel')
} }
</script> </script>
@ -112,6 +209,7 @@ const handleReject = () => {
line-height: 1.5; line-height: 1.5;
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-bottom: 10px;
} }
.approval-operation .label { .approval-operation .label {
@ -125,6 +223,42 @@ const handleReject = () => {
word-break: break-word; word-break: break-word;
} }
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--gray-800);
font-size: 14px;
}
.option-item .recommended {
color: var(--main-color);
font-weight: 600;
}
.other-input {
margin-top: 10px;
}
.other-input input {
width: 100%;
border: 1px solid var(--gray-300);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
outline: none;
}
.other-input input:focus {
border-color: var(--main-color);
}
.approval-actions { .approval-actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@ -140,10 +274,6 @@ const handleReject = () => {
font-weight: 500; font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
} }
.btn:disabled { .btn:disabled {
@ -167,7 +297,6 @@ const handleReject = () => {
.btn-approve:hover:not(:disabled) { .btn-approve:hover:not(:disabled) {
background: var(--main-700); background: var(--main-700);
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
} }
.approval-processing { .approval-processing {
@ -197,7 +326,6 @@ const handleReject = () => {
} }
} }
/* 滑入滑出动画 */
.slide-up-enter-active, .slide-up-enter-active,
.slide-up-leave-active { .slide-up-leave-active {
transition: all 0.25s ease; transition: all 0.25s ease;

View File

@ -110,6 +110,7 @@ export function useAgentStreamHandler({
} }
return true return true
case 'ask_user_question_required':
case 'human_approval_required': case 'human_approval_required':
console.log(`${debugPrefix}[approval_required]`, { console.log(`${debugPrefix}[approval_required]`, {
threadId, threadId,

View File

@ -3,21 +3,79 @@ import { message } from 'ant-design-vue'
import { handleChatError } from '@/utils/errorHandler' import { handleChatError } from '@/utils/errorHandler'
import { agentApi } from '@/apis' import { agentApi } from '@/apis'
const normalizeOptions = (rawOptions) => {
if (!Array.isArray(rawOptions)) return []
return rawOptions
.map((item) => {
if (item && typeof item === 'object') {
const label = String(item.label || item.value || '').trim()
const value = String(item.value || item.label || '').trim()
return label && value ? { label, value } : null
}
const text = String(item || '').trim()
return text ? { label: text, value: text } : null
})
.filter(Boolean)
}
const toLegacyApprovalOptions = () => [
{ label: '批准 (Recommended)', value: 'approve' },
{ label: '拒绝', value: 'reject' }
]
const extractQuestionPayload = (chunk) => {
const interruptInfo = chunk?.interrupt_info || {}
const rawOptions =
chunk?.options || interruptInfo?.options || (interruptInfo?.operation ? toLegacyApprovalOptions() : [])
const options = normalizeOptions(rawOptions)
const operation = chunk?.operation || interruptInfo?.operation || ''
const source = chunk?.source || interruptInfo?.source || (operation ? 'get_approved_user_goal' : 'interrupt')
const multiSelect = Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false)
let allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true)
const questionId = chunk?.question_id || interruptInfo?.question_id || ''
const question = chunk?.question || interruptInfo?.question || '请选择一个选项'
const legacyMode = source === 'get_approved_user_goal' && options.length === 2
if (source === 'get_approved_user_goal') {
allowOther = false
}
return {
questionId,
question,
options,
multiSelect,
allowOther,
source,
operation,
legacyMode
}
}
const inferApprovedFromAnswer = (answer) => {
if (answer === 'approve') return true
if (answer === 'reject') return false
return null
}
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) { export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
// 审批状态
const approvalState = reactive({ const approvalState = reactive({
showModal: false, showModal: false,
questionId: '',
question: '', question: '',
operation: '', operation: '',
threadId: null, options: [],
interruptInfo: null multiSelect: false,
allowOther: true,
source: '',
legacyMode: false,
threadId: null
}) })
// 处理审批逻辑 const handleApproval = async (answer, currentAgentId, agentConfigId = null) => {
const handleApproval = async (approved, currentAgentId, agentConfigId = null) => {
const threadId = approvalState.threadId const threadId = approvalState.threadId
if (!threadId) { if (!threadId) {
message.error('无效的审批请求') message.error('无效的提问请求')
approvalState.showModal = false approvalState.showModal = false
return return
} }
@ -29,94 +87,93 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
return return
} }
// 关闭弹窗
approvalState.showModal = false approvalState.showModal = false
// 清理旧的流式控制器(如果存在)
if (threadState.streamAbortController) { if (threadState.streamAbortController) {
threadState.streamAbortController.abort() threadState.streamAbortController.abort()
threadState.streamAbortController = null threadState.streamAbortController = null
} }
// 标记为处理中
threadState.isStreaming = true threadState.isStreaming = true
resetOnGoingConv(threadId) resetOnGoingConv(threadId)
threadState.streamAbortController = new AbortController() threadState.streamAbortController = new AbortController()
console.log('🔄 [APPROVAL] Starting resume process:', { approved, threadId, currentAgentId }) const approved = inferApprovedFromAnswer(answer)
const requestBody = {
thread_id: threadId,
answer,
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
}
if (approved !== null) {
requestBody.approved = approved
}
try { try {
// 调用恢复接口 const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, {
const response = await agentApi.resumeAgentChat( signal: threadState.streamAbortController?.signal
currentAgentId, })
{
thread_id: threadId,
approved: approved,
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
},
{
signal: threadState.streamAbortController?.signal
}
)
console.log('🔄 [APPROVAL] Resume API response received')
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
console.error('Resume API error:', response.status, errorText)
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`) throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`)
} }
console.log('🔄 [APPROVAL] Resume API successful, returning response for stream processing') return response
return response // 返回响应供调用方处理流式数据
} catch (error) { } catch (error) {
console.error('❌ [APPROVAL] Resume failed:', error)
if (error.name !== 'AbortError') { if (error.name !== 'AbortError') {
handleChatError(error, 'resume') handleChatError(error, 'resume')
message.error(`恢复对话失败: ${error.message || '未知错误'}`) message.error(`恢复对话失败: ${error.message || '未知错误'}`)
} }
// 重置状态 - 只在错误时重置
threadState.isStreaming = false threadState.isStreaming = false
threadState.streamAbortController = null threadState.streamAbortController = null
throw error // 重新抛出错误让调用方处理 throw error
} }
// 移除 finally 块 - 让组件管理流式状态的生命周期
} }
// 在流式处理中处理审批请求
const processApprovalInStream = (chunk, threadId, currentAgentId) => { const processApprovalInStream = (chunk, threadId, currentAgentId) => {
if (chunk.status !== 'human_approval_required') { if (chunk.status !== 'ask_user_question_required' && chunk.status !== 'human_approval_required') {
return false return false
} }
const { interrupt_info } = chunk
const threadState = getThreadState(threadId) const threadState = getThreadState(threadId)
if (!threadState) return false if (!threadState) return false
// 停止显示"处理中"状态,让用户可以看到并操作审批弹窗 const payload = extractQuestionPayload(chunk)
if (!payload.options.length) {
payload.options = toLegacyApprovalOptions()
payload.legacyMode = true
payload.allowOther = false
}
threadState.isStreaming = false threadState.isStreaming = false
// 显示审批弹窗
approvalState.showModal = true approvalState.showModal = true
approvalState.question = interrupt_info?.question || '是否批准以下操作?' approvalState.questionId = payload.questionId
approvalState.operation = interrupt_info?.operation || '未知操作' approvalState.question = payload.question
approvalState.operation = payload.operation
approvalState.options = payload.options
approvalState.multiSelect = payload.multiSelect
approvalState.allowOther = payload.allowOther
approvalState.source = payload.source
approvalState.legacyMode = payload.legacyMode
approvalState.threadId = chunk.thread_id || threadId approvalState.threadId = chunk.thread_id || threadId
approvalState.interruptInfo = interrupt_info
// 刷新消息历史显示已执行的部分 fetchThreadMessages({ agentId: currentAgentId, threadId })
fetchThreadMessages({ agentId: currentAgentId, threadId: threadId })
return true // 表示已处理审批请求,应停止流式处理 return true
} }
// 重置审批状态
const resetApprovalState = () => { const resetApprovalState = () => {
approvalState.showModal = false approvalState.showModal = false
approvalState.questionId = ''
approvalState.question = '' approvalState.question = ''
approvalState.operation = '' approvalState.operation = ''
approvalState.options = []
approvalState.multiSelect = false
approvalState.allowOther = true
approvalState.source = ''
approvalState.legacyMode = false
approvalState.threadId = null approvalState.threadId = null
approvalState.interruptInfo = null
} }
return { return {