Compare commits

..

1 Commits

Author SHA1 Message Date
Willem Jiang e5c7328cf5 fix(chat): refresh run list after each turn so scroll-to-load history works
After context compression in an active conversation, scrolling to the top
  would not load earlier messages because useThreadRuns was never invalidated
  once new runs completed. The stale runs list left hasMore permanently false.

  - Invalidate the ["thread", threadId] query in onFinish so the runs list
    stays current as new runs are created
  - Split the useThreadHistory effect into separate reset (threadId change)
    and incremental-update (runs.data change) paths so indexRef is adjusted
    without resetting already-loaded history
  - Add message deduplication when prepending loaded run messages to guard
    against overlap with the live stream
  - Extract deduplicateHistoryMessages and adjustHistoryIndex into a pure
    utility module with 10 unit tests

  Closes #2965
2026-05-15 10:11:56 +08:00
40 changed files with 406 additions and 2008 deletions
+11 -291
View File
@@ -3,10 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import threading import threading
from pathlib import Path
from typing import Any from typing import Any
from app.channels.base import Channel from app.channels.base import Channel
@@ -23,12 +21,6 @@ class DiscordChannel(Channel):
Configuration keys (in ``config.yaml`` under ``channels.discord``): Configuration keys (in ``config.yaml`` under ``channels.discord``):
- ``bot_token``: Discord Bot token. - ``bot_token``: Discord Bot token.
- ``allowed_guilds``: (optional) List of allowed Discord guild IDs. Empty = allow all. - ``allowed_guilds``: (optional) List of allowed Discord guild IDs. Empty = allow all.
- ``mention_only``: (optional) If true, only respond when the bot is mentioned.
- ``allowed_channels``: (optional) List of channel IDs where messages are always accepted
(even when mention_only is true). Use for channels where you want the bot to respond
without mentions. Empty = mention_only applies everywhere.
- ``thread_mode``: (optional) If true, group a channel conversation into a thread.
Default: same as ``mention_only``.
""" """
def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None:
@@ -40,29 +32,6 @@ class DiscordChannel(Channel):
self._allowed_guilds.add(int(guild_id)) self._allowed_guilds.add(int(guild_id))
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
self._mention_only: bool = bool(config.get("mention_only", False))
self._thread_mode: bool = config.get("thread_mode", self._mention_only)
self._allowed_channels: set[str] = set()
for channel_id in config.get("allowed_channels", []):
self._allowed_channels.add(str(channel_id))
# Session tracking: channel_id -> Discord thread_id (in-memory, persisted to JSON).
# Uses a dedicated JSON file separate from ChannelStore, which maps IM
# conversations to DeerFlow thread IDs — a different concern.
self._active_threads: dict[str, str] = {}
# Reverse-lookup set for O(1) thread ID checks (avoids O(n) scan of _active_threads.values()).
self._active_thread_ids: set[str] = set()
# Lock protecting _active_threads and the JSON file from concurrent access.
# _run_client (Discord loop thread) and the main thread both read/write.
self._thread_store_lock = threading.Lock()
store = config.get("channel_store")
if store is not None:
self._thread_store_path = store._path.parent / "discord_threads.json"
else:
self._thread_store_path = Path.home() / ".deer-flow" / "channels" / "discord_threads.json"
# Typing indicator management
self._typing_tasks: dict[str, asyncio.Task] = {}
self._client = None self._client = None
self._thread: threading.Thread | None = None self._thread: threading.Thread | None = None
@@ -106,56 +75,12 @@ class DiscordChannel(Channel):
self._thread = threading.Thread(target=self._run_client, daemon=True) self._thread = threading.Thread(target=self._run_client, daemon=True)
self._thread.start() self._thread.start()
self._load_active_threads()
logger.info("Discord channel started") logger.info("Discord channel started")
def _load_active_threads(self) -> None:
"""Restore Discord thread mappings from the dedicated JSON file on startup."""
with self._thread_store_lock:
try:
if not self._thread_store_path.exists():
logger.debug("[Discord] no thread mappings file at %s", self._thread_store_path)
return
data = json.loads(self._thread_store_path.read_text())
self._active_threads.clear()
self._active_thread_ids.clear()
for channel_id, thread_id in data.items():
self._active_threads[channel_id] = thread_id
self._active_thread_ids.add(thread_id)
if self._active_threads:
logger.info("[Discord] restored %d thread mappings from %s", len(self._active_threads), self._thread_store_path)
except Exception:
logger.exception("[Discord] failed to load thread mappings")
def _save_thread(self, channel_id: str, thread_id: str) -> None:
"""Persist a Discord thread mapping to the dedicated JSON file."""
with self._thread_store_lock:
try:
data: dict[str, str] = {}
if self._thread_store_path.exists():
data = json.loads(self._thread_store_path.read_text())
old_id = data.get(channel_id)
data[channel_id] = thread_id
# Update reverse-lookup set
if old_id:
self._active_thread_ids.discard(old_id)
self._active_thread_ids.add(thread_id)
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
self._thread_store_path.write_text(json.dumps(data, indent=2))
except Exception:
logger.exception("[Discord] failed to save thread mapping for channel %s", channel_id)
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self.bus.unsubscribe_outbound(self._on_outbound) self.bus.unsubscribe_outbound(self._on_outbound)
# Cancel all active typing indicator tasks
for target_id, task in list(self._typing_tasks.items()):
if not task.done():
task.cancel()
logger.debug("[Discord] cancelled typing task for target %s", target_id)
self._typing_tasks.clear()
if self._client and self._discord_loop and self._discord_loop.is_running(): if self._client and self._discord_loop and self._discord_loop.is_running():
close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop) close_future = asyncio.run_coroutine_threadsafe(self._client.close(), self._discord_loop)
try: try:
@@ -175,10 +100,6 @@ class DiscordChannel(Channel):
logger.info("Discord channel stopped") logger.info("Discord channel stopped")
async def send(self, msg: OutboundMessage) -> None: async def send(self, msg: OutboundMessage) -> None:
# Stop typing indicator once we're sending the response
stop_future = asyncio.run_coroutine_threadsafe(self._stop_typing(msg.chat_id, msg.thread_ts), self._discord_loop)
await asyncio.wrap_future(stop_future)
target = await self._resolve_target(msg) target = await self._resolve_target(msg)
if target is None: if target is None:
logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts) logger.error("[Discord] target not found for chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
@@ -190,9 +111,6 @@ class DiscordChannel(Channel):
await asyncio.wrap_future(send_future) await asyncio.wrap_future(send_future)
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
stop_future = asyncio.run_coroutine_threadsafe(self._stop_typing(msg.chat_id, msg.thread_ts), self._discord_loop)
await asyncio.wrap_future(stop_future)
target = await self._resolve_target(msg) target = await self._resolve_target(msg)
if target is None: if target is None:
logger.error("[Discord] target not found for file upload chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts) logger.error("[Discord] target not found for file upload chat_id=%s thread_ts=%s", msg.chat_id, msg.thread_ts)
@@ -212,41 +130,6 @@ class DiscordChannel(Channel):
logger.exception("[Discord] failed to upload file: %s", attachment.filename) logger.exception("[Discord] failed to upload file: %s", attachment.filename)
return False return False
async def _start_typing(self, channel, chat_id: str, thread_ts: str | None = None) -> None:
"""Starts a loop to send periodic typing indicators."""
target_id = thread_ts or chat_id
if target_id in self._typing_tasks:
return # Already typing for this target
async def _typing_loop():
try:
while True:
try:
await channel.trigger_typing()
except Exception:
pass
await asyncio.sleep(10)
except asyncio.CancelledError:
pass
task = asyncio.create_task(_typing_loop())
self._typing_tasks[target_id] = task
async def _stop_typing(self, chat_id: str, thread_ts: str | None = None) -> None:
"""Stops the typing loop for a specific target."""
target_id = thread_ts or chat_id
task = self._typing_tasks.pop(target_id, None)
if task and not task.done():
task.cancel()
logger.debug("[Discord] stopped typing indicator for target %s", target_id)
async def _add_reaction(self, message) -> None:
"""Add a checkmark reaction to acknowledge the message was received."""
try:
await message.add_reaction("")
except Exception:
logger.debug("[Discord] failed to add reaction to message %s", message.id, exc_info=True)
async def _on_message(self, message) -> None: async def _on_message(self, message) -> None:
if not self._running or not self._client: if not self._running or not self._client:
return return
@@ -269,143 +152,15 @@ class DiscordChannel(Channel):
if self._discord_module is None: if self._discord_module is None:
return return
# Determine whether the bot is mentioned in this message
user = self._client.user if self._client else None
if user:
bot_mention = user.mention # <@ID>
alt_mention = f"<@!{user.id}>" # <@!ID> (ping variant)
standard_mention = f"<@{user.id}>"
else:
bot_mention = None
alt_mention = None
standard_mention = ""
has_mention = (bot_mention and bot_mention in message.content) or (alt_mention and alt_mention in message.content) or (standard_mention and standard_mention in message.content)
# Strip mention from text for processing
if has_mention:
text = text.replace(bot_mention or "", "").replace(alt_mention or "", "").replace(standard_mention or "", "").strip()
# Don't return early if text is empty — still process the mention (e.g., create thread)
# --- Determine thread/channel routing and typing target ---
thread_id = None
chat_id = None
typing_target = None # The Discord object to type into
if isinstance(message.channel, self._discord_module.Thread): if isinstance(message.channel, self._discord_module.Thread):
# --- Message already inside a thread --- chat_id = str(message.channel.parent_id or message.channel.id)
thread_obj = message.channel thread_id = str(message.channel.id)
thread_id = str(thread_obj.id)
chat_id = str(thread_obj.parent_id or thread_obj.id)
typing_target = thread_obj
# If this is a known active thread, process normally
if thread_id in self._active_thread_ids:
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=chat_id,
user_id=str(message.author.id),
text=text,
msg_type=msg_type,
thread_ts=thread_id,
metadata={
"guild_id": str(guild.id) if guild else None,
"channel_id": str(message.channel.id),
"message_id": str(message.id),
},
)
inbound.topic_id = thread_id
self._publish(inbound)
# Start typing indicator in the thread
if typing_target:
asyncio.create_task(self._start_typing(typing_target, chat_id, thread_id))
asyncio.create_task(self._add_reaction(message))
return
# Thread not tracked (orphaned) — create new thread and handle below
logger.debug("[Discord] message in orphaned thread %s, will create new thread", thread_id)
thread_id = None
typing_target = None
# At this point we're guaranteed to be in a channel, not a thread
# (the Thread case is handled above). Apply mention_only for all
# non-thread messages — no special case needed.
channel_id = str(message.channel.id)
# Check if there's an active thread for this channel
if channel_id in self._active_threads:
# respect mention_only: if enabled, only process messages that mention the bot
# (unless the channel is in allowed_channels)
# Messages within a thread are always allowed through (continuation).
# At this code point we know the message is in a channel, not a thread
# (Thread case handled above), so always apply the check.
if self._mention_only and not has_mention and channel_id not in self._allowed_channels:
logger.debug("[Discord] skipping no-@ message in channel %s (not in thread)", channel_id)
return
# mention_only + fresh @ → create new thread instead of routing to existing one
if self._mention_only and has_mention:
thread_obj = await self._create_thread(message)
if thread_obj is not None:
target_thread_id = str(thread_obj.id)
self._active_threads[channel_id] = target_thread_id
self._save_thread(channel_id, target_thread_id)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj
logger.info("[Discord] created new thread %s in channel %s on mention (replacing existing thread)", target_thread_id, channel_id)
else:
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel
else:
# Existing session → route to the existing thread
target_thread_id = self._active_threads[channel_id]
logger.debug("[Discord] routing message in channel %s to existing thread %s", channel_id, target_thread_id)
thread_id = target_thread_id
chat_id = channel_id
typing_target = await self._get_channel_or_thread(target_thread_id)
elif self._mention_only and not has_mention and channel_id not in self._allowed_channels:
# Not mentioned and not in an allowed channel → skip
logger.debug("[Discord] skipping message without mention in channel %s", channel_id)
return
elif self._mention_only and has_mention:
# First mention in this channel → create thread
thread_obj = await self._create_thread(message)
if thread_obj is not None:
target_thread_id = str(thread_obj.id)
self._active_threads[channel_id] = target_thread_id
self._save_thread(channel_id, target_thread_id)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj # Type into the new thread
logger.info("[Discord] created thread %s in channel %s for user %s", target_thread_id, channel_id, message.author.display_name)
else:
# Fallback: thread creation failed (disabled/permissions), reply in channel
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel # Type into the channel
elif self._thread_mode:
# thread_mode but mention_only is False → create thread anyway for conversation grouping
thread_obj = await self._create_thread(message)
if thread_obj is None:
# Thread creation failed (disabled/permissions), fall back to channel replies
logger.info("[Discord] thread creation failed in channel %s, falling back to channel replies", channel_id)
thread_id = channel_id
chat_id = channel_id
typing_target = message.channel # Type into the channel
else:
target_thread_id = str(thread_obj.id)
self._active_threads[channel_id] = target_thread_id
self._save_thread(channel_id, target_thread_id)
thread_id = target_thread_id
chat_id = channel_id
typing_target = thread_obj # Type into the new thread
else: else:
# No threading — reply directly in channel thread = await self._create_thread(message)
thread_id = channel_id if thread is None:
chat_id = channel_id return
typing_target = message.channel # Type into the channel chat_id = str(message.channel.id)
thread_id = str(thread.id)
msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT msg_type = InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT
inbound = self._make_inbound( inbound = self._make_inbound(
@@ -422,15 +177,6 @@ class DiscordChannel(Channel):
) )
inbound.topic_id = thread_id inbound.topic_id = thread_id
# Start typing indicator in the correct target (thread or channel)
if typing_target:
asyncio.create_task(self._start_typing(typing_target, chat_id, thread_id))
self._publish(inbound)
asyncio.create_task(self._add_reaction(message))
def _publish(self, inbound) -> None:
"""Publish an inbound message to the main event loop."""
if self._main_loop and self._main_loop.is_running(): if self._main_loop and self._main_loop.is_running():
future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop) future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop)
future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None) future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None)
@@ -452,40 +198,14 @@ class DiscordChannel(Channel):
async def _create_thread(self, message): async def _create_thread(self, message):
try: try:
if self._discord_module is None:
return None
# Only TextChannel (type 0) and NewsChannel (type 10) support threads
channel_type = message.channel.type
if channel_type not in (
self._discord_module.ChannelType.text,
self._discord_module.ChannelType.news,
):
logger.info(
"[Discord] channel type %s (%s) does not support threads",
channel_type.value,
channel_type.name,
)
return None
thread_name = f"deerflow-{message.author.display_name}-{message.id}"[:100] thread_name = f"deerflow-{message.author.display_name}-{message.id}"[:100]
return await message.create_thread(name=thread_name) return await message.create_thread(name=thread_name)
except self._discord_module.errors.HTTPException as exc:
if exc.code == 50024:
logger.info(
"[Discord] cannot create thread in channel %s (error code 50024): %s",
message.channel.id,
channel_type.name if (channel_type := message.channel.type) else "unknown",
)
else:
logger.exception(
"[Discord] failed to create thread for message=%s (HTTPException %s)",
message.id,
exc.code,
)
return None
except Exception: except Exception:
logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id) logger.exception("[Discord] failed to create thread for message=%s (threads may be disabled or missing permissions)", message.id)
try:
await message.channel.send("Could not create a thread for your message. Please check that threads are enabled in this channel.")
except Exception:
pass
return None return None
async def _resolve_target(self, msg: OutboundMessage): async def _resolve_target(self, msg: OutboundMessage):
+7 -16
View File
@@ -787,22 +787,13 @@ class ChannelManager:
return return
logger.info("[Manager] invoking runs.wait(thread_id=%s, text=%r)", thread_id, msg.text[:100]) logger.info("[Manager] invoking runs.wait(thread_id=%s, text=%r)", thread_id, msg.text[:100])
try: result = await client.runs.wait(
result = await client.runs.wait( thread_id,
thread_id, assistant_id,
assistant_id, input={"messages": [{"role": "human", "content": msg.text}]},
input={"messages": [{"role": "human", "content": msg.text}]}, config=run_config,
config=run_config, context=run_context,
context=run_context, )
multitask_strategy="reject",
)
except Exception as exc:
if _is_thread_busy_error(exc):
logger.warning("[Manager] thread busy (concurrent run rejected): thread_id=%s", thread_id)
await self._send_error(msg, THREAD_BUSY_MESSAGE)
return
else:
raise
response_text = _extract_response_text(result) response_text = _extract_response_text(result)
artifacts = _extract_artifacts(result) artifacts = _extract_artifacts(result)
-2
View File
@@ -167,8 +167,6 @@ class ChannelService:
return False return False
try: try:
config = dict(config)
config["channel_store"] = self.store
channel = channel_cls(bus=self.bus, config=config) channel = channel_cls(bus=self.bus, config=config)
self._channels[name] = channel self._channels[name] = channel
await channel.start() await channel.start()
+3 -31
View File
@@ -8,8 +8,6 @@ from pydantic import BaseModel, Field
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SECRET_FILE = ".jwt_secret"
class AuthConfig(BaseModel): class AuthConfig(BaseModel):
"""JWT and auth-related configuration. Parsed once at startup. """JWT and auth-related configuration. Parsed once at startup.
@@ -32,32 +30,6 @@ class AuthConfig(BaseModel):
_auth_config: AuthConfig | None = None _auth_config: AuthConfig | None = None
def _load_or_create_secret() -> str:
"""Load persisted JWT secret from ``{base_dir}/.jwt_secret``, or generate and persist a new one."""
from deerflow.config.paths import get_paths
paths = get_paths()
secret_file = paths.base_dir / _SECRET_FILE
try:
if secret_file.exists():
secret = secret_file.read_text(encoding="utf-8").strip()
if secret:
return secret
except OSError as exc:
raise RuntimeError(f"Failed to read JWT secret from {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can read its persisted auth secret.") from exc
secret = secrets.token_urlsafe(32)
try:
secret_file.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(secret_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(secret)
except OSError as exc:
raise RuntimeError(f"Failed to persist JWT secret to {secret_file}. Set AUTH_JWT_SECRET explicitly or fix DEER_FLOW_HOME/base directory permissions so DeerFlow can store a stable auth secret.") from exc
return secret
def get_auth_config() -> AuthConfig: def get_auth_config() -> AuthConfig:
"""Get the global AuthConfig instance. Parses from env on first call.""" """Get the global AuthConfig instance. Parses from env on first call."""
global _auth_config global _auth_config
@@ -67,11 +39,11 @@ def get_auth_config() -> AuthConfig:
load_dotenv() load_dotenv()
jwt_secret = os.environ.get("AUTH_JWT_SECRET") jwt_secret = os.environ.get("AUTH_JWT_SECRET")
if not jwt_secret: if not jwt_secret:
jwt_secret = _load_or_create_secret() jwt_secret = secrets.token_urlsafe(32)
os.environ["AUTH_JWT_SECRET"] = jwt_secret os.environ["AUTH_JWT_SECRET"] = jwt_secret
logger.warning( logger.warning(
"⚠ AUTH_JWT_SECRET is not set — using an auto-generated secret " "⚠ AUTH_JWT_SECRET is not set — using an auto-generated ephemeral secret. "
"persisted to .jwt_secret. Sessions will survive restarts. " "Sessions will be invalidated on restart. "
"For production, add AUTH_JWT_SECRET to your .env file: " "For production, add AUTH_JWT_SECRET to your .env file: "
'python -c "import secrets; print(secrets.token_urlsafe(32))"' 'python -c "import secrets; print(secrets.token_urlsafe(32))"'
) )
+5 -24
View File
@@ -20,9 +20,6 @@ ACTIVE_CONTENT_MIME_TYPES = {
"image/svg+xml", "image/svg+xml",
} }
MAX_SKILL_ARCHIVE_MEMBER_BYTES = 16 * 1024 * 1024
_SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
def _build_content_disposition(disposition_type: str, filename: str) -> str: def _build_content_disposition(disposition_type: str, filename: str) -> str:
"""Build an RFC 5987 encoded Content-Disposition header value.""" """Build an RFC 5987 encoded Content-Disposition header value."""
@@ -47,22 +44,6 @@ def is_text_file_by_content(path: Path, sample_size: int = 8192) -> bool:
return False return False
def _read_skill_archive_member(zip_ref: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
"""Read a .skill archive member while enforcing an uncompressed size cap."""
if info.file_size > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks: list[bytes] = []
total_read = 0
with zip_ref.open(info, "r") as src:
while chunk := src.read(_SKILL_ARCHIVE_READ_CHUNK_SIZE):
total_read += len(chunk)
if total_read > MAX_SKILL_ARCHIVE_MEMBER_BYTES:
raise HTTPException(status_code=413, detail="Skill archive member is too large to preview")
chunks.append(chunk)
return b"".join(chunks)
def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None: def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> bytes | None:
"""Extract a file from a .skill ZIP archive. """Extract a file from a .skill ZIP archive.
@@ -79,16 +60,16 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
try: try:
with zipfile.ZipFile(zip_path, "r") as zip_ref: with zipfile.ZipFile(zip_path, "r") as zip_ref:
# List all files in the archive # List all files in the archive
infos_by_name = {info.filename: info for info in zip_ref.infolist()} namelist = zip_ref.namelist()
# Try direct path first # Try direct path first
if internal_path in infos_by_name: if internal_path in namelist:
return _read_skill_archive_member(zip_ref, infos_by_name[internal_path]) return zip_ref.read(internal_path)
# Try with any top-level directory prefix (e.g., "skill-name/SKILL.md") # Try with any top-level directory prefix (e.g., "skill-name/SKILL.md")
for name, info in infos_by_name.items(): for name in namelist:
if name.endswith("/" + internal_path) or name == internal_path: if name.endswith("/" + internal_path) or name == internal_path:
return _read_skill_archive_member(zip_ref, info) return zip_ref.read(name)
# Not found # Not found
return None return None
+2 -2
View File
@@ -99,7 +99,7 @@ rm -f backend/.deer-flow/data/deerflow.db
| `.deer-flow/users/{user_id}/memory.json` | 用户级 memory | | `.deer-flow/users/{user_id}/memory.json` | 用户级 memory |
| `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory | | `.deer-flow/users/{user_id}/agents/{agent_name}/` | 用户自定义 agent 配置、SOUL 和 agent memory |
| `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) | | `.deer-flow/admin_initial_credentials.txt` | `reset_admin` 生成的新凭据文件(0600,读完应删除) |
| `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成并持久化到 `.deer-flow/.jwt_secret`,重启后 session 保持 | | `.env` 中的 `AUTH_JWT_SECRET` | JWT 签名密钥(未设置时自动生成临时密钥,重启后 session 失效 |
### 生产环境建议 ### 生产环境建议
@@ -137,4 +137,4 @@ python -c "import secrets; print(secrets.token_urlsafe(32))"
| 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` | | 启动后没看到密码 | 当前实现不在启动日志输出密码 | 首次安装访问 `/setup`;忘记密码用 `reset_admin` |
| `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin | | `/login` 自动跳到 `/setup` | 系统还没有 admin | 在 `/setup` 创建第一个 admin |
| 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 | | 登录后 POST 返回 403 | CSRF token 缺失 | 确认前端已更新 |
| 重启后需要重新登录 | `.jwt_secret` 文件被删除且 `.env` 未设置 `AUTH_JWT_SECRET` | 在 `.env` 中设置固定密钥 | | 重启后需要重新登录 | `AUTH_JWT_SECRET` 未持久化 | 在 `.env` 中设置固定密钥 |
@@ -40,15 +40,6 @@ class MemoryUpdateQueue:
self._timer: threading.Timer | None = None self._timer: threading.Timer | None = None
self._processing = False self._processing = False
@staticmethod
def _queue_key(
thread_id: str,
user_id: str | None,
agent_name: str | None,
) -> tuple[str, str | None, str | None]:
"""Return the debounce identity for a memory update target."""
return (thread_id, user_id, agent_name)
def add( def add(
self, self,
thread_id: str, thread_id: str,
@@ -124,9 +115,8 @@ class MemoryUpdateQueue:
correction_detected: bool, correction_detected: bool,
reinforcement_detected: bool, reinforcement_detected: bool,
) -> None: ) -> None:
queue_key = self._queue_key(thread_id, user_id, agent_name)
existing_context = next( existing_context = next(
(context for context in self._queue if self._queue_key(context.thread_id, context.user_id, context.agent_name) == queue_key), (context for context in self._queue if context.thread_id == thread_id),
None, None,
) )
merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False) merged_correction_detected = correction_detected or (existing_context.correction_detected if existing_context is not None else False)
@@ -140,7 +130,7 @@ class MemoryUpdateQueue:
reinforcement_detected=merged_reinforcement_detected, reinforcement_detected=merged_reinforcement_detected,
) )
self._queue = [context for context in self._queue if self._queue_key(context.thread_id, context.user_id, context.agent_name) != queue_key] self._queue = [c for c in self._queue if c.thread_id != thread_id]
self._queue.append(context) self._queue.append(context)
def _reset_timer(self) -> None: def _reset_timer(self) -> None:
@@ -6,7 +6,6 @@ from deerflow.agents.memory.message_processing import detect_correction, detect_
from deerflow.agents.memory.queue import get_memory_queue from deerflow.agents.memory.queue import get_memory_queue
from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent from deerflow.agents.middlewares.summarization_middleware import SummarizationEvent
from deerflow.config.memory_config import get_memory_config from deerflow.config.memory_config import get_memory_config
from deerflow.runtime.user_context import resolve_runtime_user_id
def memory_flush_hook(event: SummarizationEvent) -> None: def memory_flush_hook(event: SummarizationEvent) -> None:
@@ -22,13 +21,11 @@ def memory_flush_hook(event: SummarizationEvent) -> None:
correction_detected = detect_correction(filtered_messages) correction_detected = detect_correction(filtered_messages)
reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages) reinforcement_detected = not correction_detected and detect_reinforcement(filtered_messages)
user_id = resolve_runtime_user_id(event.runtime)
queue = get_memory_queue() queue = get_memory_queue()
queue.add_nowait( queue.add_nowait(
thread_id=event.thread_id, thread_id=event.thread_id,
messages=filtered_messages, messages=filtered_messages,
agent_name=event.agent_name, agent_name=event.agent_name,
user_id=user_id,
correction_detected=correction_detected, correction_detected=correction_detected,
reinforcement_detected=reinforcement_detected, reinforcement_detected=reinforcement_detected,
) )
@@ -104,46 +104,45 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
return "[Tool call was interrupted and did not return a result.]" return "[Tool call was interrupted and did not return a result.]"
def _build_patched_messages(self, messages: list) -> list | None: def _build_patched_messages(self, messages: list) -> list | None:
"""Return messages with tool results grouped after their tool-call AIMessage. """Return a new message list with patches inserted at the correct positions.
This normalizes model-bound causal order before provider serialization while For each AIMessage with dangling tool_calls (no corresponding ToolMessage),
preserving already-valid transcripts unchanged. a synthetic ToolMessage is inserted immediately after that AIMessage.
Returns None if no patches are needed.
""" """
tool_messages_by_id: dict[str, ToolMessage] = {} # Collect IDs of all existing ToolMessages
existing_tool_msg_ids: set[str] = set()
for msg in messages: for msg in messages:
if isinstance(msg, ToolMessage): if isinstance(msg, ToolMessage):
tool_messages_by_id.setdefault(msg.tool_call_id, msg) existing_tool_msg_ids.add(msg.tool_call_id)
tool_call_ids: set[str] = set() # Check if any patching is needed
needs_patch = False
for msg in messages: for msg in messages:
if getattr(msg, "type", None) != "ai": if getattr(msg, "type", None) != "ai":
continue continue
for tc in self._message_tool_calls(msg): for tc in self._message_tool_calls(msg):
tc_id = tc.get("id") tc_id = tc.get("id")
if tc_id: if tc_id and tc_id not in existing_tool_msg_ids:
tool_call_ids.add(tc_id) needs_patch = True
break
if needs_patch:
break
if not needs_patch:
return None
# Build new list with patches inserted right after each dangling AIMessage
patched: list = [] patched: list = []
consumed_tool_msg_ids: set[str] = set() patched_ids: set[str] = set()
patch_count = 0 patch_count = 0
for msg in messages: for msg in messages:
if isinstance(msg, ToolMessage) and msg.tool_call_id in tool_call_ids:
continue
patched.append(msg) patched.append(msg)
if getattr(msg, "type", None) != "ai": if getattr(msg, "type", None) != "ai":
continue continue
for tc in self._message_tool_calls(msg): for tc in self._message_tool_calls(msg):
tc_id = tc.get("id") tc_id = tc.get("id")
if not tc_id or tc_id in consumed_tool_msg_ids: if tc_id and tc_id not in existing_tool_msg_ids and tc_id not in patched_ids:
continue
existing_tool_msg = tool_messages_by_id.get(tc_id)
if existing_tool_msg is not None:
patched.append(existing_tool_msg)
consumed_tool_msg_ids.add(tc_id)
else:
patched.append( patched.append(
ToolMessage( ToolMessage(
content=self._synthetic_tool_message_content(tc), content=self._synthetic_tool_message_content(tc),
@@ -152,14 +151,10 @@ class DanglingToolCallMiddleware(AgentMiddleware[AgentState]):
status="error", status="error",
) )
) )
consumed_tool_msg_ids.add(tc_id) patched_ids.add(tc_id)
patch_count += 1 patch_count += 1
if patched == messages: logger.warning(f"Injecting {patch_count} placeholder ToolMessage(s) for dangling tool calls")
return None
if patch_count:
logger.warning(f"Injecting {patch_count} placeholder ToolMessage(s) for dangling tool calls")
return patched return patched
@override @override
@@ -10,7 +10,6 @@ from typing import Any, Protocol, override, runtime_checkable
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import SummarizationMiddleware from langchain.agents.middleware import SummarizationMiddleware
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, RemoveMessage, ToolMessage
from langchain_core.messages.utils import get_buffer_string
from langgraph.config import get_config from langgraph.config import get_config
from langgraph.graph.message import REMOVE_ALL_MESSAGES from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
@@ -176,84 +175,12 @@ class DeerFlowSummarizationMiddleware(SummarizationMiddleware):
] ]
} }
@override
def _create_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""Generate summary without emitting streaming events to the client.
Suppresses callbacks to prevent the internal summarization LLM call from
producing visible AI message chunks in the frontend's ``messages-tuple``
stream (issue #2804).
"""
if not messages_to_summarize:
return "No previous conversation history."
trimmed = self._trim_messages_for_summary(messages_to_summarize)
if not trimmed:
return "Previous conversation was too long to summarize."
formatted = get_buffer_string(trimmed)
try:
response = self.model.with_config(callbacks=[]).invoke(
self.summary_prompt.format(messages=formatted).rstrip(),
config={
"metadata": {"lc_source": "summarization"},
"callbacks": [],
},
)
return self._extract_summary_text(response)
except Exception as e:
return f"Error generating summary: {e!s}"
@override
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""Generate summary without emitting streaming events to the client.
Suppresses callbacks to prevent the internal summarization LLM call from
producing visible AI message chunks in the frontend's ``messages-tuple``
stream (issue #2804).
"""
if not messages_to_summarize:
return "No previous conversation history."
trimmed = self._trim_messages_for_summary(messages_to_summarize)
if not trimmed:
return "Previous conversation was too long to summarize."
formatted = get_buffer_string(trimmed)
try:
response = await self.model.with_config(callbacks=[]).ainvoke(
self.summary_prompt.format(messages=formatted).rstrip(),
config={
"metadata": {"lc_source": "summarization"},
"callbacks": [],
},
)
return self._extract_summary_text(response)
except Exception as e:
return f"Error generating summary: {e!s}"
def _extract_summary_text(self, response: Any) -> str:
# Prefer .text which normalizes list content blocks (e.g. [{"type": "text", "text": "..."}]).
# Fall back to .content for non-LangChain responses.
summary_text = getattr(response, "text", None)
if summary_text is None:
summary_text = getattr(response, "content", "")
return summary_text.strip() if isinstance(summary_text, str) else str(summary_text).strip()
@override @override
def _build_new_messages(self, summary: str) -> list[HumanMessage]: def _build_new_messages(self, summary: str) -> list[HumanMessage]:
"""Override the base implementation to let the human message with the special name 'summary'. """Override the base implementation to let the human message with the special name 'summary'.
And this message will be ignored to display in the frontend, but still can be used as context for the model. And this message will be ignored to display in the frontend, but still can be used as context for the model.
""" """
return [ return [HumanMessage(content=f"Here is a summary of the conversation to date:\n\n{summary}", name="summary")]
HumanMessage(
content=f"Here is a summary of the conversation to date:\n\n{summary}",
name="summary",
additional_kwargs={"hide_from_ui": True},
)
]
def _preserve_dynamic_context_reminders( def _preserve_dynamic_context_reminders(
self, self,
@@ -7,21 +7,17 @@ reminder message so the model still knows about the outstanding todo list.
Additionally, this middleware prevents the agent from exiting the loop while Additionally, this middleware prevents the agent from exiting the loop while
there are still incomplete todo items. When the model produces a final response there are still incomplete todo items. When the model produces a final response
(no tool calls) but todos are not yet complete, the middleware queues a reminder (no tool calls) but todos are not yet complete, the middleware injects a reminder
for the next model request and jumps back to the model node to force continued and jumps back to the model node to force continued engagement.
engagement. The completion reminder is injected via ``wrap_model_call`` instead
of being persisted into graph state as a normal user-visible message.
""" """
from __future__ import annotations from __future__ import annotations
import threading
from collections.abc import Awaitable, Callable
from typing import Any, override from typing import Any, override
from langchain.agents.middleware import TodoListMiddleware from langchain.agents.middleware import TodoListMiddleware
from langchain.agents.middleware.todo import PlanningState, Todo from langchain.agents.middleware.todo import PlanningState, Todo
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse, hook_config from langchain.agents.middleware.types import hook_config
from langchain_core.messages import AIMessage, HumanMessage from langchain_core.messages import AIMessage, HumanMessage
from langgraph.runtime import Runtime from langgraph.runtime import Runtime
@@ -59,51 +55,6 @@ def _format_todos(todos: list[Todo]) -> str:
return "\n".join(lines) return "\n".join(lines)
def _format_completion_reminder(todos: list[Todo]) -> str:
"""Format a completion reminder for incomplete todo items."""
incomplete = [t for t in todos if t.get("status") != "completed"]
incomplete_text = "\n".join(f"- [{t.get('status', 'pending')}] {t.get('content', '')}" for t in incomplete)
return (
"<system_reminder>\n"
"You have incomplete todo items that must be finished before giving your final response:\n\n"
f"{incomplete_text}\n\n"
"Please continue working on these tasks. Call `write_todos` to mark items as completed "
"as you finish them, and only respond when all items are done.\n"
"</system_reminder>"
)
_TOOL_CALL_FINISH_REASONS = {"tool_calls", "function_call"}
def _has_tool_call_intent_or_error(message: AIMessage) -> bool:
"""Return True when an AIMessage is not a clean final answer.
Todo completion reminders should only fire when the model has produced a
plain final response. Provider/tool parsing details have moved across
LangChain versions and integrations, so keep all tool-intent/error signals
behind this helper instead of checking one concrete field at the call site.
"""
if message.tool_calls:
return True
if getattr(message, "invalid_tool_calls", None):
return True
# Backward/provider compatibility: some integrations preserve raw or legacy
# tool-call intent in additional_kwargs even when structured tool_calls is
# empty. If this helper changes, update the matching sentinel test
# `TestToolCallIntentOrError.test_langchain_ai_message_tool_fields_are_explicitly_handled`;
# if that test fails after a LangChain upgrade, review this helper so new
# tool-call/error fields are not silently treated as clean final answers.
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
if additional_kwargs.get("tool_calls") or additional_kwargs.get("function_call"):
return True
response_metadata = getattr(message, "response_metadata", {}) or {}
return response_metadata.get("finish_reason") in _TOOL_CALL_FINISH_REASONS
class TodoMiddleware(TodoListMiddleware): class TodoMiddleware(TodoListMiddleware):
"""Extends TodoListMiddleware with `write_todos` context-loss detection. """Extends TodoListMiddleware with `write_todos` context-loss detection.
@@ -138,7 +89,6 @@ class TodoMiddleware(TodoListMiddleware):
formatted = _format_todos(todos) formatted = _format_todos(todos)
reminder = HumanMessage( reminder = HumanMessage(
name="todo_reminder", name="todo_reminder",
additional_kwargs={"hide_from_ui": True},
content=( content=(
"<system_reminder>\n" "<system_reminder>\n"
"Your todo list from earlier is no longer visible in the current context window, " "Your todo list from earlier is no longer visible in the current context window, "
@@ -163,100 +113,6 @@ class TodoMiddleware(TodoListMiddleware):
# Maximum number of completion reminders before allowing the agent to exit. # Maximum number of completion reminders before allowing the agent to exit.
# This prevents infinite loops when the agent cannot make further progress. # This prevents infinite loops when the agent cannot make further progress.
_MAX_COMPLETION_REMINDERS = 2 _MAX_COMPLETION_REMINDERS = 2
# Hard cap for per-run reminder bookkeeping in long-lived middleware instances.
_MAX_COMPLETION_REMINDER_KEYS = 4096
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._lock = threading.Lock()
self._pending_completion_reminders: dict[tuple[str, str], list[str]] = {}
self._completion_reminder_counts: dict[tuple[str, str], int] = {}
self._completion_reminder_touch_order: dict[tuple[str, str], int] = {}
self._completion_reminder_next_order = 0
@staticmethod
def _get_thread_id(runtime: Runtime) -> str:
context = getattr(runtime, "context", None)
thread_id = context.get("thread_id") if context else None
return str(thread_id) if thread_id else "default"
@staticmethod
def _get_run_id(runtime: Runtime) -> str:
context = getattr(runtime, "context", None)
run_id = context.get("run_id") if context else None
return str(run_id) if run_id else "default"
def _pending_key(self, runtime: Runtime) -> tuple[str, str]:
return self._get_thread_id(runtime), self._get_run_id(runtime)
def _touch_completion_reminder_key_locked(self, key: tuple[str, str]) -> None:
self._completion_reminder_next_order += 1
self._completion_reminder_touch_order[key] = self._completion_reminder_next_order
def _completion_reminder_keys_locked(self) -> set[tuple[str, str]]:
keys = set(self._pending_completion_reminders)
keys.update(self._completion_reminder_counts)
keys.update(self._completion_reminder_touch_order)
return keys
def _drop_completion_reminder_key_locked(self, key: tuple[str, str]) -> None:
self._pending_completion_reminders.pop(key, None)
self._completion_reminder_counts.pop(key, None)
self._completion_reminder_touch_order.pop(key, None)
def _prune_completion_reminder_state_locked(self, protected_key: tuple[str, str]) -> None:
keys = self._completion_reminder_keys_locked()
overflow = len(keys) - self._MAX_COMPLETION_REMINDER_KEYS
if overflow <= 0:
return
candidates = [key for key in keys if key != protected_key]
candidates.sort(key=lambda key: self._completion_reminder_touch_order.get(key, 0))
for key in candidates[:overflow]:
self._drop_completion_reminder_key_locked(key)
def _queue_completion_reminder(self, runtime: Runtime, reminder: str) -> None:
key = self._pending_key(runtime)
with self._lock:
self._pending_completion_reminders.setdefault(key, []).append(reminder)
self._completion_reminder_counts[key] = self._completion_reminder_counts.get(key, 0) + 1
self._touch_completion_reminder_key_locked(key)
self._prune_completion_reminder_state_locked(protected_key=key)
def _completion_reminder_count_for_runtime(self, runtime: Runtime) -> int:
key = self._pending_key(runtime)
with self._lock:
return self._completion_reminder_counts.get(key, 0)
def _drain_completion_reminders(self, runtime: Runtime) -> list[str]:
key = self._pending_key(runtime)
with self._lock:
reminders = self._pending_completion_reminders.pop(key, [])
if reminders or key in self._completion_reminder_counts:
self._touch_completion_reminder_key_locked(key)
return reminders
def _clear_other_run_completion_reminders(self, runtime: Runtime) -> None:
thread_id, current_run_id = self._pending_key(runtime)
with self._lock:
for key in self._completion_reminder_keys_locked():
if key[0] == thread_id and key[1] != current_run_id:
self._drop_completion_reminder_key_locked(key)
def _clear_current_run_completion_reminders(self, runtime: Runtime) -> None:
key = self._pending_key(runtime)
with self._lock:
self._drop_completion_reminder_key_locked(key)
@override
def before_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
self._clear_other_run_completion_reminders(runtime)
return None
@override
async def abefore_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
self._clear_other_run_completion_reminders(runtime)
return None
@hook_config(can_jump_to=["model"]) @hook_config(can_jump_to=["model"])
@override @override
@@ -281,12 +137,10 @@ class TodoMiddleware(TodoListMiddleware):
if base_result is not None: if base_result is not None:
return base_result return base_result
# 2. Only intervene when the agent wants to exit cleanly. Tool-call # 2. Only intervene when the agent wants to exit (no tool calls).
# intent or tool-call parse errors should be handled by the tool path
# instead of being masked by todo reminders.
messages = state.get("messages") or [] messages = state.get("messages") or []
last_ai = next((m for m in reversed(messages) if isinstance(m, AIMessage)), None) last_ai = next((m for m in reversed(messages) if isinstance(m, AIMessage)), None)
if not last_ai or _has_tool_call_intent_or_error(last_ai): if not last_ai or last_ai.tool_calls:
return None return None
# 3. Allow exit when all todos are completed or there are no todos. # 3. Allow exit when all todos are completed or there are no todos.
@@ -295,14 +149,24 @@ class TodoMiddleware(TodoListMiddleware):
return None return None
# 4. Enforce a reminder cap to prevent infinite re-engagement loops. # 4. Enforce a reminder cap to prevent infinite re-engagement loops.
if self._completion_reminder_count_for_runtime(runtime) >= self._MAX_COMPLETION_REMINDERS: if _completion_reminder_count(messages) >= self._MAX_COMPLETION_REMINDERS:
return None return None
# 5. Queue a reminder for the next model request and jump back. We must # 5. Inject a reminder and force the agent back to the model.
# not persist this control prompt as a normal HumanMessage, otherwise it incomplete = [t for t in todos if t.get("status") != "completed"]
# can leak into user-visible message streams and saved transcripts. incomplete_text = "\n".join(f"- [{t.get('status', 'pending')}] {t.get('content', '')}" for t in incomplete)
self._queue_completion_reminder(runtime, _format_completion_reminder(todos)) reminder = HumanMessage(
return {"jump_to": "model"} name="todo_completion_reminder",
content=(
"<system_reminder>\n"
"You have incomplete todo items that must be finished before giving your final response:\n\n"
f"{incomplete_text}\n\n"
"Please continue working on these tasks. Call `write_todos` to mark items as completed "
"as you finish them, and only respond when all items are done.\n"
"</system_reminder>"
),
)
return {"jump_to": "model", "messages": [reminder]}
@override @override
@hook_config(can_jump_to=["model"]) @hook_config(can_jump_to=["model"])
@@ -313,47 +177,3 @@ class TodoMiddleware(TodoListMiddleware):
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Async version of after_model.""" """Async version of after_model."""
return self.after_model(state, runtime) return self.after_model(state, runtime)
@staticmethod
def _format_pending_completion_reminders(reminders: list[str]) -> str:
return "\n\n".join(dict.fromkeys(reminders))
def _augment_request(self, request: ModelRequest) -> ModelRequest:
reminders = self._drain_completion_reminders(request.runtime)
if not reminders:
return request
new_messages = [
*request.messages,
HumanMessage(
content=self._format_pending_completion_reminders(reminders),
name="todo_completion_reminder",
additional_kwargs={"hide_from_ui": True},
),
]
return request.override(messages=new_messages)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
return handler(self._augment_request(request))
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
return await handler(self._augment_request(request))
@override
def after_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
self._clear_current_run_completion_reminders(runtime)
return None
@override
async def aafter_agent(self, state: PlanningState, runtime: Runtime) -> dict[str, Any] | None:
self._clear_current_run_completion_reminders(runtime)
return None
@@ -11,7 +11,7 @@ import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
from sqlalchemy import delete, func, select, text from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.models.run_event import RunEventRow
@@ -86,28 +86,6 @@ class DbRunEventStore(RunEventStore):
user = get_current_user() user = get_current_user()
return str(user.id) if user is not None else None return str(user.id) if user is not None else None
@staticmethod
async def _max_seq_for_thread(session: AsyncSession, thread_id: str) -> int | None:
"""Return the current max seq while serializing writers per thread.
PostgreSQL rejects ``SELECT max(...) FOR UPDATE`` because aggregate
results are not lockable rows. As a release-safe workaround, take a
transaction-level advisory lock keyed by thread_id before reading the
aggregate. Other dialects keep the existing row-locking statement.
"""
stmt = select(func.max(RunEventRow.seq)).where(RunEventRow.thread_id == thread_id)
bind = session.get_bind()
dialect_name = bind.dialect.name if bind is not None else ""
if dialect_name == "postgresql":
await session.execute(
text("SELECT pg_advisory_xact_lock(hashtext(CAST(:thread_id AS text))::bigint)"),
{"thread_id": thread_id},
)
return await session.scalar(stmt)
return await session.scalar(stmt.with_for_update())
async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None): # noqa: D401 async def put(self, *, thread_id, run_id, event_type, category, content="", metadata=None, created_at=None): # noqa: D401
"""Write a single event — low-frequency path only. """Write a single event — low-frequency path only.
@@ -122,7 +100,10 @@ class DbRunEventStore(RunEventStore):
user_id = self._user_id_from_context() user_id = self._user_id_from_context()
async with self._sf() as session: async with self._sf() as session:
async with session.begin(): async with session.begin():
max_seq = await self._max_seq_for_thread(session, thread_id) # Use FOR UPDATE to serialize seq assignment within a thread.
# NOTE: with_for_update() on aggregates is a no-op on SQLite;
# the UNIQUE(thread_id, seq) constraint catches races there.
max_seq = await session.scalar(select(func.max(RunEventRow.seq)).where(RunEventRow.thread_id == thread_id).with_for_update())
seq = (max_seq or 0) + 1 seq = (max_seq or 0) + 1
row = RunEventRow( row = RunEventRow(
thread_id=thread_id, thread_id=thread_id,
@@ -145,8 +126,10 @@ class DbRunEventStore(RunEventStore):
async with self._sf() as session: async with self._sf() as session:
async with session.begin(): async with session.begin():
# Get max seq for the thread (assume all events in batch belong to same thread). # Get max seq for the thread (assume all events in batch belong to same thread).
# NOTE: with_for_update() on aggregates is a no-op on SQLite;
# the UNIQUE(thread_id, seq) constraint catches races there.
thread_id = events[0]["thread_id"] thread_id = events[0]["thread_id"]
max_seq = await self._max_seq_for_thread(session, thread_id) max_seq = await session.scalar(select(func.max(RunEventRow.seq)).where(RunEventRow.thread_id == thread_id).with_for_update())
seq = max_seq or 0 seq = max_seq or 0
rows = [] rows = []
for e in events: for e in events:
-1
View File
@@ -25,7 +25,6 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
postgres = ["deerflow-harness[postgres]"] postgres = ["deerflow-harness[postgres]"]
discord = ["discord.py>=2.7.0"]
[dependency-groups] [dependency-groups]
dev = [ dev = [
-15
View File
@@ -4,7 +4,6 @@ from pathlib import Path
import pytest import pytest
from _router_auth_helpers import call_unwrapped, make_authed_test_app from _router_auth_helpers import call_unwrapped, make_authed_test_app
from fastapi import HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import FileResponse from starlette.responses import FileResponse
@@ -103,17 +102,3 @@ def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path
assert response.status_code == 200 assert response.status_code == 200
assert response.text == "hello" assert response.text == "hello"
assert response.headers.get("content-disposition", "").startswith("attachment;") assert response.headers.get("content-disposition", "").startswith("attachment;")
def test_skill_archive_preview_rejects_oversized_member_before_decompression(tmp_path) -> None:
skill_path = tmp_path / "sample.skill"
payload = b"A" * (artifacts_router.MAX_SKILL_ARCHIVE_MEMBER_BYTES + 1)
with zipfile.ZipFile(skill_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zip_ref:
zip_ref.writestr("SKILL.md", payload)
assert skill_path.stat().st_size < artifacts_router.MAX_SKILL_ARCHIVE_MEMBER_BYTES
with pytest.raises(HTTPException) as exc_info:
artifacts_router._extract_file_from_skill_archive(skill_path, "SKILL.md")
assert exc_info.value.status_code == 413
+11 -47
View File
@@ -5,26 +5,28 @@ from unittest.mock import patch
import pytest import pytest
import app.gateway.auth.config as cfg from app.gateway.auth.config import AuthConfig
def test_auth_config_defaults(): def test_auth_config_defaults():
config = cfg.AuthConfig(jwt_secret="test-secret-key-123") config = AuthConfig(jwt_secret="test-secret-key-123")
assert config.token_expiry_days == 7 assert config.token_expiry_days == 7
def test_auth_config_token_expiry_range(): def test_auth_config_token_expiry_range():
cfg.AuthConfig(jwt_secret="s", token_expiry_days=1) AuthConfig(jwt_secret="s", token_expiry_days=1)
cfg.AuthConfig(jwt_secret="s", token_expiry_days=30) AuthConfig(jwt_secret="s", token_expiry_days=30)
with pytest.raises(Exception): with pytest.raises(Exception):
cfg.AuthConfig(jwt_secret="s", token_expiry_days=0) AuthConfig(jwt_secret="s", token_expiry_days=0)
with pytest.raises(Exception): with pytest.raises(Exception):
cfg.AuthConfig(jwt_secret="s", token_expiry_days=31) AuthConfig(jwt_secret="s", token_expiry_days=31)
def test_auth_config_from_env(): def test_auth_config_from_env():
env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"} env = {"AUTH_JWT_SECRET": "test-jwt-secret-from-env"}
with patch.dict(os.environ, env, clear=False): with patch.dict(os.environ, env, clear=False):
import app.gateway.auth.config as cfg
old = cfg._auth_config old = cfg._auth_config
cfg._auth_config = None cfg._auth_config = None
try: try:
@@ -34,57 +36,19 @@ def test_auth_config_from_env():
cfg._auth_config = old cfg._auth_config = old
def test_auth_config_missing_secret_generates_and_persists(tmp_path, caplog): def test_auth_config_missing_secret_generates_ephemeral(caplog):
import logging import logging
from deerflow.config.paths import Paths import app.gateway.auth.config as cfg
old = cfg._auth_config old = cfg._auth_config
cfg._auth_config = None cfg._auth_config = None
secret_file = tmp_path / ".jwt_secret"
try: try:
with patch.dict(os.environ, {}, clear=True): with patch.dict(os.environ, {}, clear=True):
os.environ.pop("AUTH_JWT_SECRET", None) os.environ.pop("AUTH_JWT_SECRET", None)
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)), caplog.at_level(logging.WARNING): with caplog.at_level(logging.WARNING):
config = cfg.get_auth_config() config = cfg.get_auth_config()
assert config.jwt_secret assert config.jwt_secret
assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages) assert any("AUTH_JWT_SECRET" in msg for msg in caplog.messages)
assert secret_file.exists()
assert secret_file.read_text().strip() == config.jwt_secret
finally:
cfg._auth_config = old
def test_auth_config_reuses_persisted_secret(tmp_path):
from deerflow.config.paths import Paths
old = cfg._auth_config
cfg._auth_config = None
persisted = "persisted-secret-from-file-min-32-chars!!"
(tmp_path / ".jwt_secret").write_text(persisted, encoding="utf-8")
try:
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("AUTH_JWT_SECRET", None)
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)):
config = cfg.get_auth_config()
assert config.jwt_secret == persisted
finally:
cfg._auth_config = old
def test_auth_config_empty_secret_file_generates_new(tmp_path):
from deerflow.config.paths import Paths
old = cfg._auth_config
cfg._auth_config = None
(tmp_path / ".jwt_secret").write_text("", encoding="utf-8")
try:
with patch.dict(os.environ, {}, clear=True):
os.environ.pop("AUTH_JWT_SECRET", None)
with patch("deerflow.config.paths.get_paths", return_value=Paths(base_dir=tmp_path)):
config = cfg.get_auth_config()
assert config.jwt_secret
assert len(config.jwt_secret) > 20
assert (tmp_path / ".jwt_secret").read_text().strip() == config.jwt_secret
finally: finally:
cfg._auth_config = old cfg._auth_config = old
+1 -1
View File
@@ -761,7 +761,7 @@ class TestChannelManager:
history_by_checkpoint: dict[tuple[str, str], list[str]] = {} history_by_checkpoint: dict[tuple[str, str], list[str]] = {}
async def _runs_wait(thread_id, assistant_id, *, input, config, context, multitask_strategy=None): async def _runs_wait(thread_id, assistant_id, *, input, config, context):
del assistant_id, context # unused in this test, kept for signature parity del assistant_id, context # unused in this test, kept for signature parity
checkpoint_ns = config.get("configurable", {}).get("checkpoint_ns") checkpoint_ns = config.get("configurable", {}).get("checkpoint_ns")
@@ -158,88 +158,6 @@ class TestBuildPatchedMessagesPatching:
assert patched[1].name == "bash" assert patched[1].name == "bash"
assert patched[1].status == "error" assert patched[1].status == "error"
def test_non_adjacent_tool_result_is_moved_next_to_tool_call(self):
middleware = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
HumanMessage(content="interruption"),
_tool_msg("call_1", "bash"),
]
patched = middleware._build_patched_messages(msgs)
assert patched is not None
assert isinstance(patched[0], AIMessage)
assert isinstance(patched[1], ToolMessage)
assert patched[1].tool_call_id == "call_1"
assert isinstance(patched[2], HumanMessage)
def test_multiple_tool_results_stay_grouped_after_ai_tool_call(self):
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1"), _tc("read", "call_2")]),
HumanMessage(content="interruption"),
_tool_msg("call_2", "read"),
_tool_msg("call_1", "bash"),
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
assert isinstance(patched[0], AIMessage)
assert isinstance(patched[1], ToolMessage)
assert isinstance(patched[2], ToolMessage)
assert [patched[1].tool_call_id, patched[2].tool_call_id] == ["call_1", "call_2"]
assert isinstance(patched[3], HumanMessage)
def test_valid_adjacent_tool_results_are_unchanged(self):
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
_tool_msg("call_1", "bash"),
HumanMessage(content="next"),
]
assert mw._build_patched_messages(msgs) is None
def test_tool_results_are_grouped_with_their_own_ai_turn_across_multiple_ai_messages(self):
mw = DanglingToolCallMiddleware()
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
HumanMessage(content="interruption"),
_ai_with_tool_calls([_tc("read", "call_2")]),
_tool_msg("call_1", "bash"),
_tool_msg("call_2", "read"),
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
assert isinstance(patched[0], AIMessage)
assert isinstance(patched[1], ToolMessage)
assert patched[1].tool_call_id == "call_1"
assert isinstance(patched[2], HumanMessage)
assert isinstance(patched[3], AIMessage)
assert isinstance(patched[4], ToolMessage)
assert patched[4].tool_call_id == "call_2"
def test_orphan_tool_message_is_preserved_during_grouping(self):
mw = DanglingToolCallMiddleware()
orphan = _tool_msg("orphan_call", "orphan")
msgs = [
_ai_with_tool_calls([_tc("bash", "call_1")]),
orphan,
HumanMessage(content="interruption"),
_tool_msg("call_1", "bash"),
]
patched = mw._build_patched_messages(msgs)
assert patched is not None
assert isinstance(patched[0], AIMessage)
assert isinstance(patched[1], ToolMessage)
assert patched[1].tool_call_id == "call_1"
assert orphan in patched
assert patched.count(orphan) == 1
def test_invalid_tool_call_is_patched(self): def test_invalid_tool_call_is_patched(self):
mw = DanglingToolCallMiddleware() mw = DanglingToolCallMiddleware()
msgs = [_ai_with_invalid_tool_calls([_invalid_tc()])] msgs = [_ai_with_invalid_tool_calls([_invalid_tc()])]
+1 -83
View File
@@ -1,6 +1,6 @@
import threading import threading
import time import time
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, patch
from deerflow.agents.memory.queue import ConversationContext, MemoryUpdateQueue from deerflow.agents.memory.queue import ConversationContext, MemoryUpdateQueue
from deerflow.config.memory_config import MemoryConfig from deerflow.config.memory_config import MemoryConfig
@@ -164,85 +164,3 @@ def test_flush_nowait_is_non_blocking() -> None:
assert elapsed < 0.1 assert elapsed < 0.1
assert finished.is_set() is False assert finished.is_set() is False
assert finished.wait(1.0) is True assert finished.wait(1.0) is True
def test_queue_keeps_updates_for_different_agents_in_same_thread() -> None:
queue = MemoryUpdateQueue()
with (
patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)),
patch.object(queue, "_reset_timer"),
):
queue.add(thread_id="thread-1", messages=["agent-a"], agent_name="agent-a")
queue.add(thread_id="thread-1", messages=["agent-b"], agent_name="agent-b")
assert queue.pending_count == 2
assert [context.agent_name for context in queue._queue] == ["agent-a", "agent-b"]
def test_queue_still_coalesces_updates_for_same_agent_in_same_thread() -> None:
queue = MemoryUpdateQueue()
with (
patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)),
patch.object(queue, "_reset_timer"),
):
queue.add(
thread_id="thread-1",
messages=["first"],
agent_name="agent-a",
correction_detected=True,
)
queue.add(
thread_id="thread-1",
messages=["second"],
agent_name="agent-a",
correction_detected=False,
)
assert queue.pending_count == 1
assert queue._queue[0].agent_name == "agent-a"
assert queue._queue[0].messages == ["second"]
assert queue._queue[0].correction_detected is True
def test_process_queue_updates_different_agents_in_same_thread_separately() -> None:
queue = MemoryUpdateQueue()
with (
patch("deerflow.agents.memory.queue.get_memory_config", return_value=_memory_config(enabled=True)),
patch.object(queue, "_reset_timer"),
):
queue.add(thread_id="thread-1", messages=["agent-a"], agent_name="agent-a")
queue.add(thread_id="thread-1", messages=["agent-b"], agent_name="agent-b")
mock_updater = MagicMock()
mock_updater.update_memory.return_value = True
with (
patch("deerflow.agents.memory.updater.MemoryUpdater", return_value=mock_updater),
patch("deerflow.agents.memory.queue.time.sleep"),
):
queue.flush()
assert mock_updater.update_memory.call_count == 2
mock_updater.update_memory.assert_has_calls(
[
call(
messages=["agent-a"],
thread_id="thread-1",
agent_name="agent-a",
correction_detected=False,
reinforcement_detected=False,
user_id=None,
),
call(
messages=["agent-b"],
thread_id="thread-1",
agent_name="agent-b",
correction_detected=False,
reinforcement_detected=False,
user_id=None,
),
]
)
@@ -38,42 +38,3 @@ def test_queue_process_passes_user_id_to_updater():
mock_updater.update_memory.assert_called_once() mock_updater.update_memory.assert_called_once()
call_kwargs = mock_updater.update_memory.call_args.kwargs call_kwargs = mock_updater.update_memory.call_args.kwargs
assert call_kwargs["user_id"] == "alice" assert call_kwargs["user_id"] == "alice"
def test_queue_keeps_updates_for_different_users_in_same_thread_and_agent():
q = MemoryUpdateQueue()
with patch("deerflow.agents.memory.queue.get_memory_config", return_value=MemoryConfig(enabled=True)), patch.object(q, "_reset_timer"):
q.add(thread_id="main", messages=["alice update"], agent_name="researcher", user_id="alice")
q.add(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
assert q.pending_count == 2
assert [context.user_id for context in q._queue] == ["alice", "bob"]
assert [context.messages for context in q._queue] == [["alice update"], ["bob update"]]
def test_queue_still_coalesces_updates_for_same_user_thread_and_agent():
q = MemoryUpdateQueue()
with patch("deerflow.agents.memory.queue.get_memory_config", return_value=MemoryConfig(enabled=True)), patch.object(q, "_reset_timer"):
q.add(thread_id="main", messages=["first"], agent_name="researcher", user_id="alice")
q.add(thread_id="main", messages=["second"], agent_name="researcher", user_id="alice")
assert q.pending_count == 1
assert q._queue[0].messages == ["second"]
assert q._queue[0].user_id == "alice"
assert q._queue[0].agent_name == "researcher"
def test_add_nowait_keeps_different_users_separate():
q = MemoryUpdateQueue()
with (
patch("deerflow.agents.memory.queue.get_memory_config", return_value=MemoryConfig(enabled=True)),
patch.object(q, "_schedule_timer"),
):
q.add_nowait(thread_id="main", messages=["alice update"], agent_name="researcher", user_id="alice")
q.add_nowait(thread_id="main", messages=["bob update"], agent_name="researcher", user_id="bob")
assert q.pending_count == 2
assert [context.user_id for context in q._queue] == ["alice", "bob"]
+1
View File
@@ -454,6 +454,7 @@ class TestAStream:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_with_tools_emits_tool_call_chunk(self): async def test_with_tools_emits_tool_call_chunk(self):
tool_calls = [{"name": "fn", "args": {}, "id": "c1"}] tool_calls = [{"name": "fn", "args": {}, "id": "c1"}]
with patch.object(MindIEChatModel, "_agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None): with patch.object(MindIEChatModel, "_agenerate", new_callable=AsyncMock) as mock_ag, patch.object(MindIEChatModel, "__init__", return_value=None):
mock_ag.return_value = _make_chat_result("ok", tool_calls=tool_calls) mock_ag.return_value = _make_chat_result("ok", tool_calls=tool_calls)
-33
View File
@@ -268,39 +268,6 @@ class TestEdgeCases:
class TestDbRunEventStore: class TestDbRunEventStore:
"""Tests for DbRunEventStore with temp SQLite.""" """Tests for DbRunEventStore with temp SQLite."""
@pytest.mark.anyio
async def test_postgres_max_seq_uses_advisory_lock_without_for_update(self):
from sqlalchemy.dialects import postgresql
from deerflow.runtime.events.store.db import DbRunEventStore
class FakeSession:
def __init__(self):
self.dialect = postgresql.dialect()
self.execute_calls = []
self.scalar_stmt = None
def get_bind(self):
return self
async def execute(self, stmt, params=None):
self.execute_calls.append((stmt, params))
async def scalar(self, stmt):
self.scalar_stmt = stmt
return 41
session = FakeSession()
max_seq = await DbRunEventStore._max_seq_for_thread(session, "thread-1")
assert max_seq == 41
assert session.execute_calls
assert session.execute_calls[0][1] == {"thread_id": "thread-1"}
assert "pg_advisory_xact_lock" in str(session.execute_calls[0][0])
compiled = str(session.scalar_stmt.compile(dialect=postgresql.dialect()))
assert "FOR UPDATE" not in compiled
@pytest.mark.anyio @pytest.mark.anyio
async def test_basic_crud(self, tmp_path): async def test_basic_crud(self, tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
+2 -105
View File
@@ -30,18 +30,12 @@ def _dynamic_context_reminder(msg_id: str = "reminder-1") -> HumanMessage:
) )
def _runtime( def _runtime(thread_id: str | None = "thread-1", agent_name: str | None = None) -> SimpleNamespace:
thread_id: str | None = "thread-1",
agent_name: str | None = None,
user_id: str | None = None,
) -> SimpleNamespace:
context = {} context = {}
if thread_id is not None: if thread_id is not None:
context["thread_id"] = thread_id context["thread_id"] = thread_id
if agent_name is not None: if agent_name is not None:
context["agent_name"] = agent_name context["agent_name"] = agent_name
if user_id is not None:
context["user_id"] = user_id
return SimpleNamespace(context=context) return SimpleNamespace(context=context)
@@ -56,8 +50,7 @@ def _middleware(
preserve_recent_skill_tokens_per_skill: int = 0, preserve_recent_skill_tokens_per_skill: int = 0,
) -> DeerFlowSummarizationMiddleware: ) -> DeerFlowSummarizationMiddleware:
model = MagicMock() model = MagicMock()
model.invoke.return_value = AIMessage(content="compressed summary") model.invoke.return_value = SimpleNamespace(text="compressed summary")
model.with_config.return_value.invoke.return_value = AIMessage(content="compressed summary")
return DeerFlowSummarizationMiddleware( return DeerFlowSummarizationMiddleware(
model=model, model=model,
trigger=trigger, trigger=trigger,
@@ -641,99 +634,3 @@ def test_memory_flush_hook_preserves_agent_scoped_memory(monkeypatch: pytest.Mon
queue.add_nowait.assert_called_once() queue.add_nowait.assert_called_once()
assert queue.add_nowait.call_args.kwargs["agent_name"] == "research-agent" assert queue.add_nowait.call_args.kwargs["agent_name"] == "research-agent"
# ---------------------------------------------------------------------------
# Issue #2804: summary text must not leak to the frontend via streaming
# ---------------------------------------------------------------------------
def test_build_new_messages_sets_hide_from_ui() -> None:
"""The summary HumanMessage must carry hide_from_ui so the frontend filters it."""
middleware = _middleware()
messages = middleware._build_new_messages("test summary")
assert len(messages) == 1
msg = messages[0]
assert msg.name == "summary"
assert msg.additional_kwargs.get("hide_from_ui") is True
assert "test summary" in msg.content
def test_create_summary_suppresses_callbacks() -> None:
"""_create_summary must bind callbacks=[] on the model AND pass callbacks=[]
in the invoke config to suppress inherited LangGraph stream callbacks."""
middleware = _middleware()
middleware._create_summary(_messages())
middleware.model.with_config.assert_called_once_with(callbacks=[])
bound = middleware.model.with_config.return_value
bound.invoke.assert_called_once()
call_config = bound.invoke.call_args.kwargs.get("config") or bound.invoke.call_args[1].get("config")
assert call_config is not None
assert call_config.get("callbacks") == []
assert call_config.get("metadata", {}).get("lc_source") == "summarization"
@pytest.mark.anyio
async def test_acreate_summary_suppresses_callbacks() -> None:
"""_acreate_summary must bind callbacks=[] on the model AND pass callbacks=[]
in the ainvoke config to suppress inherited LangGraph stream callbacks."""
middleware = _middleware()
middleware.model.with_config.return_value.ainvoke = mock.AsyncMock(return_value=AIMessage(content="async summary"))
await middleware._acreate_summary(_messages())
middleware.model.with_config.assert_called_once_with(callbacks=[])
bound = middleware.model.with_config.return_value
bound.ainvoke.assert_called_once()
call_config = bound.ainvoke.call_args.kwargs.get("config") or bound.ainvoke.call_args[1].get("config")
assert call_config is not None
assert call_config.get("callbacks") == []
assert call_config.get("metadata", {}).get("lc_source") == "summarization"
def test_before_model_summary_message_has_hide_from_ui() -> None:
"""End-to-end: the emitted state update contains a summary message with hide_from_ui."""
middleware = _middleware()
result = middleware.before_model({"messages": _messages()}, _runtime())
emitted = result["messages"]
summary_msg = emitted[1]
assert summary_msg.name == "summary"
assert summary_msg.additional_kwargs.get("hide_from_ui") is True
def test_memory_flush_hook_passes_runtime_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
queue = MagicMock()
monkeypatch.setattr("deerflow.agents.memory.summarization_hook.get_memory_config", lambda: MemoryConfig(enabled=True))
monkeypatch.setattr("deerflow.agents.memory.summarization_hook.get_memory_queue", lambda: queue)
memory_flush_hook(
SummarizationEvent(
messages_to_summarize=tuple(_messages()[:2]),
preserved_messages=(),
thread_id="main",
agent_name="researcher",
runtime=_runtime(thread_id="main", agent_name="researcher", user_id="alice"),
)
)
queue.add_nowait.assert_called_once()
assert queue.add_nowait.call_args.kwargs["user_id"] == "alice"
def test_extract_summary_text_normalizes_list_content_blocks() -> None:
"""AIMessage.content can be a list of content blocks; _extract_summary_text
must normalize to plain text via the .text property instead of producing
a Python repr like [{'type': 'text', 'text': 'summary'}]."""
middleware = _middleware()
response = AIMessage(content=[{"type": "text", "text": "A summary of the chat."}])
assert middleware._extract_summary_text(response) == "A summary of the chat."
# Plain string content still works
response_str = AIMessage(content="Plain summary")
assert middleware._extract_summary_text(response_str) == "Plain summary"
@@ -93,7 +93,7 @@ class TestTitleMiddlewareCoreLogic:
assert middleware._should_generate_title(state) is False assert middleware._should_generate_title(state) is False
def test_generate_title_uses_async_model_and_respects_max_chars(self, monkeypatch): def test_generate_title_uses_async_model_and_respects_max_chars(self, monkeypatch):
_set_test_title_config(max_chars=12, model_name=None) _set_test_title_config(max_chars=12)
middleware = TitleMiddleware() middleware = TitleMiddleware()
model = MagicMock() model = MagicMock()
model.ainvoke = AsyncMock(return_value=AIMessage(content="短标题")) model.ainvoke = AsyncMock(return_value=AIMessage(content="短标题"))
+15 -359
View File
@@ -1,19 +1,14 @@
"""Tests for TodoMiddleware context-loss detection.""" """Tests for TodoMiddleware context-loss detection."""
import asyncio import asyncio
from typing import Any from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
from langchain.agents import create_agent
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage, HumanMessage from langchain_core.messages import AIMessage, HumanMessage
from pydantic import PrivateAttr
from deerflow.agents.middlewares.todo_middleware import ( from deerflow.agents.middlewares.todo_middleware import (
TodoMiddleware, TodoMiddleware,
_completion_reminder_count, _completion_reminder_count,
_format_todos, _format_todos,
_has_tool_call_intent_or_error,
_reminder_in_messages, _reminder_in_messages,
_todos_in_messages, _todos_in_messages,
) )
@@ -27,35 +22,9 @@ def _reminder_msg():
return HumanMessage(name="todo_reminder", content="reminder") return HumanMessage(name="todo_reminder", content="reminder")
class _CapturingFakeMessagesListChatModel(FakeMessagesListChatModel):
_seen_messages: list[list[Any]] = PrivateAttr(default_factory=list)
@property
def seen_messages(self) -> list[list[Any]]:
return self._seen_messages
def bind_tools(self, tools, *, tool_choice=None, **kwargs):
return self
def _generate(self, messages, stop=None, run_manager=None, **kwargs):
self._seen_messages.append(list(messages))
return super()._generate(
messages,
stop=stop,
run_manager=run_manager,
**kwargs,
)
def _make_runtime(): def _make_runtime():
runtime = MagicMock() runtime = MagicMock()
runtime.context = {"thread_id": "test-thread", "run_id": "test-run"} runtime.context = {"thread_id": "test-thread"}
return runtime
def _make_runtime_for(thread_id: str, run_id: str):
runtime = _make_runtime()
runtime.context = {"thread_id": thread_id, "run_id": run_id}
return runtime return runtime
@@ -192,62 +161,10 @@ def _completion_reminder_msg():
return HumanMessage(name="todo_completion_reminder", content="finish your todos") return HumanMessage(name="todo_completion_reminder", content="finish your todos")
def _todo_completion_reminders(messages):
reminders = []
for message in messages:
if isinstance(message, HumanMessage) and message.name == "todo_completion_reminder":
reminders.append(message)
return reminders
def _ai_no_tool_calls(): def _ai_no_tool_calls():
return AIMessage(content="I'm done!") return AIMessage(content="I'm done!")
def _ai_with_invalid_tool_calls():
return AIMessage(
content="",
tool_calls=[],
invalid_tool_calls=[
{
"type": "invalid_tool_call",
"id": "write_file:36",
"name": "write_file",
"args": "{invalid",
"error": "Failed to parse tool arguments",
}
],
)
def _ai_with_raw_provider_tool_calls():
return AIMessage(
content="",
tool_calls=[],
invalid_tool_calls=[],
additional_kwargs={
"tool_calls": [
{
"id": "raw-tool-call",
"type": "function",
"function": {"name": "write_file", "arguments": '{"path":"report.md"}'},
}
]
},
)
def _ai_with_legacy_function_call():
return AIMessage(
content="",
additional_kwargs={"function_call": {"name": "write_file", "arguments": '{"path":"report.md"}'}},
)
def _ai_with_tool_finish_reason():
return AIMessage(content="", response_metadata={"finish_reason": "tool_calls"})
def _incomplete_todos(): def _incomplete_todos():
return [ return [
{"status": "completed", "content": "Step 1"}, {"status": "completed", "content": "Step 1"},
@@ -277,36 +194,6 @@ class TestCompletionReminderCount:
assert _completion_reminder_count(msgs) == 1 assert _completion_reminder_count(msgs) == 1
class TestToolCallIntentOrError:
def test_false_for_plain_final_answer(self):
assert _has_tool_call_intent_or_error(_ai_no_tool_calls()) is False
def test_true_for_structured_tool_calls(self):
assert _has_tool_call_intent_or_error(_ai_with_write_todos()) is True
def test_true_for_invalid_tool_calls(self):
assert _has_tool_call_intent_or_error(_ai_with_invalid_tool_calls()) is True
def test_true_for_raw_provider_tool_calls(self):
assert _has_tool_call_intent_or_error(_ai_with_raw_provider_tool_calls()) is True
def test_true_for_legacy_function_call(self):
assert _has_tool_call_intent_or_error(_ai_with_legacy_function_call()) is True
def test_true_for_tool_finish_reason(self):
assert _has_tool_call_intent_or_error(_ai_with_tool_finish_reason()) is True
def test_langchain_ai_message_tool_fields_are_explicitly_handled(self):
# Sentinel for LangChain compatibility: if future AIMessage versions add
# new top-level tool/function-call fields, this test should fail. When
# it does, update `_has_tool_call_intent_or_error()` so the completion
# reminder guard explicitly decides whether each new field means "not a
# clean final answer"; the helper has a matching comment pointing back
# to this sentinel.
tool_related_fields = {name for name in AIMessage.model_fields if "tool" in name.lower() or ("function" in name.lower() and "call" in name.lower())}
assert tool_related_fields <= {"tool_calls", "invalid_tool_calls"}
class TestAfterModel: class TestAfterModel:
def test_returns_none_when_agent_still_using_tools(self): def test_returns_none_when_agent_still_using_tools(self):
mw = TodoMiddleware() mw = TodoMiddleware()
@@ -348,299 +235,68 @@ class TestAfterModel:
} }
assert mw.after_model(state, _make_runtime()) is None assert mw.after_model(state, _make_runtime()) is None
def test_queues_reminder_and_jumps_to_model_when_incomplete(self): def test_injects_reminder_and_jumps_to_model_when_incomplete(self):
mw = TodoMiddleware() mw = TodoMiddleware()
runtime = _make_runtime()
state = { state = {
"messages": [HumanMessage(content="hi"), _ai_no_tool_calls()], "messages": [HumanMessage(content="hi"), _ai_no_tool_calls()],
"todos": _incomplete_todos(), "todos": _incomplete_todos(),
} }
result = mw.after_model(state, runtime) result = mw.after_model(state, _make_runtime())
assert result is not None assert result is not None
assert result["jump_to"] == "model" assert result["jump_to"] == "model"
assert "messages" not in result assert len(result["messages"]) == 1
reminder = result["messages"][0]
request = MagicMock()
request.runtime = runtime
request.messages = state["messages"]
request.override.return_value = "patched-request"
handler = MagicMock(return_value="response")
assert mw.wrap_model_call(request, handler) == "response"
request.override.assert_called_once()
reminder = request.override.call_args.kwargs["messages"][-1]
assert isinstance(reminder, HumanMessage) assert isinstance(reminder, HumanMessage)
assert reminder.name == "todo_completion_reminder" assert reminder.name == "todo_completion_reminder"
assert reminder.additional_kwargs["hide_from_ui"] is True
assert "Step 2" in reminder.content assert "Step 2" in reminder.content
assert "Step 3" in reminder.content assert "Step 3" in reminder.content
handler.assert_called_once_with("patched-request")
def test_reminder_lists_only_incomplete_items(self): def test_reminder_lists_only_incomplete_items(self):
mw = TodoMiddleware() mw = TodoMiddleware()
runtime = _make_runtime()
state = { state = {
"messages": [_ai_no_tool_calls()], "messages": [_ai_no_tool_calls()],
"todos": _incomplete_todos(), "todos": _incomplete_todos(),
} }
result = mw.after_model(state, runtime) result = mw.after_model(state, _make_runtime())
assert result is not None content = result["messages"][0].content
request = MagicMock()
request.runtime = runtime
request.messages = state["messages"]
request.override.return_value = "patched-request"
mw.wrap_model_call(request, MagicMock(return_value="response"))
content = request.override.call_args.kwargs["messages"][-1].content
assert "Step 1" not in content # completed — should not appear assert "Step 1" not in content # completed — should not appear
assert "Step 2" in content assert "Step 2" in content
assert "Step 3" in content assert "Step 3" in content
def test_allows_exit_after_max_reminders(self): def test_allows_exit_after_max_reminders(self):
mw = TodoMiddleware() mw = TodoMiddleware()
runtime = _make_runtime()
state = { state = {
"messages": [ "messages": [
_completion_reminder_msg(),
_completion_reminder_msg(),
_ai_no_tool_calls(), _ai_no_tool_calls(),
], ],
"todos": _incomplete_todos(), "todos": _incomplete_todos(),
} }
assert mw.after_model(state, runtime) is not None assert mw.after_model(state, _make_runtime()) is None
assert mw.after_model(state, runtime) is not None
assert mw.after_model(state, runtime) is None
def test_still_sends_reminder_before_cap(self): def test_still_sends_reminder_before_cap(self):
mw = TodoMiddleware() mw = TodoMiddleware()
runtime = _make_runtime()
state = { state = {
"messages": [ "messages": [
_completion_reminder_msg(), # 1 reminder so far
_ai_no_tool_calls(), _ai_no_tool_calls(),
], ],
"todos": _incomplete_todos(), "todos": _incomplete_todos(),
} }
assert mw.after_model(state, runtime) is not None result = mw.after_model(state, _make_runtime())
result = mw.after_model(state, runtime)
assert result is not None assert result is not None
assert result["jump_to"] == "model" assert result["jump_to"] == "model"
def test_does_not_trigger_for_invalid_tool_calls(self):
mw = TodoMiddleware()
state = {
"messages": [_ai_with_invalid_tool_calls()],
"todos": _incomplete_todos(),
}
assert mw.after_model(state, _make_runtime()) is None
def test_does_not_trigger_for_raw_provider_tool_calls(self):
mw = TodoMiddleware()
state = {
"messages": [_ai_with_raw_provider_tool_calls()],
"todos": _incomplete_todos(),
}
assert mw.after_model(state, _make_runtime()) is None
def test_does_not_trigger_for_legacy_function_call(self):
mw = TodoMiddleware()
state = {
"messages": [_ai_with_legacy_function_call()],
"todos": _incomplete_todos(),
}
assert mw.after_model(state, _make_runtime()) is None
def test_does_not_trigger_for_tool_finish_reason(self):
mw = TodoMiddleware()
state = {
"messages": [_ai_with_tool_finish_reason()],
"todos": _incomplete_todos(),
}
assert mw.after_model(state, _make_runtime()) is None
class TestAafterModel: class TestAafterModel:
def test_delegates_to_sync(self): def test_delegates_to_sync(self):
mw = TodoMiddleware() mw = TodoMiddleware()
runtime = _make_runtime()
state = { state = {
"messages": [_ai_no_tool_calls()], "messages": [_ai_no_tool_calls()],
"todos": _incomplete_todos(), "todos": _incomplete_todos(),
} }
result = asyncio.run(mw.aafter_model(state, runtime)) result = asyncio.run(mw.aafter_model(state, _make_runtime()))
assert result is not None assert result is not None
assert result["jump_to"] == "model" assert result["jump_to"] == "model"
assert "messages" not in result assert result["messages"][0].name == "todo_completion_reminder"
class TestWrapModelCall:
def test_no_pending_reminder_passthrough(self):
mw = TodoMiddleware()
request = MagicMock()
request.runtime = _make_runtime()
request.messages = [HumanMessage(content="hi")]
handler = MagicMock(return_value="response")
assert mw.wrap_model_call(request, handler) == "response"
request.override.assert_not_called()
handler.assert_called_once_with(request)
def test_pending_reminder_is_injected_once(self):
mw = TodoMiddleware()
runtime = _make_runtime()
state = {
"messages": [_ai_no_tool_calls()],
"todos": _incomplete_todos(),
}
mw.after_model(state, runtime)
request = MagicMock()
request.runtime = runtime
request.messages = state["messages"]
request.override.return_value = "patched-request"
handler = MagicMock(return_value="response")
assert mw.wrap_model_call(request, handler) == "response"
injected_messages = request.override.call_args.kwargs["messages"]
assert injected_messages[-1].name == "todo_completion_reminder"
request.override.reset_mock()
handler.reset_mock()
handler.return_value = "second-response"
assert mw.wrap_model_call(request, handler) == "second-response"
request.override.assert_not_called()
handler.assert_called_once_with(request)
class TestTodoMiddlewareAgentGraphIntegration:
def test_completion_reminder_is_transient_in_real_agent_graph(self):
mw = TodoMiddleware()
model = _CapturingFakeMessagesListChatModel(
responses=[
AIMessage(
content="",
tool_calls=[
{
"name": "write_todos",
"id": "todos-1",
"args": {
"todos": [
{"content": "Step 1", "status": "completed"},
{"content": "Step 2", "status": "pending"},
]
},
}
],
),
AIMessage(content="premature final 1"),
AIMessage(content="premature final 2"),
AIMessage(content="premature final 3"),
],
)
graph = create_agent(model=model, tools=[], middleware=[mw])
result = graph.invoke(
{"messages": [("user", "finish all todos")]},
context={"thread_id": "integration-thread", "run_id": "integration-run"},
)
assert len(model.seen_messages) == 4
reminders_by_call = [_todo_completion_reminders(messages) for messages in model.seen_messages]
assert reminders_by_call[0] == []
assert reminders_by_call[1] == []
assert len(reminders_by_call[2]) == 1
assert len(reminders_by_call[3]) == 1
assert "Step 1" not in reminders_by_call[2][0].content
assert "Step 2" in reminders_by_call[2][0].content
persisted_reminders = _todo_completion_reminders(result["messages"])
assert persisted_reminders == []
assert result["messages"][-1].content == "premature final 3"
assert result["todos"] == [
{"content": "Step 1", "status": "completed"},
{"content": "Step 2", "status": "pending"},
]
assert mw._pending_completion_reminders == {}
assert mw._completion_reminder_counts == {}
class TestRunScopedReminderCleanup:
def test_before_agent_clears_stale_count_without_pending_reminder(self):
mw = TodoMiddleware()
stale_runtime = _make_runtime()
stale_runtime.context = {"thread_id": "test-thread", "run_id": "stale-run"}
current_runtime = _make_runtime()
current_runtime.context = {"thread_id": "test-thread", "run_id": "current-run"}
other_thread_runtime = _make_runtime()
other_thread_runtime.context = {"thread_id": "other-thread", "run_id": "stale-run"}
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
assert mw.after_model(state, stale_runtime) is not None
assert mw.after_model(state, other_thread_runtime) is not None
# Simulate a model call that drained the pending message, followed by an
# abnormal run end where after_agent did not clear the reminder count.
assert mw._drain_completion_reminders(stale_runtime)
assert mw._completion_reminder_count_for_runtime(stale_runtime) == 1
mw.before_agent({}, current_runtime)
assert mw._completion_reminder_count_for_runtime(stale_runtime) == 0
assert mw._completion_reminder_count_for_runtime(other_thread_runtime) == 1
def test_size_guard_prunes_oldest_count_only_reminder_state(self):
mw = TodoMiddleware()
mw._MAX_COMPLETION_REMINDER_KEYS = 2
first_runtime = _make_runtime_for("thread-a", "run-a")
second_runtime = _make_runtime_for("thread-b", "run-b")
third_runtime = _make_runtime_for("thread-c", "run-c")
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
assert mw.after_model(state, first_runtime) is not None
# Simulate the normal model request path: pending reminder is consumed,
# but the run count remains until after_agent() or stale cleanup.
assert mw._drain_completion_reminders(first_runtime)
assert mw._completion_reminder_count_for_runtime(first_runtime) == 1
assert mw.after_model(state, second_runtime) is not None
assert mw.after_model(state, third_runtime) is not None
assert mw._completion_reminder_count_for_runtime(first_runtime) == 0
assert mw._completion_reminder_count_for_runtime(second_runtime) == 1
assert mw._completion_reminder_count_for_runtime(third_runtime) == 1
assert ("thread-a", "run-a") not in mw._completion_reminder_touch_order
def test_size_guard_prunes_pending_and_count_state_together(self):
mw = TodoMiddleware()
mw._MAX_COMPLETION_REMINDER_KEYS = 1
stale_runtime = _make_runtime_for("thread-a", "run-a")
current_runtime = _make_runtime_for("thread-b", "run-b")
state = {"messages": [_ai_no_tool_calls()], "todos": _incomplete_todos()}
assert mw.after_model(state, stale_runtime) is not None
assert mw.after_model(state, current_runtime) is not None
assert mw._drain_completion_reminders(stale_runtime) == []
assert mw._completion_reminder_count_for_runtime(stale_runtime) == 0
assert mw._completion_reminder_count_for_runtime(current_runtime) == 1
class TestAwrapModelCall:
def test_async_pending_reminder_is_injected(self):
mw = TodoMiddleware()
runtime = _make_runtime()
state = {
"messages": [_ai_no_tool_calls()],
"todos": _incomplete_todos(),
}
mw.after_model(state, runtime)
request = MagicMock()
request.runtime = runtime
request.messages = state["messages"]
request.override.return_value = "patched-request"
handler = AsyncMock(return_value="response")
result = asyncio.run(mw.awrap_model_call(request, handler))
assert result == "response"
injected_messages = request.override.call_args.kwargs["messages"]
assert injected_messages[-1].name == "todo_completion_reminder"
handler.assert_awaited_once_with("patched-request")
+2 -19
View File
@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -763,9 +763,6 @@ dependencies = [
] ]
[package.optional-dependencies] [package.optional-dependencies]
discord = [
{ name = "discord-py" },
]
postgres = [ postgres = [
{ name = "deerflow-harness", extra = ["postgres"] }, { name = "deerflow-harness", extra = ["postgres"] },
] ]
@@ -784,7 +781,6 @@ requires-dist = [
{ name = "deerflow-harness", editable = "packages/harness" }, { name = "deerflow-harness", editable = "packages/harness" },
{ name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" }, { name = "deerflow-harness", extras = ["postgres"], marker = "extra == 'postgres'", editable = "packages/harness" },
{ name = "dingtalk-stream", specifier = ">=0.24.3" }, { name = "dingtalk-stream", specifier = ">=0.24.3" },
{ name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.7.0" },
{ name = "email-validator", specifier = ">=2.0.0" }, { name = "email-validator", specifier = ">=2.0.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
@@ -799,7 +795,7 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" }, { name = "wecom-aibot-python-sdk", specifier = ">=0.1.6" },
] ]
provides-extras = ["postgres", "discord"] provides-extras = ["postgres"]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
@@ -927,19 +923,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" }, { url = "https://files.pythonhosted.org/packages/4c/44/102dede3f371277598df6aa9725b82e3add068c729333c7a5dbc12764579/dingtalk_stream-0.24.3-py3-none-any.whl", hash = "sha256:2160403656985962878bf60cdf5adf41619f21067348e06f07a7c7eebf5943ad", size = 27813, upload-time = "2025-10-24T09:36:57.497Z" },
] ]
[[package]]
name = "discord-py"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/57/9a2d9abdabdc9db8ef28ce0cf4129669e1c8717ba28d607b5ba357c4de3b/discord_py-2.7.1.tar.gz", hash = "sha256:24d5e6a45535152e4b98148a9dd6b550d25dc2c9fb41b6d670319411641249da", size = 1106326, upload-time = "2026-03-03T18:40:46.24Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/a7/17208c3b3f92319e7fad259f1c6d5a5baf8fd0654c54846ced329f83c3eb/discord_py-2.7.1-py3-none-any.whl", hash = "sha256:849dca2c63b171146f3a7f3f8acc04248098e9e6203412ce3cf2745f284f7439", size = 1227550, upload-time = "2026-03-03T18:40:44.492Z" },
]
[[package]] [[package]]
name = "distro" name = "distro"
version = "1.9.0" version = "1.9.0"
-8
View File
@@ -1029,14 +1029,6 @@ run_events:
# client_secret: $DINGTALK_CLIENT_SECRET # client_secret: $DINGTALK_CLIENT_SECRET
# allowed_users: [] # empty = allow all # allowed_users: [] # empty = allow all
# card_template_id: "" # Optional: AI Card template ID for streaming updates # card_template_id: "" # Optional: AI Card template ID for streaming updates
#
# discord:
# enabled: false
# bot_token: $DISCORD_BOT_TOKEN
# allowed_guilds: [] # empty = allow all guilds; can also be a single guild ID
# mention_only: false # If true, only respond when the bot is mentioned
# allowed_channels: [] # Optional: channel IDs exempt from mention_only (bot responds without mention)
# thread_mode: false # If true, group a channel conversation into a thread
# ============================================================================ # ============================================================================
# Guardrails Configuration # Guardrails Configuration
+3 -21
View File
@@ -28,10 +28,6 @@ http {
set $gateway_upstream gateway:8001; set $gateway_upstream gateway:8001;
set $frontend_upstream frontend:3000; set $frontend_upstream frontend:3000;
# Default proxy settings for all locations (streaming/SSE support)
proxy_buffering off;
proxy_cache off;
# Keep the unified nginx endpoint same-origin by default. When split # Keep the unified nginx endpoint same-origin by default. When split
# frontend/backend or port-forwarded deployments need browser CORS, # frontend/backend or port-forwarded deployments need browser CORS,
# configure the Gateway allowlist with GATEWAY_CORS_ORIGINS so CORS and # configure the Gateway allowlist with GATEWAY_CORS_ORIGINS so CORS and
@@ -53,6 +49,8 @@ http {
proxy_set_header Connection ''; proxy_set_header Connection '';
# SSE/Streaming support # SSE/Streaming support
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no; proxy_set_header X-Accel-Buffering no;
# Timeouts for long-running requests # Timeouts for long-running requests
@@ -72,7 +70,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Custom API: Memory endpoint # Custom API: Memory endpoint
@@ -83,7 +80,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Custom API: MCP configuration endpoint # Custom API: MCP configuration endpoint
@@ -94,7 +90,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Custom API: Skills configuration endpoint # Custom API: Skills configuration endpoint
@@ -105,7 +100,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Custom API: Agents endpoint # Custom API: Agents endpoint
@@ -116,7 +110,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Custom API: Uploads endpoint # Custom API: Uploads endpoint
@@ -131,8 +124,6 @@ http {
# Large file upload support # Large file upload support
client_max_body_size 100M; client_max_body_size 100M;
proxy_request_buffering off; proxy_request_buffering off;
# Disable response buffering to avoid permission errors
} }
# Custom API: Other endpoints under /api/threads # Custom API: Other endpoints under /api/threads
@@ -143,7 +134,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# API Documentation: Swagger UI # API Documentation: Swagger UI
@@ -154,7 +144,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# API Documentation: ReDoc # API Documentation: ReDoc
@@ -165,7 +154,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# API Documentation: OpenAPI Schema # API Documentation: OpenAPI Schema
@@ -176,7 +164,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Health check endpoint (gateway) # Health check endpoint (gateway)
@@ -187,7 +174,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# ── Provisioner API (sandbox management) ──────────────────────── # ── Provisioner API (sandbox management) ────────────────────────
@@ -201,7 +187,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# Catch-all for /api/ routes not covered above (e.g. /api/v1/auth/*). # Catch-all for /api/ routes not covered above (e.g. /api/v1/auth/*).
@@ -213,9 +198,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering to avoid permission errors when nginx
# runs as a non-root user (e.g. local development).
} }
# All other requests go to frontend # All other requests go to frontend
@@ -238,4 +220,4 @@ http {
proxy_read_timeout 600s; proxy_read_timeout 600s;
} }
} }
} }
-41
View File
@@ -70,11 +70,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering to avoid permission errors when nginx
# runs as a non-root user (e.g. local development).
proxy_buffering off;
proxy_cache off;
} }
# Custom API: Memory endpoint # Custom API: Memory endpoint
@@ -85,9 +80,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Custom API: MCP configuration endpoint # Custom API: MCP configuration endpoint
@@ -98,9 +90,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Custom API: Skills configuration endpoint # Custom API: Skills configuration endpoint
@@ -111,9 +100,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Custom API: Agents endpoint # Custom API: Agents endpoint
@@ -124,9 +110,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Custom API: Uploads endpoint # Custom API: Uploads endpoint
@@ -141,10 +124,6 @@ http {
# Large file upload support # Large file upload support
client_max_body_size 100M; client_max_body_size 100M;
proxy_request_buffering off; proxy_request_buffering off;
# Disable response buffering to avoid permission errors
proxy_buffering off;
proxy_cache off;
} }
# Custom API: Other endpoints under /api/threads # Custom API: Other endpoints under /api/threads
@@ -155,9 +134,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# API Documentation: Swagger UI # API Documentation: Swagger UI
@@ -168,9 +144,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# API Documentation: ReDoc # API Documentation: ReDoc
@@ -181,9 +154,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# API Documentation: OpenAPI Schema # API Documentation: OpenAPI Schema
@@ -194,9 +164,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Health check endpoint (gateway) # Health check endpoint (gateway)
@@ -207,9 +174,6 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
} }
# Catch-all for any /api/* prefix not matched by a more specific block above. # Catch-all for any /api/* prefix not matched by a more specific block above.
@@ -229,11 +193,6 @@ http {
# Auth endpoints set HttpOnly cookies — make sure nginx doesn't # Auth endpoints set HttpOnly cookies — make sure nginx doesn't
# strip the Set-Cookie header from upstream responses. # strip the Set-Cookie header from upstream responses.
proxy_pass_header Set-Cookie; proxy_pass_header Set-Cookie;
# Disable buffering to avoid permission errors when nginx
# runs as a non-root user (e.g. local development).
proxy_buffering off;
proxy_cache off;
} }
# All other requests go to frontend # All other requests go to frontend
@@ -66,7 +66,6 @@ export default function AgentChatPage() {
thread, thread,
pendingUsageMessages, pendingUsageMessages,
sendMessage, sendMessage,
isUploading,
isHistoryLoading, isHistoryLoading,
hasMoreHistory, hasMoreHistory,
loadMoreHistory, loadMoreHistory,
@@ -107,11 +106,7 @@ export default function AgentChatPage() {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage) => {
const sendPromise = sendMessage(threadId, message, { agent_name }); void sendMessage(threadId, message, { agent_name });
if (message.files.length > 0) {
return sendPromise;
}
void sendPromise;
}, },
[sendMessage, threadId, agent_name], [sendMessage, threadId, agent_name],
); );
@@ -248,10 +243,7 @@ export default function AgentChatPage() {
<AgentWelcome agent={agent} agentName={agent_name} /> <AgentWelcome agent={agent} agentName={agent_name} />
) )
} }
disabled={ disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
}
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
@@ -109,11 +109,7 @@ export default function ChatPage() {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage) => {
const sendPromise = sendMessage(threadId, message); void sendMessage(threadId, message);
if (message.files.length > 0) {
return sendPromise;
}
void sendPromise;
}, },
[sendMessage, threadId], [sendMessage, threadId],
); );
@@ -499,10 +499,6 @@ export const PromptInput = ({
// Keep a ref to files for cleanup on unmount (avoids stale closure) // Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files); const filesRef = useRef(files);
filesRef.current = files; filesRef.current = files;
const providerTextRef = useRef("");
if (usingProvider) {
providerTextRef.current = controller.textInput.value;
}
const openFileDialogLocal = useCallback(() => { const openFileDialogLocal = useCallback(() => {
inputRef.current?.click(); inputRef.current?.click();
@@ -772,24 +768,6 @@ export const PromptInput = ({
} }
// Convert blob URLs to data URLs asynchronously // Convert blob URLs to data URLs asynchronously
const submittedFileIds = files.map((file) => file.id);
const clearSubmittedState = () => {
const currentFileIds = new Set(filesRef.current.map((file) => file.id));
const submittedFileIdsStillPresent = submittedFileIds.filter((id) =>
currentFileIds.has(id),
);
if (submittedFileIdsStillPresent.length === filesRef.current.length) {
clear();
} else {
for (const id of submittedFileIdsStillPresent) {
remove(id);
}
}
if (usingProvider && providerTextRef.current === text) {
controller.textInput.clear();
}
};
Promise.all( Promise.all(
files.map(async ({ id, ...item }) => { files.map(async ({ id, ...item }) => {
if (item.file instanceof File) { if (item.file instanceof File) {
@@ -815,14 +793,20 @@ export const PromptInput = ({
if (result instanceof Promise) { if (result instanceof Promise) {
result result
.then(() => { .then(() => {
clearSubmittedState(); clear();
if (usingProvider) {
controller.textInput.clear();
}
}) })
.catch(() => { .catch(() => {
// Don't clear on error - user may want to retry // Don't clear on error - user may want to retry
}); });
} else { } else {
// Sync function completed without throwing, clear attachments // Sync function completed without throwing, clear attachments
clearSubmittedState(); clear();
if (usingProvider) {
controller.textInput.clear();
}
} }
} catch { } catch {
// Don't clear on error - user may want to retry // Don't clear on error - user may want to retry
@@ -110,7 +110,6 @@ export function InputBox({
threadId, threadId,
initialValue, initialValue,
onContextChange, onContextChange,
onFollowupsVisibilityChange,
onSubmit, onSubmit,
onStop, onStop,
...props ...props
@@ -143,8 +142,7 @@ export function InputBox({
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
}, },
) => void; ) => void;
onFollowupsVisibilityChange?: (visible: boolean) => void; onSubmit?: (message: PromptInputMessage) => void;
onSubmit?: (message: PromptInputMessage) => void | Promise<void>;
onStop?: () => void; onStop?: () => void;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -253,12 +251,12 @@ export function InputBox({
); );
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
onStop?.(); onStop?.();
return; return;
} }
if (!message.text.trim() && message.files.length === 0) { if (!message.text) {
return; return;
} }
setFollowups([]); setFollowups([]);
@@ -276,14 +274,11 @@ export function InputBox({
selectedModel?.supports_thinking ?? false, selectedModel?.supports_thinking ?? false,
), ),
}); });
return new Promise<void>((resolve, reject) => { setTimeout(() => onSubmit?.(message), 0);
setTimeout(() => { return;
Promise.resolve(onSubmit?.(message)).then(resolve).catch(reject);
}, 0);
});
} }
return onSubmit?.(message); onSubmit?.(message);
}, },
[ [
context, context,
@@ -353,14 +348,6 @@ export function InputBox({
!followupsHidden && !followupsHidden &&
(followupsLoading || followups.length > 0); (followupsLoading || followups.length > 0);
useEffect(() => {
onFollowupsVisibilityChange?.(showFollowups);
}, [onFollowupsVisibilityChange, showFollowups]);
useEffect(() => {
return () => onFollowupsVisibilityChange?.(false);
}, [onFollowupsVisibilityChange]);
useEffect(() => { useEffect(() => {
messagesRef.current = thread.messages; messagesRef.current = thread.messages;
}, [thread.messages]); }, [thread.messages]);
+6 -9
View File
@@ -26,13 +26,6 @@ export type MessageGroup =
| AssistantClarificationGroup | AssistantClarificationGroup
| AssistantSubagentGroup; | AssistantSubagentGroup;
const HIDDEN_CONTROL_MESSAGE_NAMES = new Set([
"summary",
"loop_warning",
"todo_reminder",
"todo_completion_reminder",
]);
export function getMessageGroups(messages: Message[]): MessageGroup[] { export function getMessageGroups(messages: Message[]): MessageGroup[] {
if (messages.length === 0) { if (messages.length === 0) {
return []; return [];
@@ -60,6 +53,10 @@ export function getMessageGroups(messages: Message[]): MessageGroup[] {
continue; continue;
} }
if (message.name === "todo_reminder") {
continue;
}
if (message.type === "human") { if (message.type === "human") {
groups.push({ id: message.id, type: "human", messages: [message] }); groups.push({ id: message.id, type: "human", messages: [message] });
continue; continue;
@@ -371,8 +368,8 @@ export function findToolCallResult(toolCallId: string, messages: Message[]) {
export function isHiddenFromUIMessage(message: Message) { export function isHiddenFromUIMessage(message: Message) {
return ( return (
message.additional_kwargs?.hide_from_ui === true || message.additional_kwargs?.hide_from_ui === true ||
(typeof message.name === "string" && message.name === "summary" ||
HIDDEN_CONTROL_MESSAGE_NAMES.has(message.name)) message.name === "loop_warning"
); );
} }
@@ -0,0 +1,49 @@
import type { Message } from "@langchain/langgraph-sdk";
/**
* Deduplicate incoming messages against an existing history.
* A message is considered a duplicate if its `id` or `tool_call_id`
* (for tool messages) already appears in the existing list.
*/
export function deduplicateHistoryMessages(
existing: Message[],
incoming: Message[],
): Message[] {
const existingIds = new Set(
existing
.map((m) => ("tool_call_id" in m ? m.tool_call_id : m.id))
.filter(Boolean),
);
return incoming.filter((m) => {
if (m.id && existingIds.has(m.id)) return false;
if (
"tool_call_id" in m &&
m.tool_call_id &&
existingIds.has(m.tool_call_id)
) {
return false;
}
return true;
});
}
/**
* Compute the new history-loading index when the runs list grows.
*
* - `currentIndex < 0` means all previously-known runs have been loaded;
* reset to the last run so the user can scroll up to load new runs.
* - `currentIndex >= 0` means some runs haven't been loaded yet;
* shift the index by the number of newly-added runs.
* - If no new runs were added, return `currentIndex` unchanged.
*/
export function adjustHistoryIndex(
currentIndex: number,
prevRunsLength: number,
newRunsLength: number,
): number {
const added = newRunsLength - prevRunsLength;
if (added <= 0) return currentIndex;
if (currentIndex < 0) return newRunsLength - 1;
return currentIndex + added;
}
+79 -137
View File
@@ -18,6 +18,10 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import { fetchThreadTokenUsage } from "./api"; import { fetchThreadTokenUsage } from "./api";
import {
adjustHistoryIndex,
deduplicateHistoryMessages,
} from "./history-utils";
import { threadTokenUsageQueryKey } from "./token-usage"; import { threadTokenUsageQueryKey } from "./token-usage";
import type { import type {
AgentThread, AgentThread,
@@ -45,60 +49,15 @@ type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>; additionalKwargs?: Record<string, unknown>;
}; };
function isNonEmptyString(value: string | undefined): value is string { function mergeMessages(
return typeof value === "string" && value.length > 0;
}
function messageIdentity(message: Message): string | undefined {
if (
"tool_call_id" in message &&
typeof message.tool_call_id === "string" &&
message.tool_call_id.length > 0
) {
return `tool:${message.tool_call_id}`;
}
if (typeof message.id === "string" && message.id.length > 0) {
return `message:${message.id}`;
}
return undefined;
}
function dedupeMessagesByIdentity(messages: Message[]): Message[] {
const lastIndexByIdentity = new Map<string, number>();
messages.forEach((message, index) => {
const identity = messageIdentity(message);
if (identity) {
lastIndexByIdentity.set(identity, index);
}
});
return messages.filter((message, index) => {
const identity = messageIdentity(message);
return !identity || lastIndexByIdentity.get(identity) === index;
});
}
function findLatestUnloadedRunIndex(
runs: Run[],
loadedRunIds: ReadonlySet<string>,
): number {
for (let i = runs.length - 1; i >= 0; i--) {
const run = runs[i];
if (run && !loadedRunIds.has(run.run_id)) {
return i;
}
}
return -1;
}
export function mergeMessages(
historyMessages: Message[], historyMessages: Message[],
threadMessages: Message[], threadMessages: Message[],
optimisticMessages: Message[], optimisticMessages: Message[],
): Message[] { ): Message[] {
const threadMessageIds = new Set( const threadMessageIds = new Set(
threadMessages.map(messageIdentity).filter(isNonEmptyString), threadMessages
.map((m) => ("tool_call_id" in m ? m.tool_call_id : m.id))
.filter(Boolean),
); );
// The overlap is a contiguous suffix of historyMessages (newest history == oldest thread). // The overlap is a contiguous suffix of historyMessages (newest history == oldest thread).
@@ -110,19 +69,28 @@ export function mergeMessages(
if (!msg) { if (!msg) {
continue; continue;
} }
const identity = messageIdentity(msg); if (
if (identity && threadMessageIds.has(identity)) { (msg?.id && threadMessageIds.has(msg.id)) ||
("tool_call_id" in msg && threadMessageIds.has(msg.tool_call_id))
) {
cutoff = i; cutoff = i;
} else { } else {
break; break;
} }
} }
return dedupeMessagesByIdentity([ return [
...historyMessages.slice(0, cutoff), ...historyMessages.slice(0, cutoff),
...threadMessages, ...threadMessages,
...optimisticMessages, ...optimisticMessages,
]); ];
}
function messageIdentity(message: Message): string | undefined {
if ("tool_call_id" in message) {
return message.tool_call_id;
}
return message.id;
} }
function getMessagesAfterBaseline( function getMessagesAfterBaseline(
@@ -352,6 +320,9 @@ export function useThreadStream({
); );
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
if (threadIdRef.current && !isMock) { if (threadIdRef.current && !isMock) {
void queryClient.invalidateQueries({
queryKey: ["thread", threadIdRef.current],
});
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: threadTokenUsageQueryKey(threadIdRef.current), queryKey: threadTokenUsageQueryKey(threadIdRef.current),
}); });
@@ -663,114 +634,85 @@ export function useThreadHistory(threadId: string) {
const runsRef = useRef(runs.data ?? []); const runsRef = useRef(runs.data ?? []);
const indexRef = useRef(-1); const indexRef = useRef(-1);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const pendingLoadRef = useRef(false);
const loadingRunIdRef = useRef<string | null>(null);
const loadedRunIdsRef = useRef<Set<string>>(new Set());
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const initialLoadDoneRef = useRef(false);
loadingRef.current = loading;
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
if (loadingRef.current) {
const pendingRunIndex = findLatestUnloadedRunIndex(
runsRef.current,
loadedRunIdsRef.current,
);
const pendingRun = runsRef.current[pendingRunIndex];
if (pendingRun && pendingRun.run_id !== loadingRunIdRef.current) {
pendingLoadRef.current = true;
}
return;
}
if (runsRef.current.length === 0) { if (runsRef.current.length === 0) {
return; return;
} }
const run = runsRef.current[indexRef.current];
loadingRef.current = true; if (!run || loadingRef.current) {
setLoading(true); return;
}
try { try {
do { setLoading(true);
pendingLoadRef.current = false; const result: { data: RunMessage[]; hasMore: boolean } = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadIdRef.current)}/runs/${encodeURIComponent(run.run_id)}/messages`,
const nextRunIndex = findLatestUnloadedRunIndex( {
runsRef.current, method: "GET",
loadedRunIdsRef.current, headers: {
); "Content-Type": "application/json",
indexRef.current = nextRunIndex;
const run = runsRef.current[nextRunIndex];
if (!run) {
indexRef.current = -1;
return;
}
const requestThreadId = threadIdRef.current;
loadingRunIdRef.current = run.run_id;
const result: { data: RunMessage[]; hasMore: boolean } = await fetch(
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(requestThreadId)}/runs/${encodeURIComponent(run.run_id)}/messages`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
}, },
).then((res) => { credentials: "include",
return res.json(); },
}); ).then((res) => {
const _messages = result.data return res.json();
.filter((m) => !m.metadata.caller?.startsWith("middleware:")) });
.map((m) => m.content); const _messages = result.data
if (threadIdRef.current !== requestThreadId) { .filter((m) => !m.metadata.caller?.startsWith("middleware:"))
return; .map((m) => m.content);
} setMessages((prev) => {
setMessages((prev) => const deduped = deduplicateHistoryMessages(prev, _messages);
dedupeMessagesByIdentity([..._messages, ...prev]), return [...deduped, ...prev];
); });
loadedRunIdsRef.current.add(run.run_id); indexRef.current -= 1;
indexRef.current = findLatestUnloadedRunIndex(
runsRef.current,
loadedRunIdsRef.current,
);
} while (pendingLoadRef.current);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
loadingRef.current = false;
loadingRunIdRef.current = null;
setLoading(false); setLoading(false);
} }
}, []); }, []);
// Reset state when threadId changes
useEffect(() => { useEffect(() => {
const threadChanged = threadIdRef.current !== threadId;
threadIdRef.current = threadId; threadIdRef.current = threadId;
runsRef.current = [];
indexRef.current = -1;
initialLoadDoneRef.current = false;
setMessages([]);
}, [threadId]);
if (threadChanged) { // Load/update history when runs data changes
runsRef.current = []; useEffect(() => {
indexRef.current = -1;
pendingLoadRef.current = false;
loadingRunIdRef.current = null;
loadedRunIdsRef.current = new Set();
loadingRef.current = false;
setLoading(false);
setMessages([]);
}
if (runs.data && runs.data.length > 0) { if (runs.data && runs.data.length > 0) {
runsRef.current = runs.data ?? []; const prevLength = runsRef.current.length;
indexRef.current = findLatestUnloadedRunIndex( runsRef.current = runs.data;
runs.data,
loadedRunIdsRef.current, if (!initialLoadDoneRef.current) {
); // Initial load: start from the most recent run
initialLoadDoneRef.current = true;
indexRef.current = runs.data.length - 1;
loadMessages().catch(() => {
toast.error("Failed to load thread history.");
});
} else if (runs.data.length > prevLength) {
// New runs added (e.g., after query invalidation): adjust indexRef
// so the user can load older history by scrolling up
indexRef.current = adjustHistoryIndex(
indexRef.current,
prevLength,
runs.data.length,
);
}
} }
loadMessages().catch(() => {
toast.error("Failed to load thread history.");
});
}, [threadId, runs.data, loadMessages]); }, [threadId, runs.data, loadMessages]);
const appendMessages = useCallback((_messages: Message[]) => { const appendMessages = useCallback((_messages: Message[]) => {
setMessages((prev) => { setMessages((prev) => {
return dedupeMessagesByIdentity([...prev, ..._messages]); return [...prev, ..._messages];
}); });
}, []); }, []);
const hasMore = indexRef.current >= 0 || !runs.data; const hasMore = indexRef.current >= 0 || !runs.data;
-62
View File
@@ -48,66 +48,4 @@ test.describe("Chat workspace", () => {
timeout: 10_000, timeout: 10_000,
}); });
}); });
test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {
let releaseUpload!: () => void;
const uploadCanFinish = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
let uploadStarted!: () => void;
const uploadStartedPromise = new Promise<void>((resolve) => {
uploadStarted = resolve;
});
await page.route("**/api/threads/*/uploads", async (route) => {
uploadStarted();
await uploadCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
const promptForm = page.locator("form").filter({ has: textarea });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await expect(promptForm.getByText("report.docx")).toBeVisible();
await textarea.fill("Summarize this document");
await textarea.press("Enter");
await uploadStartedPromise;
await expect(promptForm.getByText("report.docx")).toBeVisible();
releaseUpload();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await expect(promptForm.getByText("report.docx")).toBeHidden();
});
}); });
@@ -63,37 +63,3 @@ test("aggregates token usage messages once per assistant turn", () => {
), ),
).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]); ).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]);
}); });
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{
id: "human-1",
type: "human",
content: "Audit the middleware",
},
{
id: "todo-reminder-1",
type: "human",
name: "todo_completion_reminder",
content: "<system_reminder>finish todos</system_reminder>",
},
{
id: "todo-reminder-2",
type: "human",
name: "todo_reminder",
content: "<system_reminder>remember todos</system_reminder>",
},
{
id: "ai-1",
type: "ai",
content: "Done",
},
] as Message[];
const groups = getMessageGroups(messages);
expect(groups.map((group) => group.type)).toEqual(["human", "assistant"]);
expect(
groups.flatMap((group) => group.messages).map((message) => message.id),
).toEqual(["human-1", "ai-1"]);
});
@@ -0,0 +1,136 @@
import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "vitest";
import {
adjustHistoryIndex,
deduplicateHistoryMessages,
} from "@/core/threads/history-utils";
// ---------------------------------------------------------------------------
// deduplicateHistoryMessages
// ---------------------------------------------------------------------------
test("returns all incoming messages when existing history is empty", () => {
const existing: Message[] = [];
const incoming: Message[] = [
{ type: "human", id: "m1", content: "hello" },
{ type: "ai", id: "m2", content: "hi" },
];
const result = deduplicateHistoryMessages(existing, incoming);
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toEqual(["m1", "m2"]);
});
test("filters out messages whose id already exists in history", () => {
const existing: Message[] = [
{ type: "human", id: "m1", content: "hello" },
{ type: "ai", id: "m2", content: "hi" },
];
const incoming: Message[] = [
{ type: "human", id: "m1", content: "hello" }, // duplicate
{ type: "ai", id: "m3", content: "new" },
];
const result = deduplicateHistoryMessages(existing, incoming);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("m3");
});
test("filters out tool messages by tool_call_id", () => {
const existing: Message[] = [
{
type: "tool",
id: "t1",
tool_call_id: "tc-1",
content: "tool result",
name: "search",
} as unknown as Message,
];
const incoming: Message[] = [
{
type: "tool",
id: "t1-dup",
tool_call_id: "tc-1",
content: "tool result",
name: "search",
} as unknown as Message,
{
type: "tool",
id: "t2",
tool_call_id: "tc-2",
content: "other result",
name: "search",
} as unknown as Message,
];
const result = deduplicateHistoryMessages(existing, incoming);
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("t2");
});
test("keeps messages with no id or tool_call_id", () => {
const existing: Message[] = [
{ type: "human", id: "m1", content: "existing" },
];
const incoming: Message[] = [
// Message without id — should be kept (not considered a duplicate)
{ type: "ai", content: "no id" } as Message,
];
const result = deduplicateHistoryMessages(existing, incoming);
expect(result).toHaveLength(1);
});
test("deduplicates against tool_call_id from existing messages", () => {
// Existing message has tool_call_id stored in the id set
const existing: Message[] = [
{
type: "tool",
id: "t0",
tool_call_id: "tc-x",
content: "result",
name: "tool",
} as unknown as Message,
];
// Incoming AI message references the same id — should be filtered
const incoming: Message[] = [{ type: "ai", id: "tc-x", content: "response" }];
const result = deduplicateHistoryMessages(existing, incoming);
expect(result).toHaveLength(0);
});
// ---------------------------------------------------------------------------
// adjustHistoryIndex
// ---------------------------------------------------------------------------
test("returns unchanged index when no new runs were added", () => {
expect(adjustHistoryIndex(2, 5, 5)).toBe(2);
expect(adjustHistoryIndex(-1, 3, 3)).toBe(-1);
expect(adjustHistoryIndex(0, 1, 0)).toBe(0); // shouldn't happen, but safe
});
test("resets to last run when all previous runs were loaded", () => {
// 3 runs existed, all loaded (index = -1), now 5 runs
const result = adjustHistoryIndex(-1, 3, 5);
expect(result).toBe(4); // last index of new runs list
});
test("shifts index by number of added runs when some are unloaded", () => {
// 3 runs, currently at index 1 (run at index 2 loaded), now 6 runs
const result = adjustHistoryIndex(1, 3, 6);
// 3 new runs added, shift: 1 + (6 - 3) = 4
expect(result).toBe(4);
});
test("handles single new run when all previous were loaded", () => {
// 4 runs, all loaded (index = -1), now 5 runs
const result = adjustHistoryIndex(-1, 4, 5);
expect(result).toBe(4);
});
test("handles transition from empty runs to populated", () => {
// 0 runs → 3 runs, all loaded (index = -1)
const result = adjustHistoryIndex(-1, 0, 3);
expect(result).toBe(2);
});
@@ -1,64 +0,0 @@
import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "vitest";
import { mergeMessages } from "@/core/threads/hooks";
test("mergeMessages removes duplicate messages already present in history", () => {
const human = {
id: "human-1",
type: "human",
content: "Design an agent",
} as Message;
const ai = {
id: "ai-1",
type: "ai",
content: "Let's design it.",
} as Message;
expect(mergeMessages([human, ai, human, ai], [], [])).toEqual([human, ai]);
});
test("mergeMessages lets live thread messages replace overlapping history", () => {
const oldHuman = {
id: "human-1",
type: "human",
content: "old",
} as Message;
const liveHuman = {
id: "human-1",
type: "human",
content: "live",
} as Message;
const oldAi = {
id: "ai-1",
type: "ai",
content: "old",
} as Message;
const liveAi = {
id: "ai-1",
type: "ai",
content: "live",
} as Message;
expect(mergeMessages([oldHuman, oldAi], [liveHuman, liveAi], [])).toEqual([
liveHuman,
liveAi,
]);
});
test("mergeMessages deduplicates tool messages by tool_call_id", () => {
const oldTool = {
id: "tool-message-old",
type: "tool",
tool_call_id: "call-1",
content: "old",
} as Message;
const liveTool = {
id: "tool-message-live",
type: "tool",
tool_call_id: "call-1",
content: "live",
} as Message;
expect(mergeMessages([oldTool], [liveTool], [])).toEqual([liveTool]);
});
-81
View File
@@ -72,7 +72,6 @@ def find_config_file() -> Path | None:
_SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$") _SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$")
_INDENTED_SECTION_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*$")
_KEY_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*(\S.*?)\s*$") _KEY_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*(\S.*?)\s*$")
@@ -142,84 +141,6 @@ def section_value(lines: list[str], section: str, key: str) -> str | None:
return None return None
def nested_section_value(lines: list[str], section_path: str, key: str) -> str | None:
"""Return the value of a nested YAML key like ``channels.discord.enabled``.
Handles two levels of nesting:
channels:
discord:
enabled: true
"""
parts = section_path.split(".")
if len(parts) != 2:
return None
parent_section, child_section = parts
inside_parent = False
inside_child = False
parent_indent: int | None = None
child_indent: int | None = None
for raw in lines:
line = _strip_comment(raw)
if not line.strip():
continue
stripped = line.lstrip()
indent = len(line) - len(stripped)
# Top-level section match
sect_match = _SECTION_RE.match(line)
if sect_match:
if indent == 0:
inside_parent = sect_match.group(1) == parent_section
inside_child = False
parent_indent = None
child_indent = None
continue
if not inside_parent:
continue
# Track parent indent from first child
if parent_indent is None and indent > 0:
parent_indent = indent
# If indent goes back to 0, we left the parent section
if indent == 0:
inside_parent = False
inside_child = False
continue
# Check if we're at the parent's child level (subsection)
if parent_indent is not None and indent == parent_indent:
# This could be a subsection or a direct key of parent
sub_match = _INDENTED_SECTION_RE.match(line)
if sub_match and sub_match.group(1) == child_section:
inside_child = True
child_indent = None
continue
else:
inside_child = False
continue
if not inside_child:
continue
# We're inside the subsection — track child indent
if child_indent is None and indent > (parent_indent or 0):
child_indent = indent
if child_indent is not None and indent != child_indent:
continue
key_match = _KEY_RE.match(line)
if key_match and key_match.group(1) == key:
return _unquote(key_match.group(2).strip())
return None
def detect_from_config(path: Path) -> list[str]: def detect_from_config(path: Path) -> list[str]:
try: try:
text = path.read_text(encoding="utf-8", errors="replace") text = path.read_text(encoding="utf-8", errors="replace")
@@ -231,8 +152,6 @@ def detect_from_config(path: Path) -> list[str]:
extras.add("postgres") extras.add("postgres")
if (section_value(lines, "checkpointer", "type") or "").lower() == "postgres": if (section_value(lines, "checkpointer", "type") or "").lower() == "postgres":
extras.add("postgres") extras.add("postgres")
if (nested_section_value(lines, "channels.discord", "enabled") or "").lower() == "true":
extras.add("discord")
return sorted(extras) return sorted(extras)