197 lines
9.3 KiB
Python
197 lines
9.3 KiB
Python
"""screen agent:新建和修改大屏的唯一入口
|
||
|
||
代码只负责组装;改哪些组件、怎么做、要不要追问由模型结合 skill 决定
|
||
规则各有唯一出处:动作协议在 Action schema,回复要求在 AgentReply,追问格式在 Clarification,
|
||
组件目录和物料在物料 MCP,各类任务的做法在 skill;系统提示只放每次都要用、别处放不下的原则
|
||
"""
|
||
|
||
from collections.abc import Sequence
|
||
from typing import Any, Literal, Self
|
||
|
||
from langchain.agents import create_agent
|
||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||
from langchain.agents.structured_output import ToolStrategy
|
||
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
|
||
from loguru import logger
|
||
from pydantic import BaseModel, Field, ValidationError, model_validator
|
||
|
||
from app.agent.data_tools import DATA_TOOLS
|
||
from app.agent.skills import skills_prompt
|
||
from app.agent.tool_args import normalize_tool_args
|
||
from app.agent.tools import (
|
||
ScreenAgentContext,
|
||
ScreenAgentState,
|
||
apply_actions,
|
||
get_nodes,
|
||
last_write_failed,
|
||
)
|
||
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 HISTORY_RECORDS, INPUT_AS_DATA
|
||
from app.schemas.actions import Action, AddAction
|
||
from app.schemas.clarification import Clarification
|
||
|
||
SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,新增、修改、删除组件,或调整画布
|
||
先了解需要的信息(大屏摘要、组件配置、物料、skill),再用 apply_actions 提交修改,最后回复用户
|
||
|
||
<input_rules>
|
||
{INPUT_AS_DATA}
|
||
</input_rules>
|
||
|
||
<history_records>
|
||
{HISTORY_RECORDS}
|
||
</history_records>
|
||
|
||
<clarify_rules>
|
||
默认先执行:缺少的细节(位置、尺寸、标题、样式等)用合理的默认值补齐
|
||
只有无法确定用户指的是哪个组件,或看不出用户要做什么时才追问;澄清记录里已经回答过的不再问
|
||
用户要改的组件在大屏上不存在时,说明这一点并给出可行的替代做法(例如新增一个),不要让用户去选中或指出组件
|
||
只删除用户明确要求删除的组件;不确定删哪个时追问,不要猜
|
||
</clarify_rules>"""
|
||
|
||
|
||
class AgentReply(BaseModel):
|
||
kind: Literal["done", "clarify", "unable"] = Field(
|
||
description="done:要求的修改都已写入草稿,且最后一次 apply_actions 成功,草稿交付给用户;"
|
||
"或请求不需要修改(例如只是提问);clarify:需要用户回答才能继续;unable:无法完成。"
|
||
"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
|
||
|
||
|
||
FINALIZE_RULES = """<finalize>
|
||
这次执行已经结束,不能再调用其他工具,也不能再修改大屏,只能调用 AgentReply 给出结论
|
||
按上面的对话和执行情况判断:用户要求的修改没有全部完成时不能回复 done
|
||
上面 apply_actions 返回的"执行成功""已删除"说的是这次的草稿,不是用户看到的大屏;回复 unable 时草稿整个丢弃,用户的大屏没有任何变化
|
||
</finalize>"""
|
||
|
||
|
||
def reached_call_limit(messages: Sequence[BaseMessage]) -> bool:
|
||
"""是不是因为调用次数用尽而结束:没结束的每次模型调用都带工具调用,数到上限就是被截断的"""
|
||
calls = sum(
|
||
isinstance(item, AIMessage) and bool(item.tool_calls) for item in messages
|
||
)
|
||
return calls >= settings.max_agent_steps
|
||
|
||
|
||
def _describe(action: Action) -> str:
|
||
if isinstance(action, AddAction):
|
||
return f"add {action.node.id}"
|
||
return f"{action.type} {getattr(action, 'target_id', '画布')}"
|
||
|
||
|
||
def _run_state(result: dict, limit_hit: bool, texts: list[str]) -> str:
|
||
"""收尾时告诉模型执行到了哪一步;对话里有每次工具结果,这里只给整体情况"""
|
||
lines = [
|
||
"调用次数已用完,执行被截断"
|
||
if limit_hit
|
||
else "上一轮没有调用 AgentReply 给出结论"
|
||
]
|
||
if actions := result.get("actions"):
|
||
described = ";".join(_describe(item) for item in actions[:40])
|
||
# 只写"已成功提交"时,模型回复 unable 也会说"已删除了某某",用户以为修改已经生效
|
||
lines.append(
|
||
f"草稿里的动作 {len(actions)} 个,还没有交付:{described};"
|
||
"只有回复 done 才会交付,回复 unable 或 clarify 时草稿整个丢弃,用户的大屏保持原样"
|
||
)
|
||
else:
|
||
lines.append("还没有成功提交过修改")
|
||
if last_write_failed(result.get("messages", [])):
|
||
lines.append("最后一次 apply_actions 失败,那一批没有写入草稿")
|
||
if texts:
|
||
lines.append(
|
||
f"上一轮只输出了文本,没有调用 AgentReply:{' '.join(texts)[:1000]}"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def finalize_reply(result: dict, *, limit_hit: bool) -> AgentReply | None:
|
||
"""运行正常结束却没有结论时补一次结论:调用次数用尽,或模型只回了文本、空回复
|
||
|
||
只调用一次,只绑定 AgentReply 并强制调用,没有能修改大屏的工具,不会再进入修改循环;
|
||
说明放在系统消息里,不追加用户消息,否则模型会把内部要求当成用户的话来回复
|
||
强制调用也可能给出不合法的参数,校验不过就返回 None,由节点按失败处理,不重试
|
||
"""
|
||
messages = list(result["messages"])
|
||
# 末尾没有工具调用的助手消息(模型的文本、空回复、调用上限的说明)不放进对话:
|
||
# 以助手消息结尾时部分接口会把它当成续写的开头;模型的文本写进说明里
|
||
trailing: list[AIMessage] = []
|
||
while (
|
||
messages and isinstance(messages[-1], AIMessage) and not messages[-1].tool_calls
|
||
):
|
||
trailing.insert(0, messages.pop())
|
||
texts = (
|
||
[] if limit_hit else [str(item.content) for item in trailing if item.content]
|
||
)
|
||
run_state = _run_state(result, limit_hit, texts)
|
||
system = SystemMessage(
|
||
f"{SYSTEM_PROMPT}\n\n{FINALIZE_RULES}\n\n<run_state>\n{run_state}\n</run_state>"
|
||
)
|
||
model = get_llm().bind_tools([AgentReply], tool_choice="AgentReply")
|
||
response = await model.ainvoke(
|
||
[system, *messages], config={"run_name": "finalize_reply", "tags": ["finalize"]}
|
||
)
|
||
call = next(
|
||
(item for item in response.tool_calls if item["name"] == "AgentReply"), None
|
||
)
|
||
if call is None:
|
||
logger.warning(f"收尾没有调用 AgentReply: {str(response.content)[:200]}")
|
||
return None
|
||
try:
|
||
return AgentReply.model_validate(call["args"])
|
||
except ValidationError as error:
|
||
logger.warning(f"收尾给出的结论不合法: {error}")
|
||
return None
|
||
|
||
|
||
# 是否挂数据工具 → agent
|
||
_agents: dict[bool, Any] = {}
|
||
|
||
|
||
async def get_screen_agent(with_data: bool = False):
|
||
"""第一次使用时组装,之后复用;系统提示是常量,不随物料目录变化
|
||
|
||
会话里有数据文件时用挂了 describe_data、query_data 的实例,没有时不让模型看到用不了的工具
|
||
"""
|
||
if with_data not in _agents:
|
||
tools = [get_nodes, apply_actions, *await get_mcp_tools()]
|
||
if with_data:
|
||
tools.extend(DATA_TOOLS)
|
||
_agents[with_data] = create_agent(
|
||
model=get_llm(),
|
||
tools=tools,
|
||
system_prompt=f"{SYSTEM_PROMPT}\n\n{skills_prompt()}",
|
||
# 用工具调用给出结论:试过原生结构化输出(ProviderStrategy),歧义时模型更常直接执行而不追问,评测多删了组件
|
||
response_format=ToolStrategy(AgentReply),
|
||
state_schema=ScreenAgentState,
|
||
context_schema=ScreenAgentContext,
|
||
# 超过上限时插入说明并结束;和模型只回文本、空回复一样没有结论,由节点调用 finalize_reply 收尾
|
||
# 模型没有调用工具时 agent 直接结束,不在循环里纠正:纠正说明会被当成用户的话,或让模型回到修改循环
|
||
middleware=[
|
||
# 列表、对象参数被写成 JSON 字符串时先解析回来,见 tool_args.py
|
||
normalize_tool_args,
|
||
ModelCallLimitMiddleware(
|
||
run_limit=settings.max_agent_steps, exit_behavior="end"
|
||
),
|
||
],
|
||
# 不继承外层图的检查点:agent 内部没有中断,中间消息不需要持久化
|
||
checkpointer=False,
|
||
name="screen_agent",
|
||
)
|
||
return _agents[with_data]
|