mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-11 09:55:59 +00:00
Merge remote-tracking branch 'origin/main' into codex/im-channel-connections
# Conflicts: # backend/app/channels/discord.py # backend/app/channels/manager.py # backend/app/channels/slack.py # backend/app/channels/telegram.py
This commit is contained in:
@@ -49,6 +49,8 @@ from deerflow.tracing import build_tracing_callbacks
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BOOTSTRAP_SKILL_NAMES = {"bootstrap"}
|
||||
|
||||
|
||||
def _get_runtime_config(config: RunnableConfig) -> dict:
|
||||
"""Merge legacy configurable options with LangGraph runtime context."""
|
||||
@@ -271,6 +273,7 @@ def build_middlewares(
|
||||
agent_name: str | None = None,
|
||||
custom_middlewares: list[AgentMiddleware] | None = None,
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
deferred_setup=None,
|
||||
):
|
||||
@@ -302,6 +305,13 @@ def build_middlewares(
|
||||
|
||||
middlewares.append(DynamicContextMiddleware(agent_name=agent_name, app_config=resolved_app_config))
|
||||
|
||||
# Deterministically load a full SKILL.md when the user starts the turn with
|
||||
# /skill-name. This keeps the base system prompt metadata-only while giving
|
||||
# explicit user activation priority over model-side relevance guessing.
|
||||
from deerflow.agents.middlewares.skill_activation_middleware import SkillActivationMiddleware
|
||||
|
||||
middlewares.append(SkillActivationMiddleware(available_skills=available_skills, app_config=resolved_app_config))
|
||||
|
||||
# Add summarization middleware if enabled
|
||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||
if summarization_middleware is not None:
|
||||
@@ -369,7 +379,7 @@ def build_middlewares(
|
||||
|
||||
def _available_skill_names(agent_config, is_bootstrap: bool) -> set[str] | None:
|
||||
if is_bootstrap:
|
||||
return {"bootstrap"}
|
||||
return set(_BOOTSTRAP_SKILL_NAMES)
|
||||
if agent_config and agent_config.skills is not None:
|
||||
return set(agent_config.skills)
|
||||
return None
|
||||
@@ -475,17 +485,25 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
|
||||
if is_bootstrap:
|
||||
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
|
||||
# Keep the bootstrap skill set intentionally narrow so agent creation
|
||||
# remains deterministic before the custom agent's own config exists.
|
||||
raw_tools = get_available_tools(model_name=model_name, subagent_enabled=subagent_enabled, app_config=resolved_app_config) + [setup_agent]
|
||||
filtered = filter_tools_by_skill_allowed_tools(raw_tools, skills_for_tool_policy)
|
||||
final_tools, setup = assemble_deferred_tools(filtered, enabled=resolved_app_config.tool_search.enabled)
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
|
||||
tools=final_tools,
|
||||
middleware=build_middlewares(config, model_name=model_name, app_config=resolved_app_config, deferred_setup=setup),
|
||||
middleware=build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
available_skills=set(["bootstrap"]),
|
||||
available_skills=set(_BOOTSTRAP_SKILL_NAMES),
|
||||
app_config=resolved_app_config,
|
||||
deferred_names=setup.deferred_names,
|
||||
),
|
||||
@@ -502,12 +520,19 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
||||
return create_agent(
|
||||
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),
|
||||
tools=final_tools,
|
||||
middleware=build_middlewares(config, model_name=model_name, agent_name=agent_name, app_config=resolved_app_config, deferred_setup=setup),
|
||||
middleware=build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
agent_name=agent_name,
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
deferred_setup=setup,
|
||||
),
|
||||
system_prompt=apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
agent_name=agent_name,
|
||||
available_skills=set(agent_config.skills) if agent_config and agent_config.skills is not None else None,
|
||||
available_skills=available_skills,
|
||||
app_config=resolved_app_config,
|
||||
deferred_names=setup.deferred_names,
|
||||
),
|
||||
|
||||
@@ -625,6 +625,11 @@ You have access to skills that provide optimized workflows for specific tasks. E
|
||||
4. Load referenced resources only when needed during execution
|
||||
5. Follow the skill's instructions precisely
|
||||
|
||||
**Explicit Slash Skill Activation:**
|
||||
- If the user starts a request with `/<skill-name>`, that skill was explicitly requested for the current turn.
|
||||
- Follow the activated skill before choosing a general workflow.
|
||||
- The runtime injects the activated skill content for explicit slash activations; do not call `read_file` for that SKILL.md again unless the injected skill references supporting resources you need.
|
||||
|
||||
**Skills are located at:** {container_base_path}
|
||||
{skill_evolution_section}
|
||||
{skills_list}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Middleware for explicit slash skill activation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware
|
||||
from langchain.agents.middleware.types import ModelRequest, ModelResponse
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
from deerflow.skills.slash import parse_slash_skill_reference, resolve_slash_skill
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
from deerflow.skills.storage.skill_storage import SkillStorage
|
||||
from deerflow.skills.types import SKILL_MD_FILE
|
||||
from deerflow.utils.messages import get_original_user_content_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.config.app_config import AppConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SLASH_SKILL_ACTIVATION_KEY = "slash_skill_activation"
|
||||
_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY = "slash_skill_activation_target_id"
|
||||
_SUMMARY_MESSAGE_NAME = "summary"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Activation:
|
||||
skill_name: str
|
||||
category: str
|
||||
container_file_path: str
|
||||
skill_content: str
|
||||
content_hash: str
|
||||
remaining_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ActivationResolution:
|
||||
activation: _Activation | None = None
|
||||
failure_message: str | None = None
|
||||
|
||||
|
||||
def is_slash_skill_activation_reminder(message: object) -> bool:
|
||||
"""Return whether a message is hidden slash-skill activation context."""
|
||||
return isinstance(message, HumanMessage) and bool(message.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_KEY))
|
||||
|
||||
|
||||
def _is_user_activation_target(message: object) -> bool:
|
||||
if not isinstance(message, HumanMessage):
|
||||
return False
|
||||
if message.name == _SUMMARY_MESSAGE_NAME:
|
||||
return False
|
||||
if message.additional_kwargs.get("hide_from_ui"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class SkillActivationMiddleware(AgentMiddleware):
|
||||
"""Inject full SKILL.md content when the user explicitly types /skill-name."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
app_config: AppConfig | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._available_skills = set(available_skills) if available_skills is not None else None
|
||||
self._app_config = app_config
|
||||
|
||||
def _storage(self) -> SkillStorage:
|
||||
if self._app_config is not None:
|
||||
return get_or_new_skill_storage(app_config=self._app_config)
|
||||
return get_or_new_skill_storage()
|
||||
|
||||
@staticmethod
|
||||
def _read_skill_content(skill_file: Path, skills_root: Path) -> str:
|
||||
if skill_file.name != SKILL_MD_FILE:
|
||||
raise ValueError(f"Expected {SKILL_MD_FILE}, got {skill_file.name}")
|
||||
resolved_root = skills_root.resolve()
|
||||
resolved_file = skill_file.resolve()
|
||||
try:
|
||||
resolved_file.relative_to(resolved_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Resolved skill file must stay within the configured skills root.") from exc
|
||||
if not resolved_file.is_file():
|
||||
raise FileNotFoundError(resolved_file)
|
||||
return resolved_file.read_text(encoding="utf-8")
|
||||
|
||||
def _resolve_activation(self, text: str) -> _ActivationResolution | None:
|
||||
reference = parse_slash_skill_reference(text)
|
||||
if reference is None:
|
||||
return None
|
||||
|
||||
storage = self._storage()
|
||||
skills = storage.load_skills(enabled_only=False)
|
||||
skill = next((candidate for candidate in skills if candidate.name == reference.name), None)
|
||||
if skill is None:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not installed.")
|
||||
if not skill.enabled:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is installed but disabled. Enable it before using slash activation.")
|
||||
if self._available_skills is not None and reference.name not in self._available_skills:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` is not available for this agent.")
|
||||
|
||||
resolved = resolve_slash_skill(
|
||||
text,
|
||||
skills,
|
||||
available_skills=self._available_skills,
|
||||
container_base_path=storage.get_container_root(),
|
||||
)
|
||||
if resolved is None:
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be resolved.")
|
||||
|
||||
try:
|
||||
skill_content = self._read_skill_content(resolved.skill.skill_file, storage.get_skills_root_path())
|
||||
except (OSError, ValueError):
|
||||
logger.exception("Failed to read slash-activated skill %s", resolved.skill.name)
|
||||
return _ActivationResolution(failure_message=f"Skill `/{reference.name}` could not be loaded safely. Please check the skill installation.")
|
||||
|
||||
content_hash = hashlib.sha256(skill_content.encode("utf-8")).hexdigest()
|
||||
return _ActivationResolution(
|
||||
activation=_Activation(
|
||||
skill_name=resolved.skill.name,
|
||||
category=str(resolved.skill.category),
|
||||
container_file_path=resolved.container_file_path,
|
||||
skill_content=skill_content,
|
||||
content_hash=content_hash,
|
||||
remaining_text=resolved.remaining_text,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_activation_reminder(activation: _Activation) -> str:
|
||||
user_request = activation.remaining_text or ("No additional task text was provided after the slash skill command. Ask the user what they want to do with this skill if the next step is unclear.")
|
||||
escaped_user_request = html.escape(user_request, quote=False)
|
||||
escaped_skill_content = html.escape(activation.skill_content, quote=False)
|
||||
escaped_skill_name = html.escape(activation.skill_name, quote=True)
|
||||
escaped_category = html.escape(activation.category, quote=True)
|
||||
escaped_path = html.escape(activation.container_file_path, quote=True)
|
||||
escaped_content_hash = html.escape(activation.content_hash, quote=True)
|
||||
return f"""<slash_skill_activation>
|
||||
The user explicitly activated the `{activation.skill_name}` skill for this turn.
|
||||
Treat the task text as:
|
||||
<user_request>
|
||||
{escaped_user_request}
|
||||
</user_request>
|
||||
|
||||
Follow this skill before choosing a general workflow. Load supporting resources from the same skill directory only when needed.
|
||||
|
||||
<skill name="{escaped_skill_name}" category="{escaped_category}" path="{escaped_path}" sha256="{escaped_content_hash}">
|
||||
<skill_content encoding="xml-escaped">
|
||||
{escaped_skill_content}
|
||||
</skill_content>
|
||||
</skill>
|
||||
</slash_skill_activation>"""
|
||||
|
||||
@staticmethod
|
||||
def _has_existing_activation_for_target(messages: list, target_index: int, target: HumanMessage) -> bool:
|
||||
if target_index <= 0:
|
||||
return False
|
||||
|
||||
if target.id:
|
||||
for previous in messages[:target_index]:
|
||||
if not is_slash_skill_activation_reminder(previous):
|
||||
continue
|
||||
target_id = previous.additional_kwargs.get(_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY)
|
||||
if target_id == target.id or previous.id == f"{target.id}__slash_activation":
|
||||
return True
|
||||
|
||||
previous = messages[target_index - 1]
|
||||
return is_slash_skill_activation_reminder(previous)
|
||||
|
||||
def _find_activation_target(self, messages: list) -> tuple[int, HumanMessage, _ActivationResolution] | None:
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
target_index = next((idx for idx in range(len(messages) - 1, -1, -1) if _is_user_activation_target(messages[idx])), None)
|
||||
if target_index is None:
|
||||
return None
|
||||
|
||||
target = messages[target_index]
|
||||
if target is None:
|
||||
return None
|
||||
if self._has_existing_activation_for_target(messages, target_index, target):
|
||||
return None
|
||||
|
||||
content = get_original_user_content_text(target.content, target.additional_kwargs)
|
||||
resolution = self._resolve_activation(content)
|
||||
if resolution is None:
|
||||
return None
|
||||
return target_index, target, resolution
|
||||
|
||||
@staticmethod
|
||||
def _record_activation(request: ModelRequest, activation: _Activation, *, hook: str) -> None:
|
||||
runtime = getattr(request, "runtime", None)
|
||||
context = getattr(runtime, "context", None)
|
||||
journal = context.get("__run_journal") if isinstance(context, dict) else None
|
||||
if journal is None:
|
||||
return
|
||||
try:
|
||||
journal.record_middleware(
|
||||
"skill_activation",
|
||||
name="SkillActivationMiddleware",
|
||||
hook=hook,
|
||||
action="activate",
|
||||
changes={
|
||||
"skill_name": activation.skill_name,
|
||||
"category": activation.category,
|
||||
"path": activation.container_file_path,
|
||||
"content_hash": activation.content_hash,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Failed to record slash skill activation audit event", exc_info=True)
|
||||
|
||||
def _prepare_model_request(self, request: ModelRequest, *, hook: str) -> ModelRequest | AIMessage | None:
|
||||
target_and_resolution = self._find_activation_target(list(request.messages))
|
||||
if target_and_resolution is None:
|
||||
return None
|
||||
|
||||
target_index, target, resolution = target_and_resolution
|
||||
if resolution.failure_message:
|
||||
return AIMessage(content=resolution.failure_message)
|
||||
|
||||
activation = resolution.activation
|
||||
if activation is None:
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"SkillActivationMiddleware: activating slash skill %s category=%s path=%s hash=%s",
|
||||
activation.skill_name,
|
||||
activation.category,
|
||||
activation.container_file_path,
|
||||
activation.content_hash,
|
||||
)
|
||||
self._record_activation(request, activation, hook=hook)
|
||||
activation_msg = self._make_activation_message(target, self._build_activation_reminder(activation))
|
||||
messages = list(request.messages)
|
||||
messages.insert(target_index, activation_msg)
|
||||
return request.override(messages=messages)
|
||||
|
||||
@staticmethod
|
||||
def _make_activation_message(target: HumanMessage, activation_content: str) -> HumanMessage:
|
||||
stable_id = target.id or str(uuid.uuid4())
|
||||
additional_kwargs = {
|
||||
"hide_from_ui": True,
|
||||
_SLASH_SKILL_ACTIVATION_KEY: True,
|
||||
}
|
||||
if target.id:
|
||||
additional_kwargs[_SLASH_SKILL_ACTIVATION_TARGET_ID_KEY] = target.id
|
||||
return HumanMessage(
|
||||
content=activation_content,
|
||||
id=f"{stable_id}__slash_activation",
|
||||
additional_kwargs=additional_kwargs,
|
||||
)
|
||||
|
||||
@override
|
||||
def wrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], ModelResponse],
|
||||
) -> ModelResponse | AIMessage:
|
||||
prepared = self._prepare_model_request(request, hook="wrap_model_call")
|
||||
if prepared is None:
|
||||
return handler(request)
|
||||
if isinstance(prepared, AIMessage):
|
||||
return prepared
|
||||
return handler(prepared)
|
||||
|
||||
@override
|
||||
async def awrap_model_call(
|
||||
self,
|
||||
request: ModelRequest,
|
||||
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||
) -> ModelResponse | AIMessage:
|
||||
prepared = await asyncio.to_thread(self._prepare_model_request, request, hook="awrap_model_call")
|
||||
if prepared is None:
|
||||
return await handler(request)
|
||||
if isinstance(prepared, AIMessage):
|
||||
return prepared
|
||||
return await handler(prepared)
|
||||
@@ -13,6 +13,7 @@ from langgraph.runtime import Runtime
|
||||
from deerflow.config.paths import Paths, get_paths
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
from deerflow.utils.file_conversion import extract_outline
|
||||
from deerflow.utils.messages import ORIGINAL_USER_CONTENT_KEY, message_content_to_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -265,6 +266,8 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
|
||||
# Extract original content - handle both string and list formats
|
||||
original_content = last_message.content
|
||||
additional_kwargs = dict(last_message.additional_kwargs or {})
|
||||
additional_kwargs.setdefault(ORIGINAL_USER_CONTENT_KEY, message_content_to_text(original_content))
|
||||
if isinstance(original_content, str):
|
||||
# Simple case: string content, just prepend files message
|
||||
updated_content = f"{files_message}\n\n{original_content}"
|
||||
@@ -285,7 +288,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]):
|
||||
content=updated_content,
|
||||
id=last_message.id,
|
||||
name=last_message.name,
|
||||
additional_kwargs=last_message.additional_kwargs,
|
||||
additional_kwargs=additional_kwargs,
|
||||
)
|
||||
|
||||
messages[last_message_index] = updated_message
|
||||
|
||||
@@ -247,7 +247,15 @@ class DeerFlowClient:
|
||||
# Attaching them again on the model would emit duplicate spans.
|
||||
"model": create_chat_model(name=model_name, thinking_enabled=thinking_enabled, attach_tracing=False),
|
||||
"tools": final_tools,
|
||||
"middleware": build_middlewares(config, model_name=model_name, agent_name=self._agent_name, custom_middlewares=self._middlewares, deferred_setup=deferred_setup),
|
||||
"middleware": build_middlewares(
|
||||
config,
|
||||
model_name=model_name,
|
||||
agent_name=self._agent_name,
|
||||
available_skills=self._available_skills,
|
||||
custom_middlewares=self._middlewares,
|
||||
app_config=self._app_config,
|
||||
deferred_setup=deferred_setup,
|
||||
),
|
||||
"system_prompt": apply_prompt_template(
|
||||
subagent_enabled=subagent_enabled,
|
||||
max_concurrent_subagents=max_concurrent_subagents,
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any, Self
|
||||
|
||||
import yaml
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
|
||||
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
|
||||
@@ -150,6 +150,21 @@ class AppConfig(BaseModel):
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("models", "tools", "tool_groups", mode="before")
|
||||
@classmethod
|
||||
def _coerce_null_list_sections(cls, value: Any) -> Any:
|
||||
"""Treat a present-but-empty config section as an empty list.
|
||||
|
||||
Commenting out every entry under a top-level YAML key — e.g. ``models:``
|
||||
with only comments beneath it, exactly as shipped in
|
||||
``config.example.yaml`` — makes PyYAML parse the value as ``None``.
|
||||
Without this, the documented ``cp config.example.yaml config.yaml``
|
||||
first-run flow crashes with an opaque ``Input should be a valid list``
|
||||
pydantic error. Coercing ``None`` to ``[]`` keeps that flow working and
|
||||
matches the field's own ``default_factory=list``.
|
||||
"""
|
||||
return [] if value is None else value
|
||||
|
||||
@classmethod
|
||||
def resolve_config_path(cls, config_path: str | None = None) -> Path:
|
||||
"""Resolve the config file path.
|
||||
@@ -211,6 +226,11 @@ class AppConfig(BaseModel):
|
||||
config_data["extensions"] = extensions_config.model_dump()
|
||||
|
||||
result = cls.model_validate(config_data)
|
||||
if not result.models:
|
||||
logger.warning(
|
||||
"No models are configured in %s. Add at least one entry under `models:` (see the commented examples in config.example.yaml) or run `make setup`.",
|
||||
resolved_path,
|
||||
)
|
||||
acp_agents = cls._validate_acp_agents(config_data.get("acp_agents", {}))
|
||||
cls._apply_singleton_configs(result, acp_agents)
|
||||
return result
|
||||
|
||||
@@ -4,7 +4,20 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
class VolumeMountConfig(BaseModel):
|
||||
"""Configuration for a volume mount."""
|
||||
|
||||
host_path: str = Field(..., description="Path on the host machine")
|
||||
host_path: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Source path for the mount. Resolution depends on the active provider: "
|
||||
"``LocalSandboxProvider`` checks this path from the gateway process — in "
|
||||
"``make dev`` that is the host machine, but in Docker deployments "
|
||||
"(``make up`` / docker-compose) it is the path *inside* the "
|
||||
"``deer-flow-gateway`` container, so the host directory must also be "
|
||||
"bind-mounted into the gateway service for the mount to take effect. "
|
||||
"``AioSandboxProvider`` (DooD) passes this value straight to ``docker -v`` "
|
||||
"for the sandbox container, where it is resolved by the host Docker daemon "
|
||||
"from the host machine's perspective."
|
||||
),
|
||||
)
|
||||
container_path: str = Field(..., description="Path inside the container")
|
||||
read_only: bool = Field(default=False, description="Whether the mount is read-only")
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Patched ChatOpenAI adapter for StepFun reasoning models.
|
||||
|
||||
StepFun returns ``reasoning`` (or ``reasoning_content`` with deepseek-style) in
|
||||
both streaming deltas and non-streaming responses. Standard ``ChatOpenAI``
|
||||
ignores these non-standard fields, so reasoning content is silently dropped.
|
||||
This adapter captures reasoning from all response paths and replays it on
|
||||
historical assistant messages for multi-turn tool-call conversations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, ChatResult
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from deerflow.models.assistant_payload_replay import (
|
||||
restore_assistant_payloads,
|
||||
restore_reasoning_content,
|
||||
)
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _extract_reasoning(value: Any) -> str | object:
|
||||
"""Return reasoning content from a dict/Pydantic object.
|
||||
|
||||
StepFun may return reasoning via ``reasoning`` (default) or
|
||||
``reasoning_content`` (deepseek-style). Check both fields.
|
||||
"""
|
||||
if isinstance(value, Mapping):
|
||||
# Check reasoning_content first (deepseek-style), then reasoning (default)
|
||||
for field in ("reasoning_content", "reasoning"):
|
||||
if field in value and value[field] is not None:
|
||||
return value[field]
|
||||
return _MISSING
|
||||
|
||||
# Pydantic / SDK object attributes
|
||||
for field in ("reasoning_content", "reasoning"):
|
||||
attr = getattr(value, field, _MISSING)
|
||||
if attr is not _MISSING and attr is not None:
|
||||
return attr
|
||||
|
||||
# Some SDK versions store extra fields in model_extra
|
||||
model_extra = getattr(value, "model_extra", None)
|
||||
if isinstance(model_extra, Mapping):
|
||||
for field in ("reasoning_content", "reasoning"):
|
||||
if field in model_extra and model_extra[field] is not None:
|
||||
return model_extra[field]
|
||||
|
||||
return _MISSING
|
||||
|
||||
|
||||
def _with_reasoning_content(message: AIMessage | AIMessageChunk, reasoning: str) -> AIMessage | AIMessageChunk:
|
||||
"""Return a copy of *message* with reasoning_content stored in additional_kwargs."""
|
||||
additional_kwargs = dict(message.additional_kwargs)
|
||||
if additional_kwargs.get("reasoning_content") != reasoning:
|
||||
additional_kwargs["reasoning_content"] = reasoning
|
||||
return message.model_copy(update={"additional_kwargs": additional_kwargs})
|
||||
|
||||
|
||||
def _get_typed_choice_message(response: Any, index: int) -> Any:
|
||||
"""Extract the SDK-typed choice message at *index*, if available."""
|
||||
choices = getattr(response, "choices", None)
|
||||
if choices is None:
|
||||
return None
|
||||
try:
|
||||
return choices[index].message
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
class PatchedChatStepFun(ChatOpenAI):
|
||||
"""ChatOpenAI with full reasoning support for StepFun models.
|
||||
|
||||
Captures ``reasoning`` / ``reasoning_content`` from both streaming and
|
||||
non-streaming responses and replays it on historical assistant messages in
|
||||
multi-turn tool-call conversations.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def lc_secrets(self) -> dict[str, str]:
|
||||
return {"api_key": "STEPFUN_API_KEY", "openai_api_key": "STEPFUN_API_KEY"}
|
||||
|
||||
# --- Request payload replay ---
|
||||
|
||||
def _get_request_payload(
|
||||
self,
|
||||
input_: LanguageModelInput,
|
||||
*,
|
||||
stop: list[str] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict:
|
||||
"""Restore ``reasoning_content`` on historical assistant messages."""
|
||||
original_messages = self._convert_input(input_).to_messages()
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
|
||||
restore_assistant_payloads(
|
||||
payload.get("messages", []),
|
||||
original_messages,
|
||||
restore_reasoning_content,
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
# --- Streaming reasoning capture ---
|
||||
|
||||
def _convert_chunk_to_generation_chunk(
|
||||
self,
|
||||
chunk: dict,
|
||||
default_chunk_class: type,
|
||||
base_generation_info: dict | None,
|
||||
) -> ChatGenerationChunk | None:
|
||||
"""Capture ``reasoning`` / ``reasoning_content`` from streaming deltas."""
|
||||
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
||||
chunk,
|
||||
default_chunk_class,
|
||||
base_generation_info,
|
||||
)
|
||||
if generation_chunk is None:
|
||||
return None
|
||||
|
||||
choices = chunk.get("choices", [])
|
||||
if choices:
|
||||
delta = choices[0].get("delta") or {}
|
||||
reasoning = _extract_reasoning(delta)
|
||||
if reasoning is not _MISSING and isinstance(generation_chunk.message, AIMessageChunk):
|
||||
generation_chunk = ChatGenerationChunk(
|
||||
message=_with_reasoning_content(generation_chunk.message, reasoning),
|
||||
generation_info=generation_chunk.generation_info,
|
||||
)
|
||||
|
||||
return generation_chunk
|
||||
|
||||
# --- Non-streaming reasoning capture ---
|
||||
|
||||
def _create_chat_result(
|
||||
self,
|
||||
response: dict | Any,
|
||||
generation_info: dict | None = None,
|
||||
) -> ChatResult:
|
||||
"""Extract ``reasoning`` / ``reasoning_content`` from non-streaming responses."""
|
||||
result = super()._create_chat_result(response, generation_info)
|
||||
response_dict = response if isinstance(response, dict) else response.model_dump()
|
||||
choices = response_dict.get("choices", [])
|
||||
|
||||
patched_generations: list[ChatGeneration] | None = None
|
||||
for index, generation in enumerate(result.generations):
|
||||
choice = choices[index] if index < len(choices) else {}
|
||||
choice_message = choice.get("message", {}) if isinstance(choice, Mapping) else {}
|
||||
reasoning = _extract_reasoning(choice_message)
|
||||
|
||||
if reasoning is _MISSING and not isinstance(response, dict):
|
||||
reasoning = _extract_reasoning(_get_typed_choice_message(response, index))
|
||||
|
||||
message = generation.message
|
||||
if reasoning is not _MISSING and isinstance(message, AIMessage):
|
||||
if patched_generations is None:
|
||||
patched_generations = list(result.generations)
|
||||
patched_generations[index] = ChatGeneration(
|
||||
message=_with_reasoning_content(message, reasoning),
|
||||
generation_info=generation.generation_info,
|
||||
)
|
||||
|
||||
return ChatResult(
|
||||
generations=patched_generations or result.generations,
|
||||
llm_output=result.llm_output,
|
||||
)
|
||||
@@ -164,7 +164,18 @@ class RunJournal(BaseCallbackHandler):
|
||||
metadata={"caller": caller, **(metadata or {})},
|
||||
)
|
||||
|
||||
def on_chain_end(self, outputs: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
||||
def on_chain_end(
|
||||
self,
|
||||
outputs: Any,
|
||||
*,
|
||||
run_id: UUID,
|
||||
parent_run_id: UUID | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# Nested chain ends fire for internal graph nodes; only the root chain
|
||||
# represents the user-visible run lifecycle.
|
||||
if parent_run_id is not None:
|
||||
return
|
||||
self._put(event_type="run.end", category="outputs", content=outputs, metadata={"status": "success"})
|
||||
self._flush_sync()
|
||||
|
||||
|
||||
@@ -147,7 +147,17 @@ class LocalSandboxProvider(SandboxProvider):
|
||||
mount.container_path,
|
||||
)
|
||||
continue
|
||||
# Ensure the host path exists before adding mapping
|
||||
# Ensure the host path exists before adding mapping.
|
||||
#
|
||||
# ``host_path`` is resolved against the filesystem of the
|
||||
# process running this provider — for ``make dev`` that is
|
||||
# the host machine, but for ``make up`` it is the
|
||||
# ``deer-flow-gateway`` container, so any host path that
|
||||
# isn't bind-mounted into the gateway image will be missing
|
||||
# here. Skipping silently makes this a high-cost-to-debug
|
||||
# silent failure (sandbox skill / tool reads an empty dir
|
||||
# instead of the configured mount), so escalate to ERROR
|
||||
# and include actionable guidance. See #3244.
|
||||
if host_path.exists():
|
||||
mappings.append(
|
||||
PathMapping(
|
||||
@@ -157,10 +167,16 @@ class LocalSandboxProvider(SandboxProvider):
|
||||
)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Mount host_path does not exist, skipping: %s -> %s",
|
||||
logger.error(
|
||||
"sandbox.mounts entry %s -> %s ignored: host_path %s does not exist from the "
|
||||
"perspective of the gateway process. In Docker deployments (make up / docker-compose), "
|
||||
"this path must also be bind-mounted into the gateway container — add a matching "
|
||||
"volume entry under services.gateway.volumes in docker/docker-compose.yaml (and use "
|
||||
"the in-container path here), or run in local mode (make dev) where the gateway sees "
|
||||
"the host filesystem directly.",
|
||||
mount.host_path,
|
||||
mount.container_path,
|
||||
mount.host_path,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail if config loading fails
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from deerflow.skills.types import Skill
|
||||
|
||||
RESERVED_SLASH_SKILL_NAMES = frozenset({"bootstrap", "help", "memory", "models", "new", "status"})
|
||||
_SLASH_SKILL_RE = re.compile(r"^/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s+|$)")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SlashSkillReference:
|
||||
"""Parsed slash-skill command with the skill name and remaining task text."""
|
||||
|
||||
name: str
|
||||
remaining_text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedSlashSkill:
|
||||
"""Slash-skill activation resolved against enabled runtime-visible skills."""
|
||||
|
||||
skill: Skill
|
||||
remaining_text: str
|
||||
container_file_path: str
|
||||
|
||||
|
||||
def parse_slash_skill_reference(text: str) -> SlashSkillReference | None:
|
||||
"""Parse strict `/skill-name task` syntax, ignoring reserved control commands."""
|
||||
match = _SLASH_SKILL_RE.match(text)
|
||||
if not match:
|
||||
return None
|
||||
name = match.group(1)
|
||||
if name in RESERVED_SLASH_SKILL_NAMES:
|
||||
return None
|
||||
return SlashSkillReference(
|
||||
name=name,
|
||||
remaining_text=text[match.end() :].lstrip(),
|
||||
)
|
||||
|
||||
|
||||
def resolve_slash_skill(
|
||||
text: str,
|
||||
skills: list[Skill],
|
||||
*,
|
||||
available_skills: set[str] | None = None,
|
||||
container_base_path: str = "/mnt/skills",
|
||||
) -> ResolvedSlashSkill | None:
|
||||
"""Resolve text into an enabled, whitelisted skill activation if possible."""
|
||||
reference = parse_slash_skill_reference(text)
|
||||
if reference is None:
|
||||
return None
|
||||
if available_skills is not None and reference.name not in available_skills:
|
||||
return None
|
||||
|
||||
skill = next((candidate for candidate in skills if candidate.name == reference.name and candidate.enabled), None)
|
||||
if skill is None:
|
||||
return None
|
||||
|
||||
return ResolvedSlashSkill(
|
||||
skill=skill,
|
||||
remaining_text=reference.remaining_text,
|
||||
container_file_path=skill.get_container_file_path(container_base_path),
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
ORIGINAL_USER_CONTENT_KEY = "original_user_content"
|
||||
|
||||
|
||||
def message_content_to_text(content: Any) -> str:
|
||||
"""Extract text from LangChain message content shapes."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
text = item.get("text")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(part for part in parts if part)
|
||||
return str(content)
|
||||
|
||||
|
||||
def get_original_user_content_text(content: Any, additional_kwargs: Mapping[str, Any] | None) -> str:
|
||||
"""Return pre-middleware user text when available, otherwise content text."""
|
||||
original_content = (additional_kwargs or {}).get(ORIGINAL_USER_CONTENT_KEY)
|
||||
if isinstance(original_content, str):
|
||||
return original_content
|
||||
return message_content_to_text(content)
|
||||
Reference in New Issue
Block a user