fix: 协助卡停轮与提交防 409,隐藏配置向导会话,并限制岗位会商产物重复写入

澄清中间件改为 ToolMessage + after_model 停轮,前端提交前取消残留 run;系统/向导 thread_type 从对话列表排除;岗位总结席位加 write_file 上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-23 20:29:59 +08:00
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())
@@ -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)
@@ -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))
@@ -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.
@@ -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
@@ -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: