fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入
澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
51afebb394
commit
b81d200eca
31 files changed
+1459
-592
No files matched your search
@@ -531,6 +531,16 @@ Built-in summary and action-plan completion also preserves a visible report
|
||||
answer when the model misses its final `write_file` call: the router writes
|
||||
that exact answer as the required Markdown artifact in the owning thread's
|
||||
`outputs/` directory, so users receive both chat text and a downloadable file.
|
||||
**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` +
|
||||
`action-plan-subtasks.json`). Tests: `tests/test_position_artifact_cap.py`.
|
||||
|
||||
### 岗位 (Position) Subscription System
|
||||
|
||||
|
||||
@@ -659,7 +659,8 @@ def _build_node_context_text(
|
||||
lines.extend(
|
||||
[
|
||||
"- 本席位最终只需交付一份 Markdown 报告文档。",
|
||||
"- 请在本轮或最终回复中创建 `/mnt/user-data/outputs/` 下的 `.md` 文件,并调用 `present_files` 呈现该文件。",
|
||||
"- 请在本轮用 `write_file` **只写一次** `/mnt/user-data/outputs/` 下的单个 `.md` 文件,然后调用 `present_files` 呈现该文件并简短收尾。",
|
||||
"- **禁止**在同一轮再调用 `write_file` 生成第二份(或内容相同的)产物;写完一份后立即结束文件写入。",
|
||||
"- 只有检测到该席位产出的 `.md` 文件后,系统才会把当前席位标记为已完成;仅有文字回答不会完成节点。",
|
||||
"- 报告内容应直接围绕当前岗位职责,吸收任务信息、任务意图与上游席位产物。",
|
||||
]
|
||||
@@ -1108,7 +1109,8 @@ async def _build_builtin_prompt(
|
||||
if kind == "summary":
|
||||
instructions = [
|
||||
"请基于以下岗位会商材料,生成或按用户要求修订《岗位会商方案总结报告》。",
|
||||
"必须使用 write_file 一次性写入 `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
|
||||
"必须使用 write_file **只写一次** `/mnt/user-data/outputs/岗位会商方案总结报告.md`,随后在对话中输出完整报告正文。",
|
||||
"**禁止**再次 write_file 生成第二份(或相同)报告;写完后 present_files 并简短收尾即可。",
|
||||
"报告要覆盖任务目标、岗位关键结论、综合分析、方案建议、风险与执行路径;不要简单拼接原文。",
|
||||
]
|
||||
else:
|
||||
|
||||
@@ -703,11 +703,12 @@ async def search_threads(body: ThreadSearchRequest, request: Request) -> list[Th
|
||||
from app.gateway.deps import get_thread_store
|
||||
|
||||
repo = get_thread_store(request)
|
||||
# System threads (scheduler runs, roundtable workers, …) carry
|
||||
# ``metadata.system=true`` and are machine-created — hide them from the
|
||||
# default conversation list so they don't crowd out real chats. A caller
|
||||
# that explicitly filters on ``thread_type``/``system`` (e.g. the studio
|
||||
# notebook list) opts out of the exclusion.
|
||||
# System threads (scheduler runs, roundtable workers, report-structure /
|
||||
# writing setup wizards, …) carry ``metadata.system=true`` and/or a known
|
||||
# ``thread_type`` — hide them from the default conversation list so they
|
||||
# don't crowd out real chats. A caller that explicitly filters on
|
||||
# ``thread_type``/``system`` (e.g. the studio notebook list) opts out of
|
||||
# the exclusion.
|
||||
explicit_system_filter = bool(body.metadata) and ("thread_type" in body.metadata or "system" in body.metadata)
|
||||
rows = await repo.search(
|
||||
metadata=body.metadata or None,
|
||||
|
||||
@@ -363,6 +363,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
||||
"task_external_context",
|
||||
# 岗位会商下游席位按需读取前序席位完整交付时使用的运行期数据。
|
||||
"roundtable_peer_deliveries",
|
||||
# 岗位会商席位 / 方案总结:每轮 outputs 下 write_file 上限(行动规划不传此键)。
|
||||
"position_artifact_cap",
|
||||
# 上下文压缩开关:由聊天框下方的按钮控制(默认关闭),透传到 run config 后
|
||||
# 在 _build_middlewares 里决定是否挂载 SummarizationMiddleware。
|
||||
"summarization_enabled",
|
||||
|
||||
@@ -368,6 +368,13 @@ def _build_middlewares(
|
||||
|
||||
middlewares.append(SkillReviewMiddleware())
|
||||
|
||||
# PositionArtifactCapMiddleware — position-roundtable seats (and summary)
|
||||
# may only emit N outputs write_file calls per user turn. Action-plan skips
|
||||
# the context flag so it can still write report + JSON.
|
||||
from deerflow.agents.middlewares.position_artifact_cap_middleware import PositionArtifactCapMiddleware
|
||||
|
||||
middlewares.append(PositionArtifactCapMiddleware())
|
||||
|
||||
# LoopDetectionMiddleware — detect and break repetitive tool call loops
|
||||
middlewares.append(LoopDetectionMiddleware())
|
||||
|
||||
|
||||
+84
-76
@@ -11,7 +11,6 @@ from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
||||
from langchain_core.messages import AIMessage, ToolMessage
|
||||
from langgraph.graph import END
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.types import Command
|
||||
|
||||
@@ -155,47 +154,30 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
"allow_multiple": resolve_allow_multiple(args),
|
||||
}
|
||||
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> Command:
|
||||
"""Handle clarification request and return command to interrupt execution.
|
||||
def _parse_tool_args(self, tool_call: Any) -> dict[str, Any]:
|
||||
args = self._tool_field(tool_call, "args") or {}
|
||||
if isinstance(args, dict):
|
||||
return args
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
parsed = json.loads(args)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
return {}
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
def _handle_clarification(self, request: ToolCallRequest) -> ToolMessage:
|
||||
"""Return the clarification ToolMessage without Command(goto=END).
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
Stopping the turn is ``after_model`` + ``jump_to=end``. Returning a
|
||||
Command here used to mix with parallel tool results and loop the graph.
|
||||
If the tools node still runs (jump_to not honored), a ToolMessage with
|
||||
the same stable id replaces rather than appends.
|
||||
"""
|
||||
# Extract clarification arguments
|
||||
args = request.tool_call.get("args", {})
|
||||
question = args.get("question", "")
|
||||
|
||||
logger.info("Intercepted clarification request")
|
||||
logger.debug("Clarification question: %s", question)
|
||||
|
||||
# Format the clarification message
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
|
||||
# Get the tool call ID
|
||||
tool_call_id = request.tool_call.get("id", "")
|
||||
|
||||
# Create a ToolMessage with the formatted question
|
||||
# This will be added to the message history
|
||||
tool_message = ToolMessage(
|
||||
id=self._stable_message_id(tool_call_id, formatted_message),
|
||||
content=formatted_message,
|
||||
tool_call_id=tool_call_id,
|
||||
name="ask_clarification",
|
||||
additional_kwargs={"clarification": self._build_payload(args)},
|
||||
)
|
||||
|
||||
# Return a Command that:
|
||||
# 1. Adds the formatted tool message
|
||||
# 2. Interrupts execution by going to __end__
|
||||
# Note: We don't add an extra AIMessage here - the frontend will detect
|
||||
# and display ask_clarification tool messages directly
|
||||
return Command(
|
||||
update={"messages": [tool_message]},
|
||||
goto=END,
|
||||
)
|
||||
args = self._parse_tool_args(request.tool_call)
|
||||
tool_call_id = str(self._tool_field(request.tool_call, "id") or "")
|
||||
logger.info("Intercepted clarification request in wrap_tool_call")
|
||||
return self._clarification_tool_message(tool_call_id, args)
|
||||
|
||||
def _tool_field(self, tool_call: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(tool_call, dict):
|
||||
@@ -205,6 +187,21 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
return getter(key, default)
|
||||
return getattr(tool_call, key, default)
|
||||
|
||||
def _tool_name(self, tool_call: Any) -> str:
|
||||
"""Normalize tool names from dict / object / OpenAI-style nested function."""
|
||||
raw = self._tool_field(tool_call, "name")
|
||||
if raw is None and isinstance(tool_call, dict):
|
||||
fn = tool_call.get("function")
|
||||
if isinstance(fn, dict):
|
||||
raw = fn.get("name")
|
||||
name = str(raw or "").strip()
|
||||
if "." in name:
|
||||
name = name.rsplit(".", 1)[-1]
|
||||
return name
|
||||
|
||||
def _is_clarification_call(self, tool_call: Any) -> bool:
|
||||
return self._tool_name(tool_call).casefold() == "ask_clarification"
|
||||
|
||||
def _clarification_tool_message(self, tool_call_id: str, args: dict[str, Any]) -> ToolMessage:
|
||||
formatted_message = self._format_clarification_message(args)
|
||||
return ToolMessage(
|
||||
@@ -218,6 +215,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
def _after_model_intercept(self, last: AIMessage) -> dict[str, Any] | None:
|
||||
"""Stop the run as soon as the model emits ask_clarification.
|
||||
|
||||
Applies to **all** lead-agent chats (通用问答 / 智能体 / 写作配置 / 报告结构…)。
|
||||
|
||||
``wrap_tool_call`` + ``Command(goto=END)`` is unreliable with parallel
|
||||
tool calls (weak models often emit two clarifications, or clarification
|
||||
plus another tool): the Command mixes with other tool results and the
|
||||
@@ -225,20 +224,16 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
as "the user still hasn't answered" and keeps generating.
|
||||
"""
|
||||
tool_calls = list(last.tool_calls or [])
|
||||
clarification_calls = [
|
||||
tc for tc in tool_calls if self._tool_field(tc, "name") == "ask_clarification"
|
||||
]
|
||||
clarification_calls = [tc for tc in tool_calls if self._is_clarification_call(tc)]
|
||||
if not clarification_calls:
|
||||
return None
|
||||
|
||||
messages: list[ToolMessage] = []
|
||||
for tc in tool_calls:
|
||||
name = str(self._tool_field(tc, "name") or "")
|
||||
name = self._tool_name(tc) or "unknown"
|
||||
tool_call_id = str(self._tool_field(tc, "id") or "")
|
||||
args = self._tool_field(tc, "args") or {}
|
||||
if not isinstance(args, dict):
|
||||
args = {}
|
||||
if name == "ask_clarification":
|
||||
args = self._parse_tool_args(tc)
|
||||
if self._is_clarification_call(tc):
|
||||
messages.append(self._clarification_tool_message(tool_call_id, args))
|
||||
else:
|
||||
messages.append(
|
||||
@@ -246,7 +241,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
id=f"clarification-skip:{tool_call_id or name}",
|
||||
content="已暂停:需要先等待用户回答澄清问题,本工具未执行。",
|
||||
tool_call_id=tool_call_id,
|
||||
name=name or "unknown",
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -280,27 +275,50 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
) -> dict[str, Any] | None:
|
||||
return self.after_model(state, runtime)
|
||||
|
||||
@override
|
||||
def _stub_sibling_tool(self, request: ToolCallRequest) -> ToolMessage:
|
||||
name = self._tool_name(request.tool_call) or "unknown"
|
||||
tool_call_id = str(self._tool_field(request.tool_call, "id") or "")
|
||||
return ToolMessage(
|
||||
id=f"clarification-skip:{tool_call_id or name}",
|
||||
content="已暂停:需要先等待用户回答澄清问题,本工具未执行。",
|
||||
tool_call_id=tool_call_id,
|
||||
name=name,
|
||||
)
|
||||
|
||||
def _request_has_sibling_clarification(self, request: ToolCallRequest) -> bool:
|
||||
state = getattr(request, "state", None)
|
||||
messages: Any = None
|
||||
if isinstance(state, dict):
|
||||
messages = state.get("messages")
|
||||
elif state is not None:
|
||||
getter = getattr(state, "get", None)
|
||||
if callable(getter):
|
||||
messages = getter("messages")
|
||||
else:
|
||||
messages = getattr(state, "messages", None)
|
||||
if not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
tool_calls = getattr(last, "tool_calls", None) or []
|
||||
return any(self._is_clarification_call(tc) for tc in tool_calls)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (sync version).
|
||||
"""Complete clarification tool calls; do not Command(goto=END).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
Turn-stop is ``after_model`` + ``jump_to=end``. If the tools node still
|
||||
runs, return a ToolMessage so parallel tools do not mix Commands.
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
if self._is_clarification_call(request.tool_call):
|
||||
return self._handle_clarification(request)
|
||||
if self._request_has_sibling_clarification(request):
|
||||
return self._stub_sibling_tool(request)
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
@@ -308,18 +326,8 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
"""Intercept ask_clarification tool calls and interrupt execution (async version).
|
||||
|
||||
Args:
|
||||
request: Tool call request
|
||||
handler: Original tool execution handler (async)
|
||||
|
||||
Returns:
|
||||
Command that interrupts execution with the formatted clarification message
|
||||
"""
|
||||
# Check if this is an ask_clarification tool call
|
||||
if request.tool_call.get("name") != "ask_clarification":
|
||||
# Not a clarification call, execute normally
|
||||
return await handler(request)
|
||||
|
||||
return self._handle_clarification(request)
|
||||
if self._is_clarification_call(request.tool_call):
|
||||
return self._handle_clarification(request)
|
||||
if self._request_has_sibling_clarification(request):
|
||||
return self._stub_sibling_tool(request)
|
||||
return await handler(request)
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
"""Cap ``write_file`` deliveries 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.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from typing import Any, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
from langchain.agents.middleware import AgentMiddleware, hook_config
|
||||
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langgraph.prebuilt.tool_node import ToolCallRequest
|
||||
from langgraph.runtime import Runtime
|
||||
from langgraph.types import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONTEXT_KEY = "position_artifact_cap"
|
||||
_OUTPUTS_PREFIX = "/mnt/user-data/outputs"
|
||||
_WRITE_TOOL = "write_file"
|
||||
|
||||
_REJECT_MSG = (
|
||||
"本席位本轮产物上限已达:outputs 下只允许写入一份交付文件。"
|
||||
"请勿再次调用 write_file;若尚未呈现文件请调用 present_files,"
|
||||
"然后用简短文字说明交付已完成。"
|
||||
)
|
||||
|
||||
_REMINDER = (
|
||||
"【系统提示】本席位本轮产物文件已成功写入 outputs。"
|
||||
"禁止再次调用 write_file 生成第二份(或相同)产物。"
|
||||
"请立即 present_files(如尚未呈现),并给出简短收尾回复后结束本轮。"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_args(raw_args: object) -> dict[str, Any]:
|
||||
if isinstance(raw_args, dict):
|
||||
return raw_args
|
||||
if isinstance(raw_args, str):
|
||||
try:
|
||||
parsed = json.loads(raw_args)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return {}
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
return {}
|
||||
|
||||
|
||||
def _tool_field(tool_call: Any, key: str, default: Any = None) -> Any:
|
||||
if isinstance(tool_call, dict):
|
||||
return tool_call.get(key, default)
|
||||
getter = getattr(tool_call, "get", None)
|
||||
if callable(getter):
|
||||
return getter(key, default)
|
||||
return getattr(tool_call, key, default)
|
||||
|
||||
|
||||
def _is_outputs_path(path: object) -> bool:
|
||||
text = str(path or "").strip().replace("\\", "/")
|
||||
if not text:
|
||||
return False
|
||||
if text == _OUTPUTS_PREFIX or text.startswith(f"{_OUTPUTS_PREFIX}/"):
|
||||
return True
|
||||
lowered = text.lstrip("./")
|
||||
return lowered == "outputs" or lowered.startswith("outputs/")
|
||||
|
||||
|
||||
def _tool_call_writes_outputs(tool_call: Any) -> bool:
|
||||
if _tool_field(tool_call, "name") != _WRITE_TOOL:
|
||||
return False
|
||||
args = _normalize_args(_tool_field(tool_call, "args"))
|
||||
return _is_outputs_path(args.get("path"))
|
||||
|
||||
|
||||
def _cap_from_mapping(mapping: object) -> int | None:
|
||||
if not isinstance(mapping, dict):
|
||||
return None
|
||||
raw = mapping.get(_CONTEXT_KEY)
|
||||
if raw is None or raw is False:
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value if value > 0 else None
|
||||
|
||||
|
||||
def _cap_from_runtime(runtime: object | None) -> int | None:
|
||||
if runtime is None:
|
||||
return None
|
||||
context = getattr(runtime, "context", None)
|
||||
cap = _cap_from_mapping(context)
|
||||
if cap is not None:
|
||||
return cap
|
||||
config = getattr(runtime, "config", None)
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
for bucket_key in ("configurable", "metadata", "context"):
|
||||
cap = _cap_from_mapping(config.get(bucket_key))
|
||||
if cap is not None:
|
||||
return cap
|
||||
return None
|
||||
|
||||
|
||||
def _is_visible_human(message: Any) -> bool:
|
||||
if getattr(message, "type", None) != "human":
|
||||
return False
|
||||
kwargs = getattr(message, "additional_kwargs", None) or {}
|
||||
if kwargs.get("hide_from_ui"):
|
||||
return False
|
||||
name = getattr(message, "name", None)
|
||||
if name in {"loop_warning", "position_artifact_cap"}:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _current_turn_start(messages: list[Any]) -> int:
|
||||
for index in range(len(messages) - 1, -1, -1):
|
||||
if _is_visible_human(messages[index]):
|
||||
return index
|
||||
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()
|
||||
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":
|
||||
continue
|
||||
call_id = getattr(message, "tool_call_id", None)
|
||||
if not isinstance(call_id, str) or call_id not in outputs_ids:
|
||||
continue
|
||||
content = str(getattr(message, "content", "") or "").strip()
|
||||
if content == "OK" or (content and not content.startswith("Error")):
|
||||
success += 1
|
||||
return success
|
||||
|
||||
|
||||
def _kept_call_ids(tool_calls: list[Any]) -> set[str]:
|
||||
ids: set[str] = set()
|
||||
for tool_call in tool_calls:
|
||||
call_id = _tool_field(tool_call, "id")
|
||||
if isinstance(call_id, str) and call_id:
|
||||
ids.add(call_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _build_truncated_update(last_msg: Any, kept: list[Any]) -> dict[str, Any]:
|
||||
"""Replace structured tool_calls and keep raw provider metadata in sync."""
|
||||
update: dict[str, Any] = {"tool_calls": kept}
|
||||
additional_kwargs = dict(getattr(last_msg, "additional_kwargs", {}) or {})
|
||||
raw_calls = additional_kwargs.get("tool_calls")
|
||||
kept_ids = _kept_call_ids(kept)
|
||||
if isinstance(raw_calls, list) and raw_calls:
|
||||
if not kept:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
else:
|
||||
filtered = [item for item in raw_calls if isinstance(item, dict) and item.get("id") in kept_ids]
|
||||
if filtered:
|
||||
additional_kwargs["tool_calls"] = filtered
|
||||
else:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
elif not kept:
|
||||
additional_kwargs.pop("tool_calls", None)
|
||||
additional_kwargs.pop("function_call", None)
|
||||
update["additional_kwargs"] = additional_kwargs
|
||||
if not kept:
|
||||
response_metadata = deepcopy(getattr(last_msg, "response_metadata", {}) or {})
|
||||
if response_metadata.get("finish_reason") == "tool_calls":
|
||||
response_metadata["finish_reason"] = "stop"
|
||||
update["response_metadata"] = response_metadata
|
||||
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)
|
||||
if not current_id:
|
||||
return used
|
||||
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
|
||||
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
|
||||
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):
|
||||
used += 1
|
||||
return used
|
||||
|
||||
|
||||
class PositionArtifactCapMiddleware(AgentMiddleware[AgentState]):
|
||||
"""Enforce a per-turn outputs ``write_file`` budget for position roundtable."""
|
||||
|
||||
state_schema = AgentState
|
||||
|
||||
def _truncate_excess_writes(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
cap = _cap_from_runtime(runtime)
|
||||
if cap is None:
|
||||
return None
|
||||
|
||||
messages = list(state.get("messages") or [])
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
last_msg = messages[-1]
|
||||
if getattr(last_msg, "type", None) != "ai":
|
||||
return None
|
||||
|
||||
tool_calls = list(getattr(last_msg, "tool_calls", None) or [])
|
||||
if not tool_calls:
|
||||
return None
|
||||
|
||||
already = _successful_outputs_writes(messages[:-1], turn_start=_current_turn_start(messages))
|
||||
remaining = max(0, cap - already)
|
||||
|
||||
kept: list[Any] = []
|
||||
dropped = 0
|
||||
kept_outputs = 0
|
||||
for tool_call in tool_calls:
|
||||
if _tool_call_writes_outputs(tool_call):
|
||||
if kept_outputs >= remaining:
|
||||
dropped += 1
|
||||
continue
|
||||
kept_outputs += 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)",
|
||||
dropped,
|
||||
cap,
|
||||
already,
|
||||
)
|
||||
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.
|
||||
result["jump_to"] = "end"
|
||||
return result
|
||||
|
||||
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):
|
||||
return None
|
||||
|
||||
runtime = getattr(request, "runtime", None)
|
||||
cap = _cap_from_runtime(runtime)
|
||||
if cap is None:
|
||||
return None
|
||||
|
||||
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)
|
||||
if used < cap:
|
||||
return None
|
||||
|
||||
logger.warning(
|
||||
"Position artifact cap rejected write_file (cap=%s, used=%s, id=%s)",
|
||||
cap,
|
||||
used,
|
||||
current_id,
|
||||
)
|
||||
return ToolMessage(
|
||||
content=f"Error: {_REJECT_MSG}",
|
||||
tool_call_id=current_id,
|
||||
name=_WRITE_TOOL,
|
||||
)
|
||||
|
||||
def _maybe_remind(self, request: ModelRequest) -> ModelRequest:
|
||||
cap = _cap_from_runtime(getattr(request, "runtime", None))
|
||||
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:
|
||||
return request
|
||||
reminder = HumanMessage(
|
||||
content=_REMINDER,
|
||||
name="position_artifact_cap",
|
||||
additional_kwargs={"hide_from_ui": True, "lc_source": "position_artifact_cap"},
|
||||
)
|
||||
return request.override(messages=[*messages, reminder])
|
||||
|
||||
@hook_config(can_jump_to=["end"])
|
||||
@override
|
||||
def after_model(self, state: AgentState, runtime: Runtime) -> dict | None:
|
||||
return self._truncate_excess_writes(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)
|
||||
|
||||
@override
|
||||
def wrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], ToolMessage | Command],
|
||||
) -> ToolMessage | Command:
|
||||
rejected = self._reject_over_cap(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
return handler(request)
|
||||
|
||||
@override
|
||||
async def awrap_tool_call(
|
||||
self,
|
||||
request: ToolCallRequest,
|
||||
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
|
||||
) -> ToolMessage | Command:
|
||||
rejected = self._reject_over_cap(request)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
return await handler(request)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelCallResult:
|
||||
return handler(self._maybe_remind(request))
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelCallResult:
|
||||
return await handler(self._maybe_remind(request))
|
||||
+27
-3
@@ -15,9 +15,33 @@ three-state semantics (see :mod:`deerflow.runtime.user_context`):
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
from typing import Any
|
||||
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel
|
||||
|
||||
# Configuration wizards / machine workers that must not appear in the default
|
||||
# conversation list. Prefer ``metadata.system=true`` on create; this set also
|
||||
# covers older rows that only set ``thread_type``.
|
||||
EXCLUDED_SYSTEM_THREAD_TYPES = frozenset(
|
||||
{
|
||||
"scheduler",
|
||||
"roundtable",
|
||||
"notebook",
|
||||
"deep_research",
|
||||
"writing_setup",
|
||||
"report_structure_setup",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def is_excluded_system_thread(metadata: dict[str, Any] | None) -> bool:
|
||||
"""Return True when a thread should be hidden from user conversation lists."""
|
||||
md = metadata or {}
|
||||
if md.get("system"):
|
||||
return True
|
||||
thread_type = md.get("thread_type")
|
||||
return isinstance(thread_type, str) and thread_type in EXCLUDED_SYSTEM_THREAD_TYPES
|
||||
|
||||
|
||||
class ThreadMetaStore(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
@@ -56,9 +80,9 @@ class ThreadMetaStore(abc.ABC):
|
||||
) -> list[dict]:
|
||||
"""Search threads ordered by ``updated_at`` desc.
|
||||
|
||||
``exclude_system=True`` drops threads whose ``metadata.system`` is
|
||||
truthy (scheduler runs, roundtable workers, …) so user-facing
|
||||
conversation lists are not crowded out by machine-created threads.
|
||||
``exclude_system=True`` drops machine / wizard threads (``metadata.system``
|
||||
or known ``thread_type`` values such as scheduler / report_structure_setup)
|
||||
so user-facing conversation lists are not crowded out.
|
||||
``query`` is a case-insensitive substring match on the thread title
|
||||
(``display_name``). ``limit``/``offset`` apply to the *filtered*
|
||||
result set.
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@ from typing import Any
|
||||
|
||||
from langgraph.store.base import BaseStore
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
from deerflow.utils.time import coerce_iso, now_iso
|
||||
|
||||
@@ -107,7 +107,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
# until the requested window is full so ``limit``/``offset`` refer to
|
||||
# filtered rows.
|
||||
def _matches(record: dict) -> bool:
|
||||
if exclude_system and (record.get("metadata") or {}).get("system"):
|
||||
if exclude_system and is_excluded_system_thread(record.get("metadata")):
|
||||
return False
|
||||
if normalized_query and normalized_query not in (record.get("display_name") or "").lower():
|
||||
return False
|
||||
@@ -169,7 +169,7 @@ class MemoryThreadMetaStore(ThreadMetaStore):
|
||||
break
|
||||
for item in items:
|
||||
record = self._item_to_dict(item)
|
||||
if exclude_system and (record.get("metadata") or {}).get("system"):
|
||||
if exclude_system and is_excluded_system_thread(record.get("metadata")):
|
||||
continue
|
||||
if normalized_query and normalized_query not in (record.get("display_name") or "").lower():
|
||||
continue
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@ from typing import Any
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore
|
||||
from deerflow.persistence.thread_meta.base import ThreadMetaStore, is_excluded_system_thread
|
||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id
|
||||
|
||||
@@ -166,7 +166,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
md = row.get("metadata") or {}
|
||||
if metadata and not all(md.get(k) == v for k, v in metadata.items()):
|
||||
return False
|
||||
if exclude_system and md.get("system"):
|
||||
if exclude_system and is_excluded_system_thread(md):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -233,7 +233,7 @@ class ThreadMetaRepository(ThreadMetaStore):
|
||||
md = metadata_json or {}
|
||||
if metadata and not all(md.get(k) == v for k, v in metadata.items()):
|
||||
continue
|
||||
if exclude_system and md.get("system"):
|
||||
if exclude_system and is_excluded_system_thread(md):
|
||||
continue
|
||||
total += 1
|
||||
if len(rows) < batch_size:
|
||||
|
||||
Reference in new issue
Block a user