fix: 岗位会商本轮只允许 present_files 一次,避免重复产物卡

弱模型写完报告后仍会再调用一次 present_files,前端会画出两张相同卡片。上限同时覆盖呈现,已呈现则只催收尾。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-23 21:26:57 +08:00
1 parent b81d200eca
commit 541bc3280f
5 files changed
+443 -75

No files matched your search

+7 -5
View File
@@ -98,11 +98,13 @@ offline-backend-20260512/backend/tests/*
!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py
!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py
!offline-backend-20260512/backend/tests/test_deep_research_start_job.py
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
deploy
!offline-backend-20260512/backend/tests/test_summarization_human_message.py
!offline-backend-20260512/backend/tests/test_compact_menu_overrides.py
# Independent enterprise-research persistence, HTTP and race regressions.
!offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py
# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts).
!offline-backend-20260512/backend/tests/test_position_artifact_cap.py
deploy
.history
# Cloned third-party repos (local only)
+7 -6
View File
@@ -534,12 +534,13 @@ that exact answer as the required Markdown artifact in the owning thread's
**Per-turn outputs write cap (弱模型防重复产物)**: ordinary position-node chats
and the summary builtin send run-context `position_artifact_cap: 1` (whitelisted
in `services.merge_run_context_overrides`). `PositionArtifactCapMiddleware`
then keeps at most one successful `/mnt/user-data/outputs/` `write_file` per
user turn — truncates excess tool calls in the same model response (and syncs
raw provider `additional_kwargs.tool_calls`), rejects further writes after an
`OK` or an earlier sibling in the same batch, ends the turn when only excess
writes remain, and reminds the model to `present_files` / wrap up. **Action-plan
does not send the flag** (it must write report `.md` +
then keeps at most one successful `/mnt/user-data/outputs/` `write_file` **and**
one `present_files` per user turn — truncates excess tool calls in the same
model response (and syncs raw provider `additional_kwargs.tool_calls`), rejects
further writes/presents after an `OK` / `Successfully presented files` or an
earlier sibling in the same batch, ends the turn when only excess calls remain,
and reminds the model to present once (or wrap up if already presented).
**Action-plan does not send the flag** (it must write report `.md` +
`action-plan-subtasks.json`). Tests: `tests/test_position_artifact_cap.py`.
### 岗位 (Position) Subscription System
@@ -659,8 +659,8 @@ def _build_node_context_text(
lines.extend(
[
"- 本席位最终只需交付一份 Markdown 报告文档。",
"- 请在本轮用 `write_file` **只写一次** `/mnt/user-data/outputs/` 下的单个 `.md` 文件,然后调用 `present_files` 呈现该文件并简短收尾。",
"- **禁止**在同一轮再调用 `write_file` 生成第二份(或内容相同的)产物;写完一份后立即结束文件写入。",
"- 请在本轮用 `write_file` **只写一次** `/mnt/user-data/outputs/` 下的单个 `.md` 文件,然后调用 `present_files` **只呈现一次**该文件并简短收尾。",
"- **禁止**在同一轮再调用 `write_file` 生成第二份(或内容相同的)产物,也**禁止**对同一文件再次 `present_files`。",
"- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
"- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
]
@@ -1110,7 +1110,7 @@ async def _build_builtin_prompt(
instructions = [
"请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
"必须使用 write_file **只写一次** `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
"**禁止**再次 write_file 生成第二份(或相同)报告;写完后 present_files 并简短收尾即可。",
"**禁止**再次 write_file 生成第二份(或相同)报告;写完后 present_files **只调用一次**并简短收尾即可。",
"报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
]
else:
@@ -1,21 +1,25 @@
"""Cap ``write_file`` deliveries for position-roundtable seats.
"""Cap deliverable tools for position-roundtable seats.
Weak models (especially after a first successful Markdown write) often call
``write_file`` again with another copy of the same report and loop until the
recursion limit kills the run.
``write_file`` again with another copy of the same report, or call
``present_files`` twice for the same path, and loop until the recursion
limit kills the run. The UI then shows two identical "Successfully
presented files" cards even though only one file exists on disk.
When the run context carries ``position_artifact_cap`` (set by the position-
roundtable UI for ordinary seats and the summary agent — **not** for
action-plan, which must emit report + JSON), this middleware:
1. Truncates excess outputs ``write_file`` tool calls in a single model
response (keeps only the first *N*), including raw provider metadata.
2. Rejects further outputs ``write_file`` calls once *N* successful *or*
earlier in-batch writes already exist in the current user turn (so
parallel tool calls cannot race past the cap).
3. Ends the turn when the only remaining calls were excess writes.
4. Injects a brief hidden reminder before the next model call so the agent
wraps up with ``present_files`` / a short reply instead of rewriting.
1. Truncates excess outputs ``write_file`` **and** ``present_files`` calls in
a single model response (keeps only the first *N* of each), including
raw provider metadata.
2. Rejects further such calls once *N* successful *or* earlier in-batch
calls already exist in the current user turn (so parallel tool calls
cannot race past the cap).
3. Ends the turn when the only remaining calls were excess writes/presents.
4. Injects a brief hidden reminder before the next model call:
- write done, not yet presented → present once, then wrap up;
- write **and** present already done → wrap up, do **not** present again.
"""
from __future__ import annotations
@@ -39,17 +43,29 @@ logger = logging.getLogger(__name__)
_CONTEXT_KEY = "position_artifact_cap"
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
_WRITE_TOOL = "write_file"
_PRESENT_TOOL = "present_files"
_REJECT_MSG = (
_REJECT_WRITE = (
"本席位本轮产物上限已达:outputs 下只允许写入一份交付文件。"
"请勿再次调用 write_file;若尚未呈现文件请调用 present_files,"
"然后用简短文字说明交付已完成。"
)
_REMINDER = (
_REJECT_PRESENT = (
"本席位本轮产物已经呈现过一次,请勿再次调用 present_files。"
"用一两句话说明交付已完成即可,不要再调用任何文件工具。"
)
_REMINDER_NEED_PRESENT = (
"【系统提示】本席位本轮产物文件已成功写入 outputs。"
"禁止再次调用 write_file 生成第二份(或相同)产物。"
"请立即 present_files(如尚未呈现),并给出简短收尾回复后结束本轮。"
"请立即 present_files(仅此一次,如尚未呈现),并给出简短收尾回复后结束本轮。"
)
_REMINDER_DONE = (
"【系统提示】本席位本轮产物已写入 outputs 并且已经 present_files 呈现过。"
"禁止再次调用 write_file 或 present_files。"
"请只用一两句话收尾说明交付已完成,不要再调用任何工具。"
)
@@ -91,6 +107,17 @@ def _tool_call_writes_outputs(tool_call: Any) -> bool:
return _is_outputs_path(args.get("path"))
def _tool_call_is_present(tool_call: Any) -> bool:
return _tool_field(tool_call, "name") == _PRESENT_TOOL
def _is_successful_tool_result(content: str) -> bool:
text = content.strip()
if not text or text.startswith("Error"):
return False
return True
def _cap_from_mapping(mapping: object) -> int | None:
if not isinstance(mapping, dict):
return None
@@ -140,31 +167,50 @@ def _current_turn_start(messages: list[Any]) -> int:
return 0
def _successful_outputs_writes(messages: list[Any], *, turn_start: int) -> int:
"""Count successful outputs ``write_file`` calls in the current user turn."""
outputs_ids: set[str] = set()
def _collect_call_ids(messages: list[Any], *, turn_start: int, match: Callable[[Any], bool]) -> set[str]:
ids: set[str] = set()
for message in messages[turn_start:]:
if getattr(message, "type", None) != "ai":
continue
for tool_call in getattr(message, "tool_calls", None) or []:
if not match(tool_call):
continue
call_id = _tool_field(tool_call, "id")
if isinstance(call_id, str) and call_id:
ids.add(call_id)
return ids
def _count_successful_tools(
messages: list[Any],
*,
turn_start: int,
match: Callable[[Any], bool],
) -> int:
matching_ids = _collect_call_ids(messages, turn_start=turn_start, match=match)
success = 0
for message in messages[turn_start:]:
msg_type = getattr(message, "type", None)
if msg_type == "ai":
for tool_call in getattr(message, "tool_calls", None) or []:
if not _tool_call_writes_outputs(tool_call):
continue
call_id = _tool_field(tool_call, "id")
if isinstance(call_id, str) and call_id:
outputs_ids.add(call_id)
continue
if msg_type != "tool":
if getattr(message, "type", None) != "tool":
continue
call_id = getattr(message, "tool_call_id", None)
if not isinstance(call_id, str) or call_id not in outputs_ids:
if not isinstance(call_id, str) or call_id not in matching_ids:
continue
content = str(getattr(message, "content", "") or "").strip()
if content == "OK" or (content and not content.startswith("Error")):
content = str(getattr(message, "content", "") or "")
if _is_successful_tool_result(content):
success += 1
return success
def _successful_outputs_writes(messages: list[Any], *, turn_start: int) -> int:
"""Count successful outputs ``write_file`` calls in the current user turn."""
return _count_successful_tools(messages, turn_start=turn_start, match=_tool_call_writes_outputs)
def _successful_presents(messages: list[Any], *, turn_start: int) -> int:
"""Count successful ``present_files`` calls in the current user turn."""
return _count_successful_tools(messages, turn_start=turn_start, match=_tool_call_is_present)
def _kept_call_ids(tool_calls: list[Any]) -> set[str]:
ids: set[str] = set()
for tool_call in tool_calls:
@@ -203,36 +249,61 @@ def _build_truncated_update(last_msg: Any, kept: list[Any]) -> dict[str, Any]:
return update
def _outputs_budget_used(messages: list[Any], *, turn_start: int, current_id: str | None) -> int:
"""Successful writes this turn, plus earlier sibling writes in the same AI batch."""
used = _successful_outputs_writes(messages, turn_start=turn_start)
def _in_flight_siblings(
messages: list[Any],
*,
current_id: str | None,
match: Callable[[Any], bool],
) -> int:
"""Earlier same-batch tool calls that would consume the same budget."""
if not current_id:
return used
return 0
last_ai = None
for message in reversed(messages):
if getattr(message, "type", None) == "ai":
last_ai = message
break
if last_ai is None:
return used
return 0
batch = list(getattr(last_ai, "tool_calls", None) or [])
if current_id not in {str(_tool_field(tool_call, "id") or "") for tool_call in batch}:
return used
return 0
used = 0
for tool_call in batch:
call_id = str(_tool_field(tool_call, "id") or "")
if call_id == current_id:
break
if _tool_call_writes_outputs(tool_call):
if match(tool_call):
used += 1
return used
def _outputs_budget_used(messages: list[Any], *, turn_start: int, current_id: str | None) -> int:
return _successful_outputs_writes(messages, turn_start=turn_start) + _in_flight_siblings(
messages, current_id=current_id, match=_tool_call_writes_outputs
)
def _present_budget_used(messages: list[Any], *, turn_start: int, current_id: str | None) -> int:
return _successful_presents(messages, turn_start=turn_start) + _in_flight_siblings(
messages, current_id=current_id, match=_tool_call_is_present
)
def _reminder_for(*, writes_done: bool, presents_done: bool) -> str | None:
if not writes_done:
return None
if presents_done:
return _REMINDER_DONE
return _REMINDER_NEED_PRESENT
class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
"""Enforce a per-turn outputs ``write_file`` budget for position roundtable."""
"""Enforce a per-turn outputs write/present budget for position roundtable."""
state_schema = AgentState
def _truncate_excess_writes(self, state: AgentState, runtime: Runtime) -> dict | None:
def _truncate_excess(self, state: AgentState, runtime: Runtime) -> dict | None:
cap = _cap_from_runtime(runtime)
if cap is None:
return None
@@ -249,43 +320,52 @@ class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
if not tool_calls:
return None
already = _successful_outputs_writes(messages[:-1], turn_start=_current_turn_start(messages))
remaining = max(0, cap - already)
turn_start = _current_turn_start(messages)
prior = messages[:-1]
remaining_writes = max(0, cap - _successful_outputs_writes(prior, turn_start=turn_start))
remaining_presents = max(0, cap - _successful_presents(prior, turn_start=turn_start))
kept: list[Any] = []
dropped = 0
kept_outputs = 0
kept_writes = 0
kept_presents = 0
for tool_call in tool_calls:
if _tool_call_writes_outputs(tool_call):
if kept_outputs >= remaining:
if kept_writes >= remaining_writes:
dropped += 1
continue
kept_outputs += 1
kept_writes += 1
elif _tool_call_is_present(tool_call):
if kept_presents >= remaining_presents:
dropped += 1
continue
kept_presents += 1
kept.append(tool_call)
if dropped == 0:
return None
logger.warning(
"Position artifact cap truncated %s excess write_file call(s) (cap=%s, already=%s)",
"Position artifact cap truncated %s excess write_file/present_files call(s) "
"(cap=%s, remaining_writes=%s, remaining_presents=%s)",
dropped,
cap,
already,
remaining_writes,
remaining_presents,
)
updated = last_msg.model_copy(update=_build_truncated_update(last_msg, kept))
result: dict[str, Any] = {"messages": [updated]}
if not kept:
# No remaining tools — stop instead of looping back to the model
# which would just emit another copy of write_file.
# which would just emit another copy of write_file / present_files.
result["jump_to"] = "end"
return result
def _reject_over_cap(
self,
request: ToolCallRequest,
) -> ToolMessage | None:
def _reject_over_cap(self, request: ToolCallRequest) -> ToolMessage | None:
tool_call = getattr(request, "tool_call", None) or {}
if not _tool_call_writes_outputs(tool_call):
is_write = _tool_call_writes_outputs(tool_call)
is_present = _tool_call_is_present(tool_call)
if not is_write and not is_present:
return None
runtime = getattr(request, "runtime", None)
@@ -296,20 +376,29 @@ class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
state = getattr(request, "state", None) or {}
messages = list(state.get("messages") or []) if isinstance(state, dict) else []
current_id = str(_tool_field(tool_call, "id") or "")
used = _outputs_budget_used(messages, turn_start=_current_turn_start(messages), current_id=current_id or None)
turn_start = _current_turn_start(messages)
if is_write:
used = _outputs_budget_used(messages, turn_start=turn_start, current_id=current_id or None)
reject_text = _REJECT_WRITE
tool_name = _WRITE_TOOL
else:
used = _present_budget_used(messages, turn_start=turn_start, current_id=current_id or None)
reject_text = _REJECT_PRESENT
tool_name = _PRESENT_TOOL
if used < cap:
return None
logger.warning(
"Position artifact cap rejected write_file (cap=%s, used=%s, id=%s)",
"Position artifact cap rejected %s (cap=%s, used=%s, id=%s)",
tool_name,
cap,
used,
current_id,
)
return ToolMessage(
content=f"Error: {_REJECT_MSG}",
content=f"Error: {reject_text}",
tool_call_id=current_id,
name=_WRITE_TOOL,
name=tool_name,
)
def _maybe_remind(self, request: ModelRequest) -> ModelRequest:
@@ -317,11 +406,14 @@ class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
if cap is None:
return request
messages = list(getattr(request, "messages", None) or [])
already = _successful_outputs_writes(messages, turn_start=_current_turn_start(messages))
if already < cap:
turn_start = _current_turn_start(messages)
writes_done = _successful_outputs_writes(messages, turn_start=turn_start) >= cap
presents_done = _successful_presents(messages, turn_start=turn_start) >= cap
text = _reminder_for(writes_done=writes_done, presents_done=presents_done)
if text is None:
return request
reminder = HumanMessage(
content=_REMINDER,
content=text,
name="position_artifact_cap",
additional_kwargs={"hide_from_ui": True, "lc_source": "position_artifact_cap"},
)
@@ -330,12 +422,12 @@ class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
@hook_config(can_jump_to=["end"])
@override
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._truncate_excess_writes(state, runtime)
return self._truncate_excess(state, runtime)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(self, state: AgentState, runtime: Runtime) -> dict | None:
return self._truncate_excess_writes(state, runtime)
return self._truncate_excess(state, runtime)
@override
def wrap_tool_call(
@@ -0,0 +1,273 @@
"""Tests for PositionArtifactCapMiddleware."""
from __future__ import annotations
from types import SimpleNamespace
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from deerflow.agents.middlewares.position_artifact_cap_middleware import (
PositionArtifactCapMiddleware,
_reminder_for,
_successful_outputs_writes,
_successful_presents,
)
def _runtime(cap: int | None = 1) -> SimpleNamespace:
context: dict = {"thread_id": "t-pos"}
if cap is not None:
context["position_artifact_cap"] = cap
return SimpleNamespace(context=context, config={"configurable": dict(context)})
def _write_call(call_id: str, path: str = "/mnt/user-data/outputs/report.md") -> dict:
return {
"id": call_id,
"name": "write_file",
"args": {"path": path, "content": "# hello", "description": "deliver"},
}
def _present_call(call_id: str, path: str = "/mnt/user-data/outputs/report.md") -> dict:
return {
"id": call_id,
"name": "present_files",
"args": {"filepaths": [path]},
}
def test_successful_outputs_writes_counts_ok_in_current_turn_only() -> None:
messages = [
HumanMessage(content="上一轮"),
AIMessage(content="", tool_calls=[_write_call("old")]),
ToolMessage(content="OK", tool_call_id="old", name="write_file"),
HumanMessage(content="本轮请交付"),
AIMessage(content="", tool_calls=[_write_call("new")]),
ToolMessage(content="OK", tool_call_id="new", name="write_file"),
]
assert _successful_outputs_writes(messages, turn_start=3) == 1
def test_after_model_truncates_second_outputs_write_in_same_response() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付一份报告"),
AIMessage(
content="正在写入",
tool_calls=[
_write_call("w1"),
_write_call("w2", "/mnt/user-data/outputs/report-copy.md"),
_present_call("p1"),
],
),
]
}
update = mw.after_model(state, _runtime(1))
assert update is not None
tool_calls = update["messages"][0].tool_calls
assert [tc["id"] for tc in tool_calls] == ["w1", "p1"]
def test_after_model_strips_all_writes_once_cap_already_met() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_write_call("done")]),
ToolMessage(content="OK", tool_call_id="done", name="write_file"),
AIMessage(content="再写一份", tool_calls=[_write_call("again")]),
]
}
update = mw.after_model(state, _runtime(1))
assert update is not None
assert update["messages"][0].tool_calls == []
assert update.get("jump_to") == "end"
assert "产物上限" not in str(update["messages"][0].content)
assert "tool_calls" not in (update["messages"][0].additional_kwargs or {})
def test_after_model_noop_without_cap() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_write_call("w1"), _write_call("w2")]),
]
}
assert mw.after_model(state, _runtime(None)) is None
def test_wrap_tool_call_rejects_after_successful_delivery() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_write_call("done")]),
ToolMessage(content="OK", tool_call_id="done", name="write_file"),
]
}
request = SimpleNamespace(
tool_call=_write_call("again"),
runtime=_runtime(1),
state=state,
)
called = {"n": 0}
def handler(_request):
called["n"] += 1
return ToolMessage(content="OK", tool_call_id="again", name="write_file")
result = mw.wrap_tool_call(request, handler)
assert called["n"] == 0
assert isinstance(result, ToolMessage)
assert str(result.content).startswith("Error:")
def test_wrap_tool_call_allows_first_write() -> None:
mw = PositionArtifactCapMiddleware()
state = {"messages": [HumanMessage(content="请交付")]}
request = SimpleNamespace(
tool_call=_write_call("w1"),
runtime=_runtime(1),
state=state,
)
def handler(_request):
return ToolMessage(content="OK", tool_call_id="w1", name="write_file")
result = mw.wrap_tool_call(request, handler)
assert result.content == "OK"
def test_wrap_tool_call_rejects_parallel_second_write_in_same_batch() -> None:
"""Two write_file in one AIMessage can run concurrently; order in the list is the budget."""
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_write_call("w1"), _write_call("w2")]),
]
}
first = SimpleNamespace(tool_call=_write_call("w1"), runtime=_runtime(1), state=state)
second = SimpleNamespace(tool_call=_write_call("w2"), runtime=_runtime(1), state=state)
def handler(request):
return ToolMessage(content="OK", tool_call_id=request.tool_call["id"], name="write_file")
assert mw.wrap_tool_call(first, handler).content == "OK"
rejected = mw.wrap_tool_call(second, handler)
assert str(rejected.content).startswith("Error:")
def test_after_model_clears_provider_tool_call_metadata() -> None:
mw = PositionArtifactCapMiddleware()
extra = {
"tool_calls": [
{"id": "w1", "type": "function", "function": {"name": "write_file"}},
{"id": "w2", "type": "function", "function": {"name": "write_file"}},
]
}
ai = AIMessage(content="写两份", tool_calls=[_write_call("w1"), _write_call("w2")])
ai.additional_kwargs = extra
state = {"messages": [HumanMessage(content="请交付"), ai]}
update = mw.after_model(state, _runtime(1))
assert update is not None
kept = update["messages"][0]
assert [tc["id"] for tc in kept.tool_calls] == ["w1"]
assert [item["id"] for item in kept.additional_kwargs.get("tool_calls", [])] == ["w1"]
def test_action_plan_style_cap_two_allows_second_write() -> None:
"""Action-plan is exempt in the UI (no flag); if a cap of 2 were set it must allow both."""
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="生成行动规划"),
AIMessage(content="", tool_calls=[_write_call("md", "/mnt/user-data/outputs/行动规划报告.md")]),
ToolMessage(content="OK", tool_call_id="md", name="write_file"),
AIMessage(
content="",
tool_calls=[_write_call("json", "/mnt/user-data/outputs/action-plan-subtasks.json")],
),
]
}
assert mw.after_model(state, _runtime(2)) is None
def test_after_model_truncates_second_present_files_in_same_response() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(
content="",
tool_calls=[_write_call("w1"), _present_call("p1"), _present_call("p2")],
),
]
}
update = mw.after_model(state, _runtime(1))
assert update is not None
assert [tc["id"] for tc in update["messages"][0].tool_calls] == ["w1", "p1"]
def test_after_model_strips_second_present_after_first_succeeded() -> None:
"""Screenshot bug: write+present already done, weak model presents the same file again."""
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请评估"),
AIMessage(content="", tool_calls=[_write_call("w1"), _present_call("p1")]),
ToolMessage(content="OK", tool_call_id="w1", name="write_file"),
ToolMessage(content="Successfully presented files", tool_call_id="p1", name="present_files"),
AIMessage(content="评估报告已交付", tool_calls=[_present_call("p2")]),
]
}
update = mw.after_model(state, _runtime(1))
assert update is not None
assert update["messages"][0].tool_calls == []
assert update.get("jump_to") == "end"
def test_wrap_tool_call_rejects_second_present_files() -> None:
mw = PositionArtifactCapMiddleware()
state = {
"messages": [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_present_call("p1")]),
ToolMessage(content="Successfully presented files", tool_call_id="p1", name="present_files"),
]
}
request = SimpleNamespace(
tool_call=_present_call("p2"),
runtime=_runtime(1),
state=state,
)
called = {"n": 0}
def handler(_request):
called["n"] += 1
return ToolMessage(content="Successfully presented files", tool_call_id="p2", name="present_files")
result = mw.wrap_tool_call(request, handler)
assert called["n"] == 0
assert isinstance(result, ToolMessage)
assert "已经呈现" in str(result.content)
def test_reminder_asks_present_only_when_not_yet_presented() -> None:
assert "present_files" in (_reminder_for(writes_done=True, presents_done=False) or "")
done = _reminder_for(writes_done=True, presents_done=True) or ""
assert "已经 present_files" in done
assert "请立即 present_files" not in done
assert _reminder_for(writes_done=False, presents_done=False) is None
def test_successful_presents_counts_tool_result() -> None:
messages = [
HumanMessage(content="请交付"),
AIMessage(content="", tool_calls=[_present_call("p1")]),
ToolMessage(content="Successfully presented files", tool_call_id="p1", name="present_files"),
]
assert _successful_presents(messages, turn_start=0) == 1