feat:架构重构
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""screen agent:新建和修改大屏的唯一入口
|
||||
|
||||
代码只负责组装;改哪些组件、怎么做、要不要追问由模型结合 skill 决定
|
||||
规则各有唯一出处:动作协议在 Action schema,回复要求在 AgentReply,追问格式在 Clarification,
|
||||
组件目录和物料在物料 MCP,各类任务的做法在 skill;系统提示只放每次都要用、别处放不下的原则
|
||||
"""
|
||||
|
||||
from typing import Literal, Self
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import ModelCallLimitMiddleware, wrap_model_call
|
||||
from langchain.agents.structured_output import ToolStrategy
|
||||
from langchain_core.messages import AIMessage
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.skills import skill_middleware
|
||||
from app.agent.tools import (
|
||||
ScreenAgentContext,
|
||||
ScreenAgentState,
|
||||
apply_actions,
|
||||
get_nodes,
|
||||
)
|
||||
from app.clients.llm import get_llm
|
||||
from app.clients.mcp import get_mcp_tools
|
||||
from app.config import settings
|
||||
from app.prompt.domain import INPUT_AS_DATA
|
||||
from app.schemas.clarification import Clarification
|
||||
|
||||
SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,新增、修改、删除组件,或调整画布
|
||||
先了解需要的信息(大屏摘要、组件配置、物料、skill),再用 apply_actions 提交修改,最后回复用户
|
||||
|
||||
<input_rules>
|
||||
{INPUT_AS_DATA}
|
||||
</input_rules>
|
||||
|
||||
<clarify_rules>
|
||||
默认先执行:缺少的细节(位置、尺寸、标题、样式等)用合理的默认值补齐
|
||||
只有无法确定用户指的是哪个组件,或看不出用户要做什么时才追问;澄清记录里已经回答过的不再问
|
||||
只删除用户明确要求删除的组件;不确定删哪个时追问,不要猜
|
||||
</clarify_rules>"""
|
||||
|
||||
|
||||
class AgentReply(BaseModel):
|
||||
kind: Literal["done", "clarify", "unable"] = Field(
|
||||
description="done:最后一次 apply_actions 已成功,或请求不需要修改(例如只是提问);"
|
||||
"clarify:需要用户回答才能继续;unable:无法完成"
|
||||
)
|
||||
message: str = Field(
|
||||
default="",
|
||||
description="给用户看的一两句话。done:说明改了什么,用了默认值或示例数据时一并说明,没有修改时直接回答;"
|
||||
"unable:说明原因,有可行的替代做法时一并给出;clarify:不用填",
|
||||
)
|
||||
clarification: Clarification | None = Field(
|
||||
default=None, description="kind=clarify 时填写,其他情况不填"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def clarification_matches_kind(self) -> Self:
|
||||
if (self.kind == "clarify") != (self.clarification is not None):
|
||||
raise ValueError("kind=clarify 时必须填写 clarification,其他情况不填")
|
||||
return self
|
||||
|
||||
|
||||
# 偶发的空回复最多重新调用几次
|
||||
EMPTY_REPLY_RETRIES = 2
|
||||
|
||||
|
||||
@wrap_model_call
|
||||
async def retry_empty_reply(request, handler):
|
||||
"""每一轮都必须调用工具(最后一轮是 AgentReply);模型偶尔返回既没有文本也没有工具调用的空回复,
|
||||
这是调用失败而不是模型的决定,重新调用即可"""
|
||||
response = await handler(request)
|
||||
for _ in range(EMPTY_REPLY_RETRIES):
|
||||
message = response.result[0] if response.result else None
|
||||
if not isinstance(message, AIMessage) or message.tool_calls:
|
||||
break
|
||||
logger.warning(
|
||||
f"模型返回了没有工具调用的回复,重新调用: {message.response_metadata}"
|
||||
)
|
||||
response = await handler(request)
|
||||
return response
|
||||
|
||||
|
||||
_agent = None
|
||||
|
||||
|
||||
async def get_screen_agent():
|
||||
"""第一次使用时组装,之后复用;系统提示是常量,不随物料目录变化"""
|
||||
global _agent
|
||||
if _agent is None:
|
||||
_agent = create_agent(
|
||||
model=get_llm(),
|
||||
tools=[get_nodes, apply_actions, *await get_mcp_tools()],
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
response_format=ToolStrategy(AgentReply),
|
||||
state_schema=ScreenAgentState,
|
||||
context_schema=ScreenAgentContext,
|
||||
middleware=[
|
||||
*skill_middleware(),
|
||||
retry_empty_reply,
|
||||
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
|
||||
ModelCallLimitMiddleware(
|
||||
run_limit=settings.max_agent_steps, exit_behavior="end"
|
||||
),
|
||||
],
|
||||
# 不继承外层图的检查点:agent 内部没有中断,中间消息不需要持久化
|
||||
checkpointer=False,
|
||||
name="screen_agent",
|
||||
)
|
||||
return _agent
|
||||
@@ -0,0 +1,44 @@
|
||||
"""skill 渐进式加载:目录追加到系统提示,正文和附件由模型用 read_file 按需读取
|
||||
|
||||
加载、解析和校验都由 Deep Agents 的 SkillsMiddleware 完成,这里只有配置
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from deepagents.backends.filesystem import FilesystemBackend
|
||||
from deepagents.middleware.filesystem import FilesystemMiddleware
|
||||
from deepagents.middleware.skills import SkillsMiddleware
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
|
||||
SKILLS_ROOT = Path(__file__).resolve().parents[2] / "skills"
|
||||
|
||||
# 必须保留三个占位符;目录每一行的格式由库固定
|
||||
SKILLS_PROMPT = """<skills>
|
||||
以下 skill 提供特定任务的做法。{skills_locations}{skills_load_warnings}
|
||||
|
||||
{skills_list}
|
||||
|
||||
使用方式:
|
||||
1. 任务符合某个 skill 的描述时,先用 read_file 读取它的 SKILL.md 再动手,传 limit=1000(默认只读 100 行)
|
||||
2. 正文中引用的文件写的是相对路径,拼在该 skill 的目录后再读取,例如 chart-replace 正文里的 references/a.md 要读 /chart-replace/references/a.md
|
||||
3. 一个请求可以用到多个 skill
|
||||
</skills>"""
|
||||
|
||||
|
||||
def skill_middleware(root: Path = SKILLS_ROOT) -> list[AgentMiddleware]:
|
||||
# virtual_mode:路径以 skills/ 为根,拦截 ..、~ 和根目录以外的路径
|
||||
backend = FilesystemBackend(root_dir=root, virtual_mode=True)
|
||||
return [
|
||||
FilesystemMiddleware(
|
||||
backend=backend,
|
||||
# 只开放读取,不提供写入、搜索和执行
|
||||
tools=["read_file"],
|
||||
# 关闭大结果写盘,否则会往 skills/ 里写文件
|
||||
tool_token_limit_before_evict=None,
|
||||
human_message_token_limit_before_evict=None,
|
||||
),
|
||||
# 用元组写明来源标签,裸路径 "/" 的标签会变成 Unnamed
|
||||
SkillsMiddleware(
|
||||
backend=backend, sources=[("/", "大屏")], system_prompt=SKILLS_PROMPT
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""screen agent 的本地工具:读取本次请求的大屏、提交整批动作
|
||||
|
||||
它们读写的是本次请求的大屏和 agent 状态,所以放在本地;物料等能力由 MCP 提供
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, NotRequired
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.tools import ToolRuntime, tool
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langgraph.types import Command
|
||||
|
||||
from app.actions.batch import BatchError, run_batch
|
||||
from app.schemas.actions import Action, AddAction
|
||||
from app.schemas.component_resource import ComponentCatalog
|
||||
from app.schemas.screen import Screen
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ScreenAgentContext:
|
||||
screen: Screen # 本次请求的原始大屏,只读
|
||||
catalog: ComponentCatalog # 当前物料目录,只用于补全和校验
|
||||
|
||||
|
||||
def _latest(_, new):
|
||||
# 同一步里有多次提交时取最后一个,避免并行工具调用写同一字段时报错
|
||||
return new
|
||||
|
||||
|
||||
class ScreenAgentState(AgentState):
|
||||
# 最近一次 apply_actions 成功时补全后的动作;失败时清空,以最后一次提交为准
|
||||
actions: NotRequired[Annotated[list[Action] | None, _latest]]
|
||||
# 最近一次 apply_actions 失败的原因,成功时清空
|
||||
apply_error: NotRequired[Annotated[str | None, _latest]]
|
||||
|
||||
|
||||
@tool
|
||||
def get_nodes(node_ids: list[str], runtime: ToolRuntime[ScreenAgentContext]) -> dict:
|
||||
"""读取组件的完整配置(layout、props、style 等)。修改属性或迁移数据前先读取
|
||||
|
||||
读取的是原始大屏,不包含本次已提交的修改
|
||||
"""
|
||||
nodes = {node.id: node for node in runtime.context.screen.nodes}
|
||||
return {
|
||||
"nodes": [
|
||||
nodes[item].model_dump(mode="json") for item in node_ids if item in nodes
|
||||
],
|
||||
"missing": [item for item in node_ids if item not in nodes],
|
||||
}
|
||||
|
||||
|
||||
def _result_text(actions: list[Action]) -> str:
|
||||
lines = [f"执行成功,共 {len(actions)} 个动作已生效"]
|
||||
if sample := [
|
||||
action.node.id
|
||||
for action in actions
|
||||
if isinstance(action, AddAction) and action.sample_data
|
||||
]:
|
||||
lines.append(f"使用物料示例数据的新增组件:{'、'.join(sample)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@tool
|
||||
def apply_actions(
|
||||
actions: list[Action], runtime: ToolRuntime[ScreenAgentContext]
|
||||
) -> Command:
|
||||
"""提交本次要对大屏做的全部修改
|
||||
|
||||
每次调用都是完整的一批,从原始大屏开始按顺序模拟执行,不在上一次调用的结果上累加
|
||||
失败时整批不生效,返回带动作序号的错误,修正后重新提交整批;以最后一次调用的结果为准
|
||||
"""
|
||||
try:
|
||||
completed, _ = run_batch(
|
||||
runtime.context.screen, runtime.context.catalog, actions
|
||||
)
|
||||
except BatchError as error:
|
||||
message = f"执行失败,整批未生效:\n- {error}"
|
||||
return Command(
|
||||
update={
|
||||
"actions": None,
|
||||
"apply_error": message,
|
||||
"messages": [
|
||||
ToolMessage(
|
||||
message, tool_call_id=runtime.tool_call_id, status="error"
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
return Command(
|
||||
update={
|
||||
"actions": completed,
|
||||
"apply_error": None,
|
||||
"messages": [
|
||||
ToolMessage(_result_text(completed), tool_call_id=runtime.tool_call_id)
|
||||
],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user