feat:优化增量执行、工具调用容错与评测体系

This commit is contained in:
2026-10-11 04:40:25 -07:00
parent cd5a2b4d8f
commit dadd797391
22 changed files with 2198 additions and 578 deletions
+3
View File
@@ -10,6 +10,7 @@ from langchain.agents import create_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from app.agent.data_tools import DATA_TOOLS, DataContext
from app.agent.tool_args import normalize_tool_args
from app.clients.llm import get_llm
from app.config import settings
from app.prompt.qa import QA_DATA_RULES, QA_SYSTEM_PROMPT
@@ -37,6 +38,8 @@ def get_qa_agent(with_data: bool = False):
),
context_schema=QAContext,
middleware=[
# 有工具时才挂参数兼容:挂了 wrap_tool_call 中间件,没有工具也会多出一个工具节点
*([normalize_tool_args] if with_data else []),
# 超过上限时抛 ModelCallLimitExceededError,由节点按失败处理;
# 写成 end 时中间件只插入一条英文说明,节点分不清是正常结束还是用尽了次数
ModelCallLimitMiddleware(
+28 -6
View File
@@ -16,6 +16,8 @@ from app.schemas.actions import AddAction, AgentAction, UpdateAction
from app.schemas.screen import Screen
QUERY_KEY = "$query"
# 图表物料的数据都在这里,series 的 encode 按它的列名引用;整个换成查询结果时数据就不再是示例数据
SOURCE_PATH = "props.option.dataset.source"
@dataclass(frozen=True)
@@ -124,10 +126,30 @@ def _join(path: str, key: str | int) -> str:
return f"{path}.{key}" if path else str(key)
def encode_problems(screen: Screen, node_ids: set[str]) -> list[str]:
"""用了 $query 的组件,series 的 encode 引用的列必须在 dataset.source 里
def _binding(node) -> tuple[Any, Any]:
option = node.props.get("option")
if not isinstance(option, dict):
return None, None
return option.get("dataset"), option.get("series")
数据换成查询结果后列名变了,encode 还引用物料默认的列名时图表是空的,前端也不报错,所以在这里拦下
def rebound_nodes(before: Screen, after: Screen) -> set[str]:
"""这一批改过数据或映射(option.dataset、option.series)的组件,新增的也算
只按前后状态比较,不看是哪种动作改的:$query、直接写数据、单独改 encode 都会让两者对不上;没动过的旧组件不核对
"""
old = {node.id: _binding(node) for node in before.nodes}
return {
node.id
for node in after.nodes
if node.id not in old or old[node.id] != _binding(node)
}
def encode_problems(screen: Screen, node_ids: set[str]) -> list[str]:
"""指定组件的 series encode 引用的列必须在 dataset.source 里
数据换了列名、或 encode 改成了不存在的列时图表是空的,前端也不报错,所以在这里拦下
"""
problems = []
for node in screen.nodes:
@@ -161,11 +183,11 @@ def encode_problems(screen: Screen, node_ids: set[str]) -> list[str]:
]
if missing:
wrong.append(
f"series.{position}.encode.{key} 用了 {'、'.join(missing)}"
f"props.option.series.{position}.encode.{key} 用了 {'、'.join(missing)}"
)
if wrong:
problems.append(
f"组件 {node.id}:{';'.join(wrong)},但 dataset.source 的列是 {'、'.join(columns)};"
"把 encode 改成查询结果的列名,或在 SQL 里用 AS 起对应的别名"
f"组件 {node.id}:{';'.join(wrong)},但 props.option.dataset.source 的列是 {'、'.join(columns)};"
"把 encode 改成 dataset.source 里的列名;数据来自 $query 时也可以在 SQL 里用 AS 起对应的别名"
)
return problems
+96 -38
View File
@@ -5,27 +5,31 @@
组件目录和物料在物料 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, wrap_model_call
from langchain.agents.middleware import ModelCallLimitMiddleware
from langchain.agents.structured_output import ToolStrategy
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage
from loguru import logger
from pydantic import BaseModel, Field, model_validator
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 INPUT_AS_DATA
from app.schemas.actions import Action, AddAction
from app.schemas.clarification import Clarification
SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,新增、修改、删除组件,或调整画布
@@ -45,13 +49,15 @@ SYSTEM_PROMPT = f"""你是大屏编辑助手,按用户的要求新建大屏,
class AgentReply(BaseModel):
kind: Literal["done", "clarify", "unable"] = Field(
description="done:最后一次 apply_actions 已成功,或请求不需要修改(例如只是提问);"
"clarify:需要用户回答才能继续;unable:无法完成"
description="done:要求的修改都已写入草稿,且最后一次 apply_actions 成功,草稿交付给用户;"
"或请求不需要修改(例如只是提问);clarify:需要用户回答才能继续;unable:无法完成。"
"clarify 和 unable 时草稿整个丢弃,用户看到的仍是原始大屏"
)
message: str = Field(
default="",
description="给用户看的一两句话。done:说明改了什么,用了默认值或示例数据时一并说明,没有修改时直接回答;"
"unable:说明原因,有可行的替代做法时一并给出;clarify:不用填",
description="给用户看的一两句话,不要提草稿、工具、参数这类内部说法。done:说明改了什么,用了默认值或示例数据时一并说明,没有修改时直接回答;"
"unable:草稿整个丢弃,用户的大屏和请求前一样,不要说已经删除、新增或修改了什么;"
"说明没能完成什么和原因,有可行的替代做法时一并给出;clarify:不用填",
)
clarification: Clarification | None = Field(
default=None, description="kind=clarify 时填写,其他情况不填"
@@ -64,39 +70,89 @@ class AgentReply(BaseModel):
return self
# 模型没有调用工具时最多纠正几次
NO_TOOL_CALL_RETRIES = 2
NO_TOOL_CALL_HINT = (
"每一轮都要调用工具。要结束时调用 AgentReply,给用户的话写在 message 里"
)
FINALIZE_RULES = """<finalize>
这次执行已经结束,不能再调用其他工具,也不能再修改大屏,只能调用 AgentReply 给出结论
按上面的对话和执行情况判断:用户要求的修改没有全部完成时不能回复 done
上面 apply_actions 返回的"执行成功""已删除"说的是这次的草稿,不是用户看到的大屏;回复 unable 时草稿整个丢弃,用户的大屏没有任何变化
</finalize>"""
@wrap_model_call
async def require_tool_call(request, handler):
"""ToolStrategy 要求每一轮都调用工具,结束时调用 AgentReply;模型偶尔只回文本(想直接回复用户)或空回复
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
原样重试往往得到同样的结果,或者模型转而重复提交 apply_actions 直到调用次数用尽,
所以把它的回复和纠正说明加在下一次调用里;只用于这一次调用,不写进 agent 的消息
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,由节点按失败处理,不重试
"""
response = await handler(request)
for _ in range(NO_TOOL_CALL_RETRIES):
message = response.result[0] if response.result else None
if not isinstance(message, AIMessage) or message.tool_calls:
break
logger.warning(
f"模型没有调用工具,纠正后重新调用: {str(message.content)[:200]}"
)
# 空回复不放回去,部分接口不接受内容为空的助手消息
replied = [message] if message.content else []
request = request.override(
messages=[
*request.messages,
*replied,
HumanMessage(content=NO_TOOL_CALL_HINT),
]
)
response = await handler(request)
return response
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
@@ -120,9 +176,11 @@ async def get_screen_agent(with_data: bool = False):
response_format=ToolStrategy(AgentReply),
state_schema=ScreenAgentState,
context_schema=ScreenAgentContext,
# 超过上限时插入说明并结束;和模型只回文本、空回复一样没有结论,由节点调用 finalize_reply 收尾
# 模型没有调用工具时 agent 直接结束,不在循环里纠正:纠正说明会被当成用户的话,或让模型回到修改循环
middleware=[
require_tool_call,
# 超过上限时插入说明并结束,没有结构化结果,由节点按失败处理
# 列表、对象参数被写成 JSON 字符串时先解析回来,见 tool_args.py
normalize_tool_args,
ModelCallLimitMiddleware(
run_limit=settings.max_agent_steps, exit_behavior="end"
),
+264
View File
@@ -0,0 +1,264 @@
"""工具参数兼容:模型把列表、对象参数写成 JSON 字符串时,按工具的参数 schema 解析回来
部分模型(例如 claude-haiku-5.5)在嵌套较深、内容较长时,常把 apply_actions 的 actions 整个写成 JSON 字符串,
或把列表里的每一项写成字符串;参数校验报 "Input should be a valid list" 后它往往原样再交,评测里出现过
连续 10 次直到调用次数用尽。这类问题和哪个工具无关,所以在工具调用入口统一处理,对本地工具和 MCP 工具都生效
- 合法的输入不动:schema 里任何一处接受字符串(包括联合类型的某个成员、数组某个成员的 items)时不解析;
布尔 schema、解析不了的 $ref 这类看不准的情况按"什么都接受"处理,交给原有的参数校验
- 只处理两层:参数本身,以及列表参数里的每一项
- 写成字符串的长 JSON 多数只是结尾的括号多写或少写了一个:原文以括号结尾、模型输出没有因为长度被截断时,
按还没闭合的括号重新补齐结尾;截断时丢掉的内容补不回来,补齐反而会得到一个意思不同的合法动作,所以不补
- 仍然解析不出来时不执行,用中文说明错在哪里:参数校验的英文报错只说"应该是列表",模型会照旧写成字符串
- 解析或补齐后执行时,在工具结果末尾说明:补齐只能猜结尾,评测里模型把中间的 option 提前闭合、结尾又少一个括号,
补齐后 series 落到了 props 下,执行结果对不上而它看不出原因,反复原样提交
"""
import json
from dataclasses import replace
from typing import Any
from langchain.agents.middleware import wrap_tool_call
from langchain_core.messages import AIMessage, ToolMessage
from langchain_core.tools import BaseTool
from langgraph.types import Command
from loguru import logger
STRUCTURED = {"array", "object"}
ANY = frozenset({"string", "number", "integer", "boolean", "null", "array", "object"})
CLOSERS = {"[": "]", "{": "}"}
# 各家接口表示"输出达到长度上限被截断"的写法
TRUNCATED = {"length", "max_tokens"}
def _resolve(schema: Any, defs: dict) -> Any:
"""展开 $ref;指向的定义找不到时按 True(什么都接受)处理"""
while isinstance(schema, dict) and "$ref" in schema:
name = schema["$ref"].rsplit("/", 1)[-1]
if name not in defs:
return True
schema = defs[name]
return schema
def _types(schema: Any, defs: dict) -> set[str]:
"""schema 接受的 JSON 类型:anyOf、oneOf 取并集,allOf 取交集,没有类型约束时什么都接受"""
schema = _resolve(schema, defs)
if schema is False:
return set()
if not isinstance(schema, dict):
return set(ANY)
kind = schema.get("type")
if kind:
types = set(kind) if isinstance(kind, list) else {kind}
elif "properties" in schema:
types = {"object"}
elif "items" in schema:
types = {"array"}
else:
types = set(ANY)
for member in schema.get("allOf", []):
types &= _types(member, defs)
if members := [*schema.get("anyOf", []), *schema.get("oneOf", [])]:
types &= set().union(*(_types(member, defs) for member in members))
return types
def _items(schema: Any, defs: dict) -> list:
"""各个数组成员的 items schema;有数组成员没有限定 items 时,其中有一个 True(什么都接受)"""
schema = _resolve(schema, defs)
if not isinstance(schema, dict):
return [True] if schema is True else []
kind = schema.get("type")
own = set(kind) if isinstance(kind, list) else {kind}
found = []
if "prefixItems" in schema:
# 每个位置各有 schema 的元组,不逐项解析
found.append(True)
elif "items" in schema:
found.append(schema["items"])
elif "array" in own:
found.append(True)
for key in ("anyOf", "oneOf", "allOf"):
for member in schema.get(key, []):
found.extend(_items(member, defs))
return found
def _reclose(text: str) -> str | None:
"""去掉结尾连续的 ] 和 },按还没闭合的括号重新补齐;结尾停在字符串中间时补不了"""
body = text.rstrip().rstrip("]} \t\r\n")
stack: list[str] = []
in_string = escaped = False
for char in body:
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
elif char == '"':
in_string = True
elif char in CLOSERS:
stack.append(char)
elif char in "]}" and (not stack or CLOSERS[stack.pop()] != char):
# 中间就有括号对不上,不是结尾的问题,补不了
return None
if in_string or not stack:
return None
return body + "".join(CLOSERS[item] for item in reversed(stack))
def _matches(value: Any, types: set[str]) -> bool:
return (isinstance(value, list) and "array" in types) or (
isinstance(value, dict) and "object" in types
)
def _parse(value: Any, types: set[str], repair: bool) -> tuple[Any, str | None, bool]:
"""schema 只接受列表或对象、却收到字符串时解析成 JSON;返回 (值, 解析不了时的说明, 是否补了结尾括号)"""
if not isinstance(value, str) or "string" in types or not types & STRUCTURED:
return value, None, False
expected = "数组" if "array" in types else "对象"
patched = False
try:
parsed = json.loads(value)
except json.JSONDecodeError as error:
# 原文不以括号结尾多半是被截断了,补齐结尾会丢掉后面的内容,不补
ends_closed = value.rstrip().endswith(("]", "}"))
repaired = _reclose(value) if repair and ends_closed else None
try:
parsed, patched = (json.loads(repaired) if repaired else None), True
except json.JSONDecodeError:
parsed = None
if not _matches(parsed, types):
problem = (
f"应该直接写成 JSON {expected},不要写成字符串;"
f"这个字符串也不是合法的 JSON(第 {error.pos} 个字符附近:{error.msg})"
)
return value, problem, False
if not _matches(parsed, types):
return value, f"应该直接写成 JSON {expected},不要写成字符串", False
return parsed, None, patched
def normalize_args(
tool: BaseTool, args: dict, *, repair: bool = True
) -> tuple[dict, list[str], list[str]]:
"""按工具的参数 schema 把写成字符串的列表、对象参数解析回来
返回新的参数(不修改传入的)、解析不了的参数说明、补了结尾括号的参数名;有说明时不该执行这次调用
repair=False 时不补括号,用于模型输出被截断的情况
"""
schema = tool.tool_call_schema
schema = schema if isinstance(schema, dict) else schema.model_json_schema()
defs = schema.get("$defs", {})
properties = schema.get("properties", {})
fixed, problems, repaired = {}, [], []
for name, value in args.items():
prop = properties.get(name)
if prop is None:
fixed[name] = value
continue
value, problem, patched = _parse(value, _types(prop, defs), repair)
if problem:
problems.append(f"{name} {problem}")
item_schemas = _items(prop, defs)
if isinstance(value, list) and item_schemas:
item_types = set().union(*(_types(item, defs) for item in item_schemas))
parsed_items = []
for index, item in enumerate(value):
item, problem, item_patched = _parse(item, item_types, repair)
if problem:
problems.append(f"{name} 的第 {index + 1} 项{problem}")
patched = patched or item_patched
parsed_items.append(item)
value = parsed_items
if patched:
repaired.append(name)
fixed[name] = value
return fixed, problems, repaired
def _truncated(state: Any, call_id: str) -> bool:
"""发起这次调用的模型输出是不是因为长度上限被截断"""
messages = state.get("messages", []) if isinstance(state, dict) else []
message = next(
(
item
for item in reversed(messages)
if isinstance(item, AIMessage)
and any(call["id"] == call_id for call in item.tool_calls)
),
None,
)
metadata = message.response_metadata if message is not None else {}
return bool(
{metadata.get("finish_reason"), metadata.get("stop_reason")} & TRUNCATED
)
@wrap_tool_call
async def normalize_tool_args(request, handler):
"""在参数校验前统一修正参数;没有改动时原样执行,解析不了时返回中文说明,不执行"""
tool = request.tool
args = request.tool_call.get("args")
if tool is None or not isinstance(args, dict):
return await handler(request)
truncated = _truncated(request.state, request.tool_call["id"])
fixed, problems, repaired = normalize_args(tool, args, repair=not truncated)
if problems:
logger.info(
f"工具 {tool.name} 的参数写成了不合法的 JSON 字符串"
f"{'(模型输出被截断)' if truncated else ''}: {problems}"
)
return ToolMessage(
"参数格式错误,这次调用没有执行:\n"
+ "\n".join(f"- {item}" for item in problems)
+ ("\n- 输出达到了长度上限被截断,分成几批提交" if truncated else ""),
tool_call_id=request.tool_call["id"],
name=tool.name,
status="error",
)
if fixed == args:
return await handler(request)
changed = [name for name in args if fixed[name] != args[name]]
strict = [name for name in changed if name not in repaired]
notes = []
if strict:
logger.info(
f"工具 {tool.name} 的参数 {'、'.join(strict)} 写成了 JSON 字符串,已解析"
)
notes.append(f"{'、'.join(strict)} 写成了 JSON 字符串,已解析后执行")
if repaired:
logger.info(
f"工具 {tool.name} 的参数 {'、'.join(repaired)} 写成了 JSON 字符串,结尾括号对不上,补齐后解析"
)
# 补的是结尾,括号多写或少写在中间时,补齐后层级是错的,但它是合法的 JSON,执行时不一定报错
notes.append(
f"{'、'.join(repaired)} 写成了 JSON 字符串,结尾的括号数量不对,已按还没闭合的括号补齐后执行;"
"如果中间的括号写错了,补齐后的层级也是错的,对照执行结果核对"
)
result = await handler(
request.override(tool_call={**request.tool_call, "args": fixed})
)
# 不告诉模型时它会一直写成字符串,补齐猜错层级时它也看不出原因
return _with_note(
result, "参数说明:" + ";".join(notes) + ";以后直接写成 JSON,不要写成字符串"
)
def _with_note(result: Any, note: str) -> Any:
"""在工具返回的消息末尾加一句说明;工具返回 Command 时改其中的工具消息"""
if isinstance(result, ToolMessage):
return result.model_copy(update={"content": f"{result.content}\n{note}"})
if isinstance(result, Command) and isinstance(result.update, dict):
messages = [
item.model_copy(update={"content": f"{item.content}\n{note}"})
if isinstance(item, ToolMessage)
else item
for item in result.update.get("messages", [])
]
return replace(result, update={**result.update, "messages": messages})
return result
+221 -69
View File
@@ -1,6 +1,7 @@
"""screen agent 的本地工具:读取本次请求的大屏、提交整批动作
"""screen agent 的本地工具:读取当前大屏、在当前大屏上提交一批修改
它们读写的是本次请求的大屏和 agent 状态,所以放在本地;物料等能力由 MCP 提供
读取、修改和校验都基于同一份草稿:原始大屏加上之前写入草稿的修改;回复 done 时草稿交付给用户
"""
from collections.abc import Sequence
@@ -9,44 +10,57 @@ from typing import Annotated, NotRequired
from langchain.agents import AgentState
from langchain.tools import ToolRuntime, tool
from langchain_core.messages import ToolMessage
from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
from langgraph.types import Command
from pydantic import Field
from app.actions.batch import BatchError, run_batch
from app.actions.batch import BatchError, misplaced_props, run_batch
from app.agent.data_tools import DataContext
from app.agent.query_values import EmbeddedQuery, encode_problems, fill_queries
from app.schemas.actions import Action, AddAction, AgentAction
from app.agent.query_values import (
SOURCE_PATH,
EmbeddedQuery,
encode_problems,
fill_queries,
rebound_nodes,
)
from app.schemas.actions import (
Action,
AddAction,
AgentAction,
DeleteAction,
UpdateAction,
)
from app.schemas.component_resource import ComponentCatalog
from app.schemas.screen import Screen
@dataclass(frozen=True)
class ScreenAgentContext:
screen: Screen # 本次请求的原始大屏,只读
screen: Screen # 本次请求的原始大屏,只读,交付的动作从它开始执行
catalog: ComponentCatalog # 当前物料目录,只用于补全和校验
data: DataContext = field(default_factory=DataContext) # 本会话可用的数据表
def _latest(_, new):
# 同一步里有多次提交时取最后一个,避免并行工具调用写同一字段时报错
return new
# 两个字段只由 apply_actions 写,同一轮只执行第一次提交,所以一步里最多一次写入,不需要合并规则
# 提交是否失败不另存字段,由 last_write_failed 从消息推出:参数解析、参数校验、查询、执行各处的失败都只体现在消息上
class ScreenAgentState(AgentState):
# 最近一次 apply_actions 成功时补全后的动作;失败时清空,以最后一次提交为准
actions: NotRequired[Annotated[list[Action] | None, _latest]]
# 最近一次 apply_actions 失败的原因,成功时清空
apply_error: NotRequired[Annotated[str | None, _latest]]
# 草稿:最近一次写入后的大屏;还没有写入时不存在,用原始大屏
working_screen: NotRequired[Screen]
# 交付记录:各次写入的动作(补全、展开后)按顺序拼接,在原始大屏上依次执行得到草稿
actions: NotRequired[list[Action]]
def current_screen(runtime: ToolRuntime[ScreenAgentContext]) -> Screen:
return runtime.state.get("working_screen") or runtime.context.screen
@tool
def get_nodes(node_ids: list[str], runtime: ToolRuntime[ScreenAgentContext]) -> dict:
"""读取组件的完整配置(layout、props、style 等)。修改属性或迁移数据前先读取
读取的是原始大屏,不包含本次已提交的修改
读取的是草稿:原始大屏加上之前写入草稿的修改;和 apply_actions 在同一轮调用时读不到这次提交的结果,要核对就在下一轮读取
"""
nodes = {node.id: node for node in runtime.context.screen.nodes}
nodes = {node.id: node for node in current_screen(runtime).nodes}
return {
"nodes": [
nodes[item].model_dump(mode="json") for item in node_ids if item in nodes
@@ -61,13 +75,38 @@ def _result_text(
actions: list[Action],
undeclared_sample: frozenset[str] = frozenset(),
queries: Sequence[EmbeddedQuery] = (),
unchanged: bool = False,
repeated: bool = False,
relabeled: Sequence[str] = (),
) -> str:
"""执行结果:除了动作数量,还给出执行后的画布和每个组件的布局
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正,
不用靠重复提交来试;本次动过布局或新增的组件前面标 *,已删除的单独列出
模型提交后才第一次看到结果,能自己发现位置超出画布、互相重叠、没排整齐等问题再修正;
这一批动过布局或新增的组件前面标 *,这一批删除的单独列出
没有带来变化时也照样给出布局:只说"已经写入"会让模型以为达到了目标,可能当前大屏本来就是这样、并不符合要求
统一说"写入草稿"、"回复 done 时交付":说"已生效"时模型回复 unable 也会说已经改了什么,
不说怎么交付时它会再交一批空的或重复的来"确认"
"""
lines = [f"执行成功,共 {len(actions)} 个动作已生效"]
if unchanged and repeated:
# 之前的提交已经改过:模型不确定是否写入才会重复提交,要写明已经在草稿里、不要重复,并说怎么结束,
# 只说"没有变化"时它以为修改没成功,会换着字段反复提交直到调用次数用尽
lines = [
(
"执行成功,但这一批没有改变草稿(0 个动作):提交的内容和草稿里已有的一样,之前的提交已经改过。"
"对照要求和下面的布局:已经达到就调用 AgentReply 回复 done,草稿会一起交付;"
"没达到就提交不同的修改,不要原样重复提交"
)
]
elif unchanged:
# 这次运行还没有改过大屏:原始大屏本来就是这样,模型的方案并没有改动任何东西,只陈述事实;
# 加上"完成就回复"之类的提示时,大屏并不符合要求它也会当成完成
lines = [
"执行成功,共 0 个动作写入草稿:提交的修改和原始大屏已有的一样,大屏原本就是这样"
]
else:
lines = [
f"执行成功,共 {len(actions)} 个动作已写入草稿;回复 done 时草稿交付给用户,不需要再提交确认"
]
sample = [
action.node.id
for action in actions
@@ -78,10 +117,15 @@ def _result_text(
if guessed := [item for item in sample if item in undeclared_sample]:
lines.append(
f"未写 sample_data,按物料默认标成了示例数据:{'、'.join(guessed)};"
"如果这些组件展示的数据都来自大屏上已有的组件或上传的数据文件,把 sample_data 设为 false 重新提交整批,否则不用处理"
"如果这些组件展示的数据都来自大屏上已有的组件或上传的数据文件,"
"用 $query 整体替换它们的 option.dataset.source,或再提交一批:先 delete,再用 sample_data=false 重新 add,否则不用处理"
)
if declared := [item for item in sample if item not in undeclared_sample]:
lines.append(f"标为示例数据的新增组件:{'、'.join(declared)}")
if relabeled:
lines.append(
f"数据整体换成了 $query 的查询结果,已改标为非示例数据:{'、'.join(relabeled)}"
)
# 模型提交时只写了 SQL,看不到结果;给出列名和行数,用来核对 encode 和数据量
if queries:
lines.append("$query 已替换为查询结果:")
@@ -110,74 +154,182 @@ def _result_text(
return "\n".join(lines)
def _reply(
runtime: ToolRuntime, text: str, *, error: bool = False, **update
) -> Command:
"""返回工具消息;状态字段由调用方显式传入,没传的保持不变"""
status = "error" if error else "success"
message = ToolMessage(text, tool_call_id=runtime.tool_call_id, status=status)
return Command(update={**update, "messages": [message]})
def _first_write(message: AIMessage) -> str | None:
"""一条模型消息里第一个 apply_actions 调用的 ID;同一轮只执行它"""
return next(
(call["id"] for call in message.tool_calls if call["name"] == "apply_actions"),
None,
)
def _is_first_submission(runtime: ToolRuntime) -> bool:
"""按模型消息里 tool_calls 的顺序,这次调用是不是第一个 apply_actions
同一轮的多个调用都从同一份草稿出发,都执行会互相覆盖;只执行第一个,与并发谁先跑完无关
直接调用工具、找不到发起调用的模型消息时,视为第一个
"""
message = next(
(
item
for item in reversed(runtime.state.get("messages", []))
if isinstance(item, AIMessage)
and any(call["id"] == runtime.tool_call_id for call in item.tool_calls)
),
None,
)
return message is None or _first_write(message) == runtime.tool_call_id
def last_write_failed(messages: Sequence[BaseMessage]) -> bool:
"""最后一次被接受的提交是否失败:最后一条带 apply_actions 的模型消息里,第一个调用的结果是错误
参数解析、参数校验、查询、执行任何一处失败,结果都是 status="error" 的工具消息,都在这里判断,
不靠各处各自记状态,以后多一种失败也不会漏;同一轮被拒绝的后续调用不是第一个,不算
没有结果时也算失败:这一批没有执行,最后想做的修改没有写入草稿
"""
for position in range(len(messages) - 1, -1, -1):
message = messages[position]
if isinstance(message, AIMessage) and (call_id := _first_write(message)):
reply = next(
(
item
for item in messages[position + 1 :]
if isinstance(item, ToolMessage) and item.tool_call_id == call_id
),
None,
)
return reply is None or reply.status == "error"
return False
@tool
def apply_actions(
actions: Annotated[
list[AgentAction],
# 空的一批会清掉之前成功的结果;参数校验失败不改状态,之前的结果保留
Field(min_length=1, description="至少一个动作;不需要修改时不要调用,直接回复"),
# 不限制至少一个:以前整批替换时空的一批会清掉之前的结果,增量执行下空的一批只是没有变化,
# 按普通的无变化回复,比参数校验的英文报错更能让模型知道该结束了
Field(description="这一批要执行的动作;不需要修改时不要调用,直接回复"),
],
runtime: ToolRuntime[ScreenAgentContext],
) -> Command:
"""提交本次要对大屏做的全部修改
"""把一批修改写进这次的草稿;回复 done 时草稿交付给用户,不需要再提交确认
每次调用都是完整的一批,从原始大屏开始按顺序模拟执行,不在上一次调用的结果上累加
成功时返回执行后的画布和各组件布局,用它核对结果,不对再重新提交整批
失败时整批不生效,返回带动作序号的错误,修正后重新提交整批;以最后一次调用的结果为准
第一次提交从原始大屏开始,之后每一批都在草稿上继续修改;回复 unable 或 clarify 时草稿整个丢弃
成功时返回这一批执行后的画布和各组件布局,用它核对结果;结果不对再提交一批修正,已经写入的动作不用重复提交
一批里任何一个动作失败,这一批都不写入草稿,之前写入的保留;按带序号的错误修正这一批后再提交
同一轮只执行第一次调用,同一轮的 get_nodes 也读不到这次的结果;看到执行结果后再调用 AgentReply
"""
if not _is_first_submission(runtime):
# 被拒绝的调用不写任何状态字段,不会覆盖第一次提交的结果
return _reply(
runtime,
"同一轮只执行第一次 apply_actions,这一批没有执行;需要的话在下一轮根据执行结果再提交",
error=True,
)
before = current_screen(runtime)
misplaced = misplaced_props(actions, runtime.context.catalog)
# 写错层级的属性不生效,失败和成功时都说明;它常是 encode 对不上的真正原因
hint = (
"\n新增组件写了物料没有的属性,前端不会使用:"
+ ";".join(misplaced)
+ ";检查是不是写错了层级"
if misplaced
else ""
)
try:
actions, queries = fill_queries(actions, runtime.context.data)
completed, after = run_batch(
runtime.context.screen, runtime.context.catalog, actions
)
problems = encode_problems(after, {item.node_id for item in queries})
completed, after = run_batch(before, runtime.context.catalog, actions)
# 这一批改过数据或映射的组件都核对,不论数据来自这一批的 $query、之前的提交还是直接写入
problems = encode_problems(after, rebound_nodes(before, after))
except BatchError as error:
problems = [str(error)]
if problems:
message = "执行失败,整批未生效:\n" + "\n".join(
# 失败只体现在工具消息上,由 last_write_failed 判断,不写状态字段
message = "执行失败,这一批未写入草稿,之前写入的修改保留:\n" + "\n".join(
f"- {item}" for item in problems
)
return Command(
update={
"actions": None,
"apply_error": message,
"messages": [
ToolMessage(
message, tool_call_id=runtime.tool_call_id, status="error"
)
],
}
)
# 和上一次成功的提交完全一样:结果早已生效,再提交不会有变化,直接告诉模型结束,不再空转
if completed == runtime.state.get("actions"):
return Command(
update={
"messages": [
ToolMessage(
"这一批的结果和上一次提交完全相同,已经生效,不要重复提交;"
"任务完成就调用 AgentReply 回复用户,没完成就改成不同的内容",
tool_call_id=runtime.tool_call_id,
)
],
}
)
return _reply(runtime, message + hint, error=True)
undeclared = frozenset(
action.node.id
for action in actions
if isinstance(action, AddAction)
and "sample_data" not in action.model_fields_set
)
return Command(
update={
"actions": completed,
"apply_error": None,
"messages": [
ToolMessage(
_result_text(
runtime.context.screen, after, completed, undeclared, queries
),
tool_call_id=runtime.tool_call_id,
)
],
}
log = runtime.state.get("actions", [])
delivered, relabeled = _relabel_real_data(
[*log, *completed], _replaced_sources(actions, queries)
)
# 按交付结果判断有没有变化,不比较参数:同样的 move 再执行一次会继续移动,同样的 update 再执行一次没有变化
# 交付结果除了大屏还有只在动作上的示例数据标记,只比大屏会把"重新 add 改 sample_data"当成没变化而丢掉
unchanged = after == before and _sample_marks(delivered) == _sample_marks(log)
# 这次运行之前已有成功提交时,没有变化多半是在重复自己的修改;还没有时说明原始大屏本来就是这样
text = _result_text(
before,
after,
delivered[len(log) :],
undeclared,
queries,
unchanged,
repeated=bool(log),
relabeled=relabeled,
)
if unchanged:
return _reply(runtime, text + hint)
return _reply(runtime, text + hint, working_screen=after, actions=delivered)
def _replaced_sources(
actions: Sequence[AgentAction], queries: Sequence[EmbeddedQuery]
) -> set[str]:
"""这一批用 update 把整个 dataset.source 换成 $query 结果的组件;只替换其中一部分时不算"""
return {
item.node_id
for item in queries
if item.path == SOURCE_PATH and isinstance(actions[item.index], UpdateAction)
}
def _relabel_real_data(
actions: list[Action], node_ids: set[str]
) -> tuple[list[Action], list[str]]:
"""数据整体换成查询结果的组件,把它最近一次 add 的示例数据标记改为 false
标记只在 add 动作上、不影响大屏,改它不影响"交付记录依次执行得到草稿";
原始大屏上的组件没有 add,跨请求的标记要等大屏和前端契约支持
"""
result = list(actions)
relabeled = []
for node_id in sorted(node_ids):
index = next(
(
position
for position in range(len(result) - 1, -1, -1)
if isinstance(result[position], AddAction)
and result[position].node.id == node_id
),
None,
)
if index is not None and result[index].sample_data:
result[index] = result[index].model_copy(update={"sample_data": False})
relabeled.append(node_id)
return result, relabeled
def _sample_marks(actions: Sequence[Action]) -> dict[str, bool]:
"""按交付记录,当前每个新增组件是否标为示例数据;这个标记只在 add 动作上,不在大屏里"""
marks: dict[str, bool] = {}
for action in actions:
if isinstance(action, AddAction):
marks[action.node.id] = action.sample_data
elif isinstance(action, DeleteAction):
marks.pop(action.target_id, None)
return marks