mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* fix(tools): publish the default agent's SOUL.md atomically setup_agent persisted the default agent's global SOUL.md with a truncating write_text. The open truncates the previous file before the new content lands, so an interrupted setup — process kill, disk full, an exception while flushing — leaves a truncated persona file, and the prompt builder reads SOUL.md on every run, so the agent then silently runs with part of its instructions missing. The same file is already published atomically everywhere else: the custom-agent store stages it to a temporary and commits with os.replace, and #6618 replaced the identical truncating pattern for the Lark CLI runtime files the same day. The default agent's copy was the one remaining write of this file that could tear. Publish through a staged temporary plus os.replace, mirroring the store's _stage_temp commit, and clean the temporary up when the publish fails so no partial artifact survives. Tests: a failed publish (os.replace raising) keeps the previous SOUL.md byte-for-byte and leaves no temporary behind, a control pins that the old truncating write loses the content in the same failure, and the happy path writes the full file. Fixes #6667 Signed-off-by: yetuge <2219677952@qq.com> * fix(tools): keep SOUL.md's mode and clean the temporary on staging failure Review follow-up on #6668, both non-blocking suggestions that guard the publish itself: - NamedTemporaryFile creates the temporary with mode 0600, so the first publish made the global SOUL.md 0600 (the old write_text produced 0644 & ~umask) and every re-publish clobbered a mode an operator had set. Stat the target and chmod the temporary to that mode (0644 for a first publish) before the replace — the order #6618 codified for the Lark runtime files and atomic_write_extensions_config already follows. - A failure inside the staging block (handle.write or the implicit flush/close, e.g. ENOSPC) raised before the try started, orphaning the delete=False temporary in base_dir. One try/finally now spans staging and publishing, so neither phase can leave a temporary behind. The exact POSIX mode semantics were verified outside the Windows host (WSL Ubuntu: mkstemp is 0600, first publish is 0644, a re-publish keeps an operator-set 0640, and both failure phases leave no temporary); the exact-mode tests are therefore POSIX-gated, with a cross-platform test pinning that the chmod lands on the temporary before the replace and a staging-failure test that is red on the previous head. The shared-helper suggestion is left as a follow-up: folding six independent publishers into one helper is a cross-module refactor, not part of this bug fix. Fixes #6667 Signed-off-by: yetuge <2219677952@qq.com> --------- Signed-off-by: yetuge <2219677952@qq.com>
133 lines
5.5 KiB
Python
133 lines
5.5 KiB
Python
import logging
|
|
import os
|
|
import stat
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from langchain_core.messages import ToolMessage
|
|
from langchain_core.tools import tool
|
|
from langgraph.types import Command
|
|
|
|
from deerflow.config.agents_config import SOUL_FILENAME, validate_agent_name
|
|
from deerflow.config.paths import get_paths
|
|
from deerflow.knowledge_scope import canonicalize_knowledge_scope
|
|
from deerflow.persistence.agents import get_agent_store
|
|
from deerflow.runtime.user_context import resolve_runtime_user_id
|
|
from deerflow.tools.types import Runtime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _publish_text(target: Path, text: str) -> None:
|
|
"""Publish *text* at *target* so no reader ever observes a half-written file.
|
|
|
|
A truncating write destroys the previous file before the new content
|
|
lands, so an interrupted write leaves a truncated SOUL.md — the persona
|
|
file the prompt builder reads on every run — instead of the old one. The
|
|
custom-agent store already publishes the same file through a staged
|
|
temporary plus ``os.replace``; the default agent's copy must not be the
|
|
one that can tear.
|
|
|
|
The temporary carries the target's mode (0644 for a first publish) before
|
|
the replace, so a re-publish keeps the mode an operator set on the file,
|
|
and one try/finally spans staging and publishing so a failure in either
|
|
phase cannot orphan the temporary in ``base_dir``.
|
|
"""
|
|
try:
|
|
mode = stat.S_IMODE(os.stat(target).st_mode)
|
|
except OSError:
|
|
mode = 0o644
|
|
temporary: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=target.parent, suffix=".tmp", delete=False) as handle:
|
|
temporary = Path(handle.name)
|
|
handle.write(text)
|
|
handle.flush()
|
|
os.chmod(temporary, mode)
|
|
os.fsync(handle.fileno())
|
|
temporary.replace(target)
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
@tool(parse_docstring=True)
|
|
def setup_agent(
|
|
soul: str,
|
|
description: str,
|
|
runtime: Runtime,
|
|
skills: list[str] | None = None,
|
|
) -> Command:
|
|
"""Setup the custom DeerFlow agent.
|
|
|
|
Args:
|
|
soul: Full SOUL.md content defining the agent's personality and behavior.
|
|
description: One-line description of what the agent does.
|
|
skills: Optional list of skill names this agent should use. None means use all enabled skills, empty list means no skills.
|
|
"""
|
|
|
|
# Reject empty / whitespace-only soul before touching the filesystem.
|
|
# Without this guard the tool would happily persist an empty SOUL.md and
|
|
# still report success, which caused the frontend to enter the "agent
|
|
# created" state for an unusable agent (issue #3549). Failing loud lets
|
|
# the model retry instead of silently producing a broken artifact and,
|
|
# together with the upstream agent_name fix, prevents the global default
|
|
# SOUL.md from being overwritten with empty content.
|
|
if not soul or not soul.strip():
|
|
return Command(
|
|
update={
|
|
"messages": [
|
|
ToolMessage(
|
|
content="Error: soul content is empty; refusing to create agent with an empty SOUL.md",
|
|
tool_call_id=runtime.tool_call_id,
|
|
)
|
|
]
|
|
}
|
|
)
|
|
|
|
agent_name: str | None = runtime.context.get("agent_name") if runtime.context else None
|
|
|
|
try:
|
|
agent_name = validate_agent_name(agent_name)
|
|
if agent_name:
|
|
# Custom agents are persisted under the current user's bucket (via
|
|
# the configured store — file or db) so different users, and
|
|
# different nodes, resolve the same agent. setup is idempotent, so
|
|
# this is an upsert.
|
|
user_id = resolve_runtime_user_id(runtime)
|
|
config_data: dict = {"name": agent_name}
|
|
store = get_agent_store()
|
|
try:
|
|
existing = store.get(agent_name, user_id=user_id)
|
|
except FileNotFoundError:
|
|
pass # First bootstrap has no user-authored label to preserve.
|
|
else:
|
|
if existing.knowledge_scope is not None:
|
|
config_data["knowledge_scope"] = canonicalize_knowledge_scope(existing.knowledge_scope)
|
|
if existing.display_name is not None:
|
|
config_data["display_name"] = existing.display_name
|
|
if description:
|
|
config_data["description"] = description
|
|
if skills is not None:
|
|
config_data["skills"] = skills
|
|
store.update(agent_name, config_data, soul, user_id=user_id)
|
|
else:
|
|
# Default agent (no agent_name): SOUL.md lives at the global base
|
|
# dir. It is not a custom-agent record, so it stays file-based
|
|
# regardless of the agent-storage backend.
|
|
paths = get_paths()
|
|
paths.base_dir.mkdir(parents=True, exist_ok=True)
|
|
_publish_text(paths.base_dir / SOUL_FILENAME, soul)
|
|
|
|
logger.info(f"[agent_creator] Created agent '{agent_name}'")
|
|
return Command(
|
|
update={
|
|
"created_agent_name": agent_name,
|
|
"messages": [ToolMessage(content=f"Agent '{agent_name}' created successfully!", tool_call_id=runtime.tool_call_id)],
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[agent_creator] Failed to create agent '{agent_name}': {e}", exc_info=True)
|
|
return Command(update={"messages": [ToolMessage(content=f"Error: {e}", tool_call_id=runtime.tool_call_id)]})
|