fix: 岗位会商本轮只允许 present_files 一次,避免重复产物卡
弱模型写完报告后仍会再调用一次 present_files,前端会画出两张相同卡片。上限同时覆盖呈现,已呈现则只催收尾。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
b81d200eca
commit
541bc3280f
5 files changed
+443
-75
No files matched your search
+7
-5
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
+153
-61
@@ -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
|
||||
Reference in new issue
Block a user