"""agent 分析工具(docs/design/analysis-round/00-总方案.md §3.2):参数、方法说明、查询容器、台账、编号、下钻、次数上限、 遮罩列、续跑。 算子(app.analysis.run)换成按请求交回假结论的替身(tests/fixtures/analysis_ops.FakeOps),断言入口交给算子的东西、算子 交回后入口做的事;五个工具接真算子的端到端见 tests/test_analysis_e2e.py。模型换成脚本(MockChatModel._decide),不调用任何 模型。库是虚构的零售库 shop。 """ from __future__ import annotations import asyncio import json import sqlite3 import pytest from langchain_core.messages import AIMessage, SystemMessage, ToolMessage from sqlalchemy import select from app.analysis import contract from app.analysis.contract import CallError from app.core import artifact_store from app.core.artifact_store import canonical_json, content_hash from app.db.base import SessionLocal from app.db.models import Artifact, Run, RunEvent from app.engine.runner import run_manager from app.tools.analysis import CALL_ERROR_HEAD, REFUSED_CODE from app.tools.builtin.analysis import ANALYSIS_METHOD_PROMPT, ARGS, METHOD_GUIDE, NO_SCOPE from tests.fixtures.analysis_ops import FakeOps TOOL = "db_query__shop" DECOMPOSE = contract.TOOL_NAMES["decompose"] BASELINE = contract.TOOL_NAMES["baseline"] SQL = "SELECT region, week, SUM(amount) AS gmv FROM orders GROUP BY region, week ORDER BY region, week" MASKED_SQL = "SELECT region, phone AS tel, SUM(amount) AS gmv FROM orders GROUP BY region, phone ORDER BY region" ROWS = [(1, "华东", "2026-W41", 820.0, "139-0000-0001"), (2, "华东", "2026-W42", 700.0, "139-0000-0001"), (3, "华南", "2026-W41", 700.0, "139-0000-0002"), (4, "华南", "2026-W42", 666.0, "139-0000-0002"), (5, "华北", "2026-W41", 600.0, "139-0000-0003"), (6, "华北", "2026-W42", 560.0, "139-0000-0003")] DECOMPOSE_ARGS = {"mode": "additive", "query": "Q1", "by": ["region"], "period": "week", "base": "2026-W41", "target": "2026-W42", "measure": "gmv", "label": "销售额", "hypothesis": "下降是不是集中在某个地区"} @pytest.fixture(autouse=True) async def engine_up(): await run_manager.setup() yield await run_manager.shutdown() async def _shop(tmp_path, scratch_source, options=None): """虚构零售库 shop(tests/conftest.py 的 scratch_source:同名的旧源连同数据目录、现查缓存先删掉,测试结束时再删)。""" path = tmp_path / "shop.db" db = sqlite3.connect(path) db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, week TEXT, amount REAL, phone TEXT);") db.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", ROWS) db.commit() db.close() return await scratch_source("shop", path, options=options) @pytest.fixture async def shop(tmp_path, scratch_source): return await _shop(tmp_path, scratch_source) class Script: """Agent(和协作成员)依次发 calls 里的工具调用,再给结论;seen 记下每次带工具的调用看到的消息。""" def __init__(self, monkeypatch, calls, *, final="销售额下降,主要来自华东。", route=None): from app.providers import mock_model self.seen: list[list] = [] self.plain: list[list] = [] routed = {"n": 0} def decide(model, messages): if model.response_format: routed["n"] += 1 if route is not None and routed["n"] == 1: return AIMessage(content=json.dumps(route, ensure_ascii=False)) return AIMessage(content=json.dumps({"assignments": [], "done": True, "reason": "完成"})) if not model.tools: self.plain.append(list(messages)) return AIMessage(content="收尾") self.seen.append(list(messages)) done = sum(isinstance(m, ToolMessage) for m in messages) if done < len(calls): name, args = calls[done] return AIMessage(content="", tool_calls=[{"name": name, "args": args, "id": f"call_{done}"}]) return AIMessage(content=final) monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) def node(nid, ntype, **config): return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} def agent_graph(tools=(TOOL, DECOMPOSE), approval="never", **cfg): nodes = [node("start", "input"), node("bot", "agent", prompt="销售额为什么下降", tools=list(tools), approval=approval, **cfg), node("out", "output", fields=[{"name": "answer", "value": "{{ nodes.bot.text }}"}])] return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]} async def wait(run_id, statuses=("succeeded", "failed")): for _ in range(400): await asyncio.sleep(0.05) async with SessionLocal() as session: row = await session.get(Run, run_id) if row.status in statuses and (row.status == "interrupted" or row.manifest_seq is not None): return row raise AssertionError(f"run {run_id} 没有结束:{row.status}") async def events(run_id, etype=None, node_id=None): async with SessionLocal() as session: q = select(RunEvent).where(RunEvent.run_id == run_id) if etype: q = q.where(RunEvent.type == etype) if node_id: q = q.where(RunEvent.node_id == node_id) return [e.data for e in (await session.execute(q.order_by(RunEvent.seq))).scalars()] async def run_graph(graph, **kw): run = await run_manager.start(graph=graph, input_payload={}, **kw) row = await wait(run.id) assert row.status == "succeeded", row.error return row def text(message): return message.content if isinstance(message.content, str) else json.dumps(message.content, ensure_ascii=False) def tool_messages(script): return [text(m) for m in script.seen[-1] if isinstance(m, ToolMessage)] async def ledger(run_id, node_id="bot"): [finished] = await events(run_id, "node.finished", node_id) return finished["evidence"] # -------------------------------------------------------------------------- # 工具定义:参数只有「算什么」,五个工具共用一份方法说明 # -------------------------------------------------------------------------- def test_the_five_tools_take_no_thresholds_and_share_one_method(): from app.capabilities.registry import is_platform_query from app.tools.registry import all_specs specs = all_specs() for op, name in contract.TOOL_NAMES.items(): spec = specs[name] props = spec.args_schema.model_json_schema()["properties"] assert not set(props) & set(contract.DEFAULTS), f"{name} 的参数里有阈值:{set(props) & set(contract.DEFAULTS)}" assert spec.args_schema is ARGS[op] and spec.category == "分析" and spec.description # 说明开头是契约里这种分析的叫法(「趋势判断:……」),助手的工具清单、证据面板、评测写的是同一种说法 assert spec.description.startswith(f"{contract.OP_LABELS[op]}:"), spec.description[:12] assert spec.guide is METHOD_GUIDE and not spec.dangerous and not spec.writes_state # 不查库:能力登记、出处链都不把它的结果当查询快照 assert not is_platform_query(name) assert "query" in ARGS["decompose"].model_json_schema()["properties"] assert {"mode", "by", "period", "base", "target", "measure", "numerator", "denominator", "product", "factors", "headline", "parent", "label", "hypothesis"} <= set(ARGS["decompose"].model_fields) assert {"basis", "grain", "lag"} <= set(ARGS["baseline"].model_fields) # 参数名以算子为准(app/analysis 各算子文件头):差异检验的均值是 mean / sd / n 或 sum / sumsq / n,逐行是 value assert {"reference", "pairs", "mode", "mean", "sd", "sum", "sumsq", "n", "value"} <= set(ARGS["differ"].model_fields) assert {"base_query", "target_query", "measure", "grain"} <= set(ARGS["comparable"].model_fields) # 运行当天、目录事实、下钻层数由系统填,哪个工具的参数里都没有 assert not any({"today", "catalog", "depth"} & set(model.model_fields) for model in ARGS.values()) def test_unknown_parameters_and_thresholds_are_rejected_like_any_tool(): from app.tools.registry import ToolArgsError, prepare_args with pytest.raises(ToolArgsError) as found: prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "main_share": 0.3}) assert "main_share 不是该工具的参数" in str(found.value) with pytest.raises(ToolArgsError): prepare_args(ARGS["decompose"], {**DECOMPOSE_ARGS, "mode": "average"}) def test_chat_users_may_bind_the_analysis_tools(): from app.auth.chat_policy import CHAT_BUILTIN_TOOLS, CHAT_NODE_TYPES, chat_graph_problem assert set(contract.TOOL_NAMES.values()) <= CHAT_BUILTIN_TOOLS and contract.NODE_TYPE in CHAT_NODE_TYPES assert chat_graph_problem(agent_graph()) is None def test_a_tool_node_cannot_call_an_analysis_tool(): from app.engine.schema import GraphSpec, validate_graph graph = {"nodes": [node("start", "input"), node("t", "tool", tool=DECOMPOSE, args=DECOMPOSE_ARGS)], "edges": [{"source": "start", "target": "t"}]} [issue] = [i for i in validate_graph(GraphSpec.model_validate(graph)).issues if i.code == "analysis.tool_node"] assert issue.level == "error" and issue.field == "tool" and "分析卡" in issue.message async def test_calling_it_outside_an_agent_says_where_it_belongs(): from app.tools.registry import ToolContext, Versions, call_tool with pytest.raises(CallError) as found: await call_tool(DECOMPOSE, DECOMPOSE_ARGS, ToolContext(data_versions=Versions.CURRENT)) assert found.value.message == NO_SCOPE def test_input_snapshots_carry_the_payload_as_is(): """交给算子的一份输入:查询工具的 payload 原样(SQL 检查结果、「查询区间里这张表没有数据」、查询当时记下的遮罩,可比性 检查按它们判数据问题),加上出处和遮罩列;合并查询写明方言(在 SQLite 里执行)。""" from app.engine.merge_query import MERGE_SOURCE from app.tools.analysis import input_snapshot content = {"columns": ["region", "gmv"], "rows": [["华东", 1]], "sql": "SELECT …", "source": "shop", "column_types": {"region": "text", "gmv": "number"}, "truncated": False, "checks": [{"level": "error", "code": "fanout_sum"}], "no_data": [{"columns": ["gmv"]}], "mask_columns": ["phone"], "masked_at_query": ["tel"]} snap = input_snapshot(content, ref="Q1", artifact="a" * 64, node_id="bot", masked=["tel", "phone"]) assert snap == {**content, "ref": "Q1", "artifact": "a" * 64, "node_id": "bot", "masked": ["tel", "phone"]} merged = input_snapshot({**content, "source": MERGE_SOURCE}, ref="m", artifact="b" * 64, node_id="m") assert merged["dialect"] == "sqlite" and merged["masked"] == [] async def test_each_query_is_parsed_once_per_node_for_its_tables(monkeypatch): """agent 每次分析都按本节点全部查询认表(目录、剖析、探查都按表给,tools/analysis.context_of):同一条 SQL 一个节点里只 解析一次(AnalysisScope.tables_cache)。以前每次调用都把本节点的查询从头解析一遍。""" from app.engine import evidence from app.tools.analysis import AnalysisScope, SCOPE_KEY, input_snapshot from app.tools.builtin.analysis import DecomposeArgs, run_tool from app.tools.registry import ToolContext FakeOps(monkeypatch) parsed: list[str] = [] real = evidence.sql_tables monkeypatch.setattr(evidence, "sql_tables", lambda sql: parsed.append(sql) or real(sql)) scope = AnalysisScope(node_id="bot", snapshot={}) for alias, sql in (("Q1", SQL), ("Q2", MASKED_SQL)): content = {"columns": ["region", "week", "gmv"], "rows": [["华东", "2026-W41", 820.0]], "sql": sql, "source": "nowhere", "truncated": False} artifact = await artifact_store.put_json(content, kind="query_snapshot") scope.add_query(alias, input_snapshot(content, ref=alias, artifact=artifact, node_id="bot")) ctx = ToolContext(run_id="r", node_id="bot", extra={SCOPE_KEY: scope}) for _ in range(3): await run_tool("decompose", ctx, DecomposeArgs(**DECOMPOSE_ARGS)) assert parsed == [SQL, MASKED_SQL] and scope.tables_cache[SQL] == ("orders",) # -------------------------------------------------------------------------- # 方法说明:绑了分析工具的 Agent 注入一次,模型调用节点、没绑的 Agent 不注入 # -------------------------------------------------------------------------- async def test_the_method_is_injected_once_into_the_agent_system(monkeypatch, shop): FakeOps(monkeypatch) script = Script(monkeypatch, [(TOOL, {"sql": SQL})]) await run_graph(agent_graph(tools=(TOOL, DECOMPOSE, BASELINE), system="你是零售分析师。")) [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] assert system.content.startswith("你是零售分析师。") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1 script = Script(monkeypatch, [(TOOL, {"sql": SQL})]) await run_graph(agent_graph(tools=(TOOL,), system="你是零售分析师。")) [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] assert system.content == "你是零售分析师。", "没绑分析工具的 Agent,system 和以前一字不差" async def test_model_call_nodes_get_no_method(monkeypatch): from app.providers import mock_model seen: list = [] def decide(model, messages): seen.append(list(messages)) return AIMessage(content="好") monkeypatch.setattr(mock_model.MockChatModel, "_decide", decide) nodes = [node("start", "input"), node("llm", "llm", system="你是零售分析师。", prompt="说一句"), node("out", "output", fields=[{"name": "a", "value": "{{ nodes.llm.text }}"}])] await run_graph({"nodes": nodes, "edges": [{"source": "start", "target": "llm"}, {"source": "llm", "target": "out"}]}) assert ANALYSIS_METHOD_PROMPT not in json.dumps([text(m) for m in seen[0]], ensure_ascii=False) # -------------------------------------------------------------------------- # 一次分析:查询容器、台账、工件、编号、交回模型的那一段 # -------------------------------------------------------------------------- async def test_an_analysis_reads_this_nodes_query_and_is_recorded(monkeypatch, shop): ops = FakeOps(monkeypatch) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)]) row = await run_graph(agent_graph()) # 算子拿到的:请求只有「算什么」,Q1 解析成本节点那次查询的快照 [call] = ops.calls assert call["request"] == {"op": "decompose", "mode": "additive", "hypothesis": "下降是不是集中在某个地区", "params": {k: v for k, v in DECOMPOSE_ARGS.items() if k not in ("mode", "hypothesis")}} ends = await events(row.id, "tool.end", "bot") query_artifact = ends[0]["query_artifact"] snap = call["snapshots"]["Q1"] assert snap["ref"] == "Q1" and snap["artifact"] == query_artifact and snap["node_id"] == "bot" assert snap["source"] == "shop" and snap["sql"] == SQL and snap["truncated"] is False assert snap["columns"] == ["region", "week", "gmv"] and snap["rows"][0] == ["华东", "2026-W41", 820.0] # 查询工具的 payload 原样(工件库里那份),加上出处和遮罩列 assert snap["column_types"] == {"region": "text", "week": "text", "gmv": "number"} and snap["masked"] == [] assert snap["row_count"] == 6 and snap == {**artifact_store.load(query_artifact), "ref": "Q1", "artifact": query_artifact, "node_id": "bot", "masked": []} # 阈值:没有发起时的快照,现读设置(没存过就是缺省) assert call["settings"] == contract.DEFAULTS assert set(call["context"]) == {"today", "probe", "catalogs", "facts"} # 探查器按这次运行固定的数据版本解析数据源(analysis.make_prober),剖析事实是按表取的函数(probe.profile_facts) assert call["context"]["probe"].dialect("shop") == "sqlite" and callable(call["context"]["facts"]["shop"]) # 结论落成工件,tool.end 带着它;台账记一条 kind analysis(带 call_id),在那次查询之后 analysis_artifact = ends[1]["analysis_artifact"] stored = artifact_store.load(analysis_artifact) assert stored["op"] == "decompose" and stored["verdict"]["code"] == "driver" assert stored["hypothesis"] == "下降是不是集中在某个地区" entries = await ledger(row.id) assert [e["kind"] for e in entries] == ["tool", "query", "tool", "analysis"] analysis = entries[-1] assert analysis == contract.ledger_entry(stored, node_id="bot", exec_no=1, artifact=analysis_artifact, call_id="call_1") assert analysis["inputs"] == [query_artifact] async with SessionLocal() as session: kinds = {r.kind for r in (await session.execute( select(Artifact).where(Artifact.id == analysis_artifact))).scalars()} assert kinds == {contract.ARTIFACT_KIND} # 交回模型的:查询是「【证据 Q1】」,分析是「【分析 A1】」加判定语、关键数、结果表的前几段 first, second = tool_messages(script) assert first.startswith("【证据 Q1】\n") head, body = second.split("\n", 1) assert head == "【分析 A1】" reply = json.loads(body) assert reply["verdict"] == "driver" and reply["subjects"] == [["华东"]] and reply["status"] == "ok" assert reply["phrase"] == stored["phrase"] and reply["values"]["top_share"] == "0.6186" assert reply["table"]["rows"][0][0] == "华东" and "criterion" in reply # 调用记录里记着编号和结论工件 output = artifact_store.load((await events(row.id, "node.finished", "bot"))[0]["artifact"]) assert [c.get("alias") for c in output["tool_calls"]] == ["Q1", "A1"] assert output["tool_calls"][1]["analysis_artifact"] == analysis_artifact async def test_a_refused_analysis_is_still_a_recorded_conclusion(monkeypatch, shop): FakeOps(monkeypatch, statuses=["refused"]) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (BASELINE, {"query": "Q1", "period": "week", "measure": "gmv"})]) row = await run_graph(agent_graph(tools=(TOOL, BASELINE))) entries = await ledger(row.id) assert entries[-1]["kind"] == "analysis" and entries[-1]["status"] == "refused" and entries[-1]["verdict"] is None reply = json.loads(tool_messages(script)[1].split("\n", 1)[1]) assert reply["status"] == "refused" and reply["reason_code"] == "history_short" and "8 期" in reply["reason"] [warn] = [e for e in await events(row.id, "log", "bot") if e.get("code") == REFUSED_CODE] assert warn["level"] == "warn" and warn["reason_code"] == "history_short" async def test_call_errors_go_back_to_the_model_and_are_not_recorded(monkeypatch, shop): ops = FakeOps(monkeypatch, errors=[CallError("masked_column", "列「tel」设置了遮罩,不能当维度"), None]) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})]) row = await run_graph(agent_graph()) assert len(ops.calls) == 2 _, refused, done = tool_messages(script) assert refused == f"{CALL_ERROR_HEAD}列「tel」设置了遮罩,不能当维度" assert done.startswith("【分析 A1】"), "写错的那次不占编号" [error] = [e for e in await events(row.id, "tool.error", "bot")] assert error["call_error"] == "masked_column" and error["error"] == refused entries = await ledger(row.id) assert [e["call_id"] for e in entries if e["kind"] == "analysis"] == ["call_2"] async def test_drill_down_reads_this_nodes_earlier_conclusion(monkeypatch, shop): ops = FakeOps(monkeypatch) drill = {**DECOMPOSE_ARGS, "by": ["week"], "parent": {"of": "A1", "key": ["华东"]}, "hypothesis": "华东内部"} script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, drill)]) row = await run_graph(agent_graph()) first, second = ops.calls # parent 按本节点的编号取回上一层那份结论:就是存下的那一份(按内容寻址,哈希对得上) stored = artifact_store.load((await events(row.id, "tool.end", "bot"))[1]["analysis_artifact"]) assert second["parent"] == stored assert content_hash(canonical_json(second["parent"])) == (await events(row.id, "tool.end", "bot"))[1][ "analysis_artifact"] assert second["parents"]("A9") is None assert tool_messages(script)[2].startswith("【分析 A2】") async def test_the_number_of_analyses_per_node_is_capped(monkeypatch, shop): ops = FakeOps(monkeypatch) # 第二次换一个维度(同样的参数会被护栏当成重复调用、直接交回上次的结果,不走到分析) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["week"]})]) await run_graph(agent_graph(), options={contract.SETTINGS_KEY: {"max_calls": 1}}) assert len(ops.calls) == 1 capped = tool_messages(script)[2] import app.analysis as analysis_ops assert capped == CALL_ERROR_HEAD + analysis_ops.exhausted({"max_calls": 1}).message # 阈值来自发起时的快照 assert ops.calls[0]["settings"]["max_calls"] == 1 async def test_masked_columns_reach_the_operator(monkeypatch, tmp_path, scratch_source): await _shop(tmp_path, scratch_source, {"mask_columns": ["phone"]}) ops = FakeOps(monkeypatch) Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["tel"]})]) await run_graph(agent_graph()) masked = ops.calls[0]["snapshots"]["Q1"]["masked"] # 按 SQL 认出改了名字的遮罩列(phone AS tel)排在前面,数据源设的列名也在 assert masked[0] == "tel" and "phone" in masked async def test_mask_results_runs_hand_over_the_masked_snapshot(monkeypatch, tmp_path, scratch_source): await _shop(tmp_path, scratch_source, {"mask_columns": ["phone"]}) ops = FakeOps(monkeypatch) Script(monkeypatch, [(TOOL, {"sql": MASKED_SQL}), (DECOMPOSE, {**DECOMPOSE_ARGS, "by": ["region"]})]) await run_graph(agent_graph(), options={"mask_results": True, "read_only_data": True}) snap = ops.calls[0]["snapshots"]["Q1"] assert "tel" in snap["masked"] tel = snap["columns"].index("tel") assert {r[tel] for r in snap["rows"]} == {"已遮罩"}, "遮罩运行里算子拿到的是遮过的快照" # -------------------------------------------------------------------------- # 续跑:审批恢复时节点整个重放,分析不再执行、编号不错位 # -------------------------------------------------------------------------- async def test_replay_after_approvals_does_not_repeat_or_renumber(monkeypatch, shop): ops = FakeOps(monkeypatch) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS), (DECOMPOSE, {**DECOMPOSE_ARGS, "parent": {"of": "A1", "key": ["华东"]}})]) run = await run_manager.start(graph=agent_graph(approval="always"), input_payload={}) for _ in range(3): row = await wait(run.id, ("interrupted", "failed", "succeeded")) assert row.status == "interrupted", row.error await run_manager.resume(run.id, {"approved": True}) row = await wait(run.id, ("succeeded", "failed", "interrupted")) assert row.status == "succeeded", row.error assert len(ops.calls) == 2, "分析在重放时又执行了一次" ends = await events(row.id, "tool.end", "bot") assert len(ends) == 3 # 重放之后模型看到的编号还是同一套,下钻取到的是同一份结论 assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】", "【分析 A2】"] assert ops.calls[1]["parent"] == artifact_store.load(ends[1]["analysis_artifact"]) entries = [e for e in await ledger(row.id) if e["kind"] == "analysis"] assert [(e["call_id"], e["artifact"]) for e in entries] == [("call_1", ends[1]["analysis_artifact"]), ("call_2", ends[2]["analysis_artifact"])] # -------------------------------------------------------------------------- # 协作成员:同样注入方法说明、同样编号(协作节点不记证据台账) # -------------------------------------------------------------------------- async def test_a_team_member_gets_the_method_and_numbers(monkeypatch, shop): ops = FakeOps(monkeypatch) script = Script(monkeypatch, [(TOOL, {"sql": SQL}), (DECOMPOSE, DECOMPOSE_ARGS)], route={"assignments": [{"agent": "分析员", "instruction": "拆一下销售额的变化"}], "done": False}) nodes = [node("start", "input"), node("team", "supervisor", goal="销售额为什么下降", approval="never", max_rounds=2, agents=[ {"name": "分析员", "system": "你负责拆解", "tools": [TOOL, DECOMPOSE], "max_steps": 4}]), node("out", "output", fields=[{"name": "a", "value": "{{ nodes.team.text }}"}])] run = await run_manager.start(graph={"nodes": nodes, "edges": [{"source": "start", "target": "team"}, {"source": "team", "target": "out"}]}, input_payload={}) row = await wait(run.id) assert row.status == "succeeded", row.error [system] = [m for m in script.seen[0] if isinstance(m, SystemMessage)] assert system.content.startswith("你负责拆解") and system.content.count(ANALYSIS_METHOD_PROMPT) == 1 assert [m.split("\n", 1)[0] for m in tool_messages(script)] == ["【证据 Q1】", "【分析 A1】"] assert ops.calls[0]["snapshots"]["Q1"]["node_id"] == "team" [end] = [e for e in await events(row.id, "tool.end", "team") if e.get("analysis_artifact")] assert artifact_store.load(end["analysis_artifact"])["op"] == "decompose"